diff --git a/src/Bicep.LangServer/Handlers/GetDeploymentDataHandler.cs b/src/Bicep.LangServer/Handlers/GetDeploymentDataHandler.cs index e38aad38d52..db72d166d04 100644 --- a/src/Bicep.LangServer/Handlers/GetDeploymentDataHandler.cs +++ b/src/Bicep.LangServer/Handlers/GetDeploymentDataHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Bicep.Core.Semantics; using Bicep.Core.Workspaces; using Bicep.LanguageServer.CompilationManager; using MediatR; @@ -14,7 +15,7 @@ namespace Bicep.LanguageServer.Handlers public record GetDeploymentDataRequest(TextDocumentIdentifier TextDocument) : ITextDocumentIdentifierParams, IRequest; - public record GetDeploymentDataResponse(string? TemplateJson = null, string? ParametersJson = null, string? ErrorMessage = null); + public record GetDeploymentDataResponse(bool LocalDeployEnabled, string? TemplateJson = null, string? ParametersJson = null, string? ErrorMessage = null); public class GetDeploymentDataHandler : IJsonRpcRequestHandler { @@ -33,10 +34,14 @@ public async Task Handle(GetDeploymentDataRequest req if (this.compilationManager.GetCompilation(request.TextDocument.Uri) is not { } context) { - return new(ErrorMessage: $"Bicep compilation failed. An unexpected error occurred."); + return new(ErrorMessage: $"Bicep compilation failed. An unexpected error occurred.", LocalDeployEnabled: false); } var semanticModel = context.Compilation.GetEntrypointSemanticModel(); + var localDeployEnabled = semanticModel.Features.LocalDeployEnabled; + + string? paramsFile = null; + string? templateFile = null; if (semanticModel.Root.FileKind == BicepSourceFileKind.ParamsFile) { var result = context.Compilation.Emitter.Parameters(); @@ -44,21 +49,23 @@ public async Task Handle(GetDeploymentDataRequest req if (result.Parameters is null || result.Template?.Template is null) { - return new(ErrorMessage: $"Bicep compilation failed. The Bicep parameters file contains errors."); + return new(ErrorMessage: $"Bicep compilation failed. The Bicep parameters file contains errors.", LocalDeployEnabled: localDeployEnabled); } - return new(TemplateJson: result.Template.Template, ParametersJson: result.Parameters); + paramsFile = result.Parameters; + templateFile = result.Template.Template; + + if (!semanticModel.Root.TryGetBicepFileSemanticModelViaUsing().IsSuccess(out var usingModel)) + { + return new(ErrorMessage: $"Bicep compilation failed. The Bicep parameters file contains errors.", LocalDeployEnabled: localDeployEnabled); + } } else { - var result = context.Compilation.Emitter.Template(); - if (result.Template is null) - { - return new(ErrorMessage: $"Bicep compilation failed. The Bicep file contains errors."); - } - - return new(TemplateJson: result.Template); + return new(ErrorMessage: $"Bicep compilation failed. The Bicep file contains errors.", LocalDeployEnabled: localDeployEnabled); } + + return new(TemplateJson: templateFile, ParametersJson: paramsFile, LocalDeployEnabled: localDeployEnabled); } } } diff --git a/src/Bicep.LangServer/Handlers/LocalDeployHandler.cs b/src/Bicep.LangServer/Handlers/LocalDeployHandler.cs new file mode 100644 index 00000000000..a734b274277 --- /dev/null +++ b/src/Bicep.LangServer/Handlers/LocalDeployHandler.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Local.Deploy; +using Bicep.Local.Deploy.Extensibility; +using Azure.Deployments.Core.Definitions; +using Azure.Deployments.Core.ErrorResponses; +using Bicep.Core.Extensions; +using Bicep.Core.Registry; +using Bicep.Core.Semantics; +using Bicep.Core.TypeSystem.Types; +using Bicep.LanguageServer.CompilationManager; +using MediatR; +using Microsoft.WindowsAzure.ResourceStack.Common.Json; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using OmniSharp.Extensions.LanguageServer.Protocol.Window; + +namespace Bicep.LanguageServer.Handlers; + +[Method("bicep/localDeploy", Direction.ClientToServer)] +public record LocalDeployRequest(TextDocumentIdentifier TextDocument) + : ITextDocumentIdentifierParams, IRequest; + +public record LocalDeploymentContent( + string ProvisioningState, + ImmutableDictionary Outputs, + LocalDeploymentOperationError? Error); + +public record LocalDeploymentOperationError( + string Code, + string Message, + string Target); + +public record LocalDeploymentOperationContent( + string ResourceName, + string ProvisioningState, + LocalDeploymentOperationError? Error); + +public record LocalDeployResponse( + LocalDeploymentContent Deployment, + ImmutableArray Operations); + +public class LocalDeployHandler : IJsonRpcRequestHandler +{ + private readonly IModuleDispatcher moduleDispatcher; + private readonly ICompilationManager compilationManager; + private readonly ILanguageServerFacade server; + + public LocalDeployHandler(IModuleDispatcher moduleDispatcher, ICompilationManager compilationManager, ILanguageServerFacade server) + { + this.moduleDispatcher = moduleDispatcher; + this.compilationManager = compilationManager; + this.server = server; + } + + public async Task Handle(LocalDeployRequest request, CancellationToken cancellationToken) + { + try + { + if (this.compilationManager.GetCompilation(request.TextDocument.Uri) is not { } context) + { + throw new InvalidOperationException("Failed to find active compilation."); + } + + var paramsModel = context.Compilation.GetEntrypointSemanticModel(); + //Failure scenario is ignored since a diagnostic for it would be emitted during semantic analysis + if (paramsModel.HasErrors() || + !paramsModel.Root.TryGetBicepFileSemanticModelViaUsing().IsSuccess(out var usingModel)) + { + throw new InvalidOperationException("Bicep file had errors."); + } + + var parameters = context.Compilation.Emitter.Parameters(); + if (parameters.Parameters is not {} parametersString || + parameters.Template?.Template is not {} templateString) + { + throw new InvalidOperationException("Bicep file had errors."); + } + + await using LocalExtensibilityHandler extensibilityHandler = new(moduleDispatcher, GrpcExtensibilityProvider.Start); + await extensibilityHandler.InitializeProviders(context.Compilation); + + var result = await LocalDeployment.Deploy(extensibilityHandler, templateString, parametersString, cancellationToken); + + return FromResult(result); + } + catch (Exception ex) + { + server.Window.LogError($"Unhandled exception during local deployment: {ex}"); + return new( + new("Failed", ImmutableDictionary.Empty, new("UnhandledException", ex.Message, "")), + ImmutableArray.Empty + ); + } + } + + private static LocalDeploymentOperationContent FromOperation(DeploymentOperationDefinition operation) + { + var result = operation.Properties.StatusMessage.TryFromJToken(); + var error = result?.Error?.Message.TryFromJson()?.Error; + var operationError = error is {} ? new LocalDeploymentOperationError(error.Code, error.Message, error.Target) : null; + + return new LocalDeploymentOperationContent( + operation.Properties.TargetResource.SymbolicName, + operation.Properties.ProvisioningState.ToString(), + operationError); + } + + private static LocalDeployResponse FromResult(LocalDeployment.Result result) + { + var deployError = result.Deployment.Properties.Error is {} error ? + new LocalDeploymentOperationError(error.Code, error.Message, error.Target) : null; + + + LocalDeploymentContent deployment = new( + result.Deployment.Properties.ProvisioningState.ToString() ?? "Failed", + result.Deployment.Properties.Outputs?.ToImmutableDictionary(x => x.Key, x => x.Value.Value) ?? ImmutableDictionary.Empty, + deployError); + + var operations = result.Operations.Select(FromOperation).ToImmutableArray(); + + return new(deployment, operations); + } +} \ No newline at end of file diff --git a/src/Bicep.LangServer/Server.cs b/src/Bicep.LangServer/Server.cs index fbfe8addf7e..17a19e966fe 100644 --- a/src/Bicep.LangServer/Server.cs +++ b/src/Bicep.LangServer/Server.cs @@ -66,6 +66,7 @@ public Server(Action onOptionsFunc) .WithHandler() .WithHandler() .WithHandler() + .WithHandler() .WithServices(RegisterServices); onOptionsFunc(options); diff --git a/src/vscode-bicep/src/language/protocol.ts b/src/vscode-bicep/src/language/protocol.ts index e5ac7fed072..863b9672826 100644 --- a/src/vscode-bicep/src/language/protocol.ts +++ b/src/vscode-bicep/src/language/protocol.ts @@ -46,6 +46,7 @@ export interface GetDeploymentDataRequest { } export interface GetDeploymentDataResponse { + localDeployEnabled: boolean; templateJson?: string; parametersJson?: string; errorMessage?: string; @@ -59,6 +60,41 @@ export const getDeploymentDataRequestType = new ProtocolRequestType< void >("bicep/getDeploymentData"); +export interface LocalDeployRequest { + textDocument: TextDocumentIdentifier; +} + +export interface LocalDeploymentOperationError { + code: string; + message: string; + target: string; +} + +export interface LocalDeploymentOperationContent { + resourceName: string; + provisioningState: string; + error?: LocalDeploymentOperationError; +} + +interface LocalDeploymentContent { + provisioningState: string; + outputs: Record; + error?: LocalDeploymentOperationError; +} + +export interface LocalDeployResponse { + deployment: LocalDeploymentContent; + operations: LocalDeploymentOperationContent[]; +} + +export const localDeployRequestType = new ProtocolRequestType< + LocalDeployRequest, + LocalDeployResponse, + never, + void, + void +>("bicep/localDeploy"); + export interface BicepDeploymentScopeParams { textDocument: TextDocumentIdentifier; } diff --git a/src/vscode-bicep/src/panes/deploy/app/components/App.tsx b/src/vscode-bicep/src/panes/deploy/app/components/App.tsx index 2409bb84668..6a7cb1cb98f 100644 --- a/src/vscode-bicep/src/panes/deploy/app/components/App.tsx +++ b/src/vscode-bicep/src/panes/deploy/app/components/App.tsx @@ -13,10 +13,12 @@ import { ParametersInputView } from "./sections/ParametersInputView"; import { useAzure } from "./hooks/useAzure"; import { DeploymentScopeInputView } from "./sections/DeploymentScopeInputView"; import { FormSection } from "./sections/FormSection"; +import { LocalDeployOperations, LocalDeployOutputs, LocalDeployResult } from "./localDeploy"; export const App: FC = () => { const [errorMessage, setErrorMessage] = useState(); - const messages = useMessageHandler({ setErrorMessage }); + const [localDeployRunning, setLocalDeployRunning] = useState(false); + const messages = useMessageHandler({ setErrorMessage, setLocalDeployRunning }); const azure = useAzure({ scope: messages.scope, acquireAccessToken: messages.acquireAccessToken, @@ -51,46 +53,101 @@ export const App: FC = () => { await azure.whatIf(); } + async function handleLocalDeployClick() { + messages.publishTelemetry('deployPane/localDeploy', {}); + messages.startLocalDeploy(); + } + + if (!messages.messageState.initialized) { + return ( + + ); + } + + if (messages.messageState.localDeployEnabled && !messages.paramsMetadata.sourceFilePath?.endsWith('.bicepparam')) { + return ( +
+ Local Deployment is only currently supported for .bicepparam files. Relaunch this pane for a .bicepparam file. +
+ ); + } + return (
- -
- - The Bicep Deployment Pane is an experimental feature. -
- Documentation is available here. Please raise issues or feature requests here. -
-
- - - - - - {errorMessage &&
- - {errorMessage} -
} -
- Deploy - Validate - What-If -
- {azure.running && } -
- - {messages.scope && <> - - - - + {!messages.messageState.localDeployEnabled && <> + +
+ + The Bicep Deployment Pane is an experimental feature. +
+ Documentation is available here. Please raise issues or feature requests here. +
+
+ + + + + + {errorMessage &&
+ + {errorMessage} +
} +
+ Deploy + Validate + What-If +
+ {azure.running && } +
+ + {messages.scope && <> + + + + + } + } + + {messages.messageState.localDeployEnabled && <> + +
+ + Local Deployment is an experimental feature. +
+
+ + + + + {errorMessage &&
+ + {errorMessage} +
} +
+ Deploy +
+ {localDeployRunning && } +
+ + {!localDeployRunning && messages.localDeployResult && <> + + + + } }
); diff --git a/src/vscode-bicep/src/panes/deploy/app/components/hooks/useMessageHandler.ts b/src/vscode-bicep/src/panes/deploy/app/components/hooks/useMessageHandler.ts index 0a14c9fb7c3..fa64ab8715d 100644 --- a/src/vscode-bicep/src/panes/deploy/app/components/hooks/useMessageHandler.ts +++ b/src/vscode-bicep/src/panes/deploy/app/components/hooks/useMessageHandler.ts @@ -7,6 +7,7 @@ import { createGetAccessTokenMessage, createGetDeploymentScopeMessage, createGetStateMessage, + createLocalDeployMessage, createPickParamsFileMessage, createPublishTelemetryMessage, createReadyMessage, @@ -22,6 +23,7 @@ import { } from "../../../models"; import { AccessToken } from "@azure/identity"; import { TelemetryProperties } from "@microsoft/vscode-azext-utils"; +import { LocalDeployResponse } from "../../../../../language"; // TODO see if there's a way to use react hooks instead of this hackery let accessTokenResolver: { @@ -31,21 +33,37 @@ let accessTokenResolver: { export interface UseMessageHandlerProps { setErrorMessage: (message?: string) => void; + setLocalDeployRunning: (running: boolean) => void; } +type MessageHandlerState = { + initialized: boolean; + localDeployEnabled: boolean; +}; + export function useMessageHandler(props: UseMessageHandlerProps) { - const { setErrorMessage } = props; + const { setErrorMessage, setLocalDeployRunning } = props; const [persistedState, setPersistedState] = useState(); const [templateMetadata, setTemplateMetadata] = useState(); const [paramsMetadata, setParamsMetadata] = useState({ parameters: {}, }); + const [messageState, setMessageState] = useState({ + initialized: false, + localDeployEnabled: false, + }); const [scope, setScope] = useState(); + const [localDeployResult, setLocalDeployResult] = + useState(); const handleMessageEvent = (e: MessageEvent) => { const message = e.data; switch (message.kind) { case "DEPLOYMENT_DATA": { + setMessageState({ + initialized: true, + localDeployEnabled: message.localDeployEnabled, + }); if (!message.templateJson) { setTemplateMetadata(undefined); setErrorMessage( @@ -109,6 +127,11 @@ export function useMessageHandler(props: UseMessageHandlerProps) { savePersistedState({ ...persistedState, scope: message.scope }); return; } + case "LOCAL_DEPLOY_RESULT": { + setLocalDeployResult(message); + setLocalDeployRunning(false); + return; + } } }; @@ -154,6 +177,11 @@ export function useMessageHandler(props: UseMessageHandlerProps) { return promise; } + function startLocalDeploy() { + setLocalDeployRunning(true); + vscode.postMessage(createLocalDeployMessage()); + } + return { pickParamsFile, paramsMetadata, @@ -163,5 +191,8 @@ export function useMessageHandler(props: UseMessageHandlerProps) { pickScope, scope, publishTelemetry, + startLocalDeploy, + localDeployResult, + messageState, }; } diff --git a/src/vscode-bicep/src/panes/deploy/app/components/localDeploy.tsx b/src/vscode-bicep/src/panes/deploy/app/components/localDeploy.tsx new file mode 100644 index 00000000000..3dc32d1cfe3 --- /dev/null +++ b/src/vscode-bicep/src/panes/deploy/app/components/localDeploy.tsx @@ -0,0 +1,78 @@ +import { VSCodeDataGrid, VSCodeDataGridCell, VSCodeDataGridRow } from "@vscode/webview-ui-toolkit/react"; +import { FormSection } from "./sections/FormSection"; +import { LocalDeployResponse, LocalDeploymentOperationContent } from "../../../../language"; +import { getPreformattedJson } from "./utils"; +import { FC } from "react"; + +export const LocalDeployResult: FC<{ result: LocalDeployResponse }> = ({ result }) => { + const error = result.deployment.error; + return ( + +

{result.deployment.provisioningState}

+ {error && + + {error && + Code + {error.code} + } + {error.message && + Message + {error.message} + } + } +
+ ); +} + +export const LocalDeployOperations: FC<{ result: LocalDeployResponse }> = ({ result }) => { + if (!result.operations) { + return null; + } + + return ( + + + + Resource Name + State + Error + + {result.operations.map(operation => ( + + {operation.resourceName} + {operation.provisioningState} + {operation.error ? getPreformattedJson(operation.error) : ''} + + ))} + + + ); +} + + +export const LocalDeployOutputs: FC<{ result: LocalDeployResponse }> = ({ result }) => { + if (!result.deployment.outputs) { + return null; + } + + return ( + + + + Name + Value + + {Object.keys(result.deployment.outputs).map(name => ( + + {name} + {getPreformattedJson(result.deployment.outputs[name])} + + ))} + + + ); +} + +function isFailed(operation: LocalDeploymentOperationContent) { + return operation.provisioningState.toLowerCase() === "failed"; +} \ No newline at end of file diff --git a/src/vscode-bicep/src/panes/deploy/messages.ts b/src/vscode-bicep/src/panes/deploy/messages.ts index 5dfeb7affe5..c3dc11846ba 100644 --- a/src/vscode-bicep/src/panes/deploy/messages.ts +++ b/src/vscode-bicep/src/panes/deploy/messages.ts @@ -8,6 +8,7 @@ import { UntypedError, } from "./models"; import { TelemetryProperties } from "@microsoft/vscode-azext-utils"; +import { LocalDeployResponse } from "../../language"; interface SimpleMessage { kind: T; @@ -24,6 +25,7 @@ export type DeploymentDataMessage = MessageWithPayload< "DEPLOYMENT_DATA", { documentPath: string; + localDeployEnabled: boolean; templateJson?: string; parametersJson?: string; errorMessage?: string; @@ -31,12 +33,14 @@ export type DeploymentDataMessage = MessageWithPayload< >; export function createDeploymentDataMessage( documentPath: string, + localDeployEnabled: boolean, templateJson?: string, parametersJson?: string, errorMessage?: string, ): DeploymentDataMessage { return createMessageWithPayload("DEPLOYMENT_DATA", { documentPath, + localDeployEnabled, templateJson, parametersJson, errorMessage, @@ -174,12 +178,28 @@ export function createPublishTelemetryMessage( }); } +export type LocalDeployMessage = SimpleMessage<"LOCAL_DEPLOY">; +export function createLocalDeployMessage(): LocalDeployMessage { + return createSimpleMessage("LOCAL_DEPLOY"); +} + +export type LocalDeployResultMessage = MessageWithPayload< + "LOCAL_DEPLOY_RESULT", + LocalDeployResponse +>; +export function createLocalDeployResultMessage( + response: LocalDeployResponse, +): LocalDeployResultMessage { + return createMessageWithPayload("LOCAL_DEPLOY_RESULT", response); +} + export type VscodeMessage = | DeploymentDataMessage | GetStateResultMessage | PickParamsFileResultMessage | GetAccessTokenResultMessage - | GetDeploymentScopeResultMessage; + | GetDeploymentScopeResultMessage + | LocalDeployResultMessage; export type ViewMessage = | ReadyMessage @@ -188,7 +208,8 @@ export type ViewMessage = | PickParamsFileMessage | GetAccessTokenMessage | GetDeploymentScopeMessage - | PublishTelemetryMessage; + | PublishTelemetryMessage + | LocalDeployMessage; function createSimpleMessage(kind: T): SimpleMessage { return { kind }; diff --git a/src/vscode-bicep/src/panes/deploy/view.ts b/src/vscode-bicep/src/panes/deploy/view.ts index 3eda9857276..2d817a68c5c 100644 --- a/src/vscode-bicep/src/panes/deploy/view.ts +++ b/src/vscode-bicep/src/panes/deploy/view.ts @@ -10,10 +10,14 @@ import { createGetAccessTokenResultMessage, createGetDeploymentScopeResultMessage, createGetStateResultMessage, + createLocalDeployResultMessage, createPickParamsFileResultMessage, ViewMessage, } from "./messages"; -import { getDeploymentDataRequestType } from "../../language"; +import { + getDeploymentDataRequestType, + localDeployRequestType, +} from "../../language"; import { Disposable } from "../../utils/disposable"; import { debounce } from "../../utils/time"; import { getLogger } from "../../utils/logger"; @@ -32,6 +36,7 @@ export class DeployPaneView extends Disposable { private readonly onDidChangeViewStateEmitter: vscode.EventEmitter; private readyToRender = false; + private document?: vscode.TextDocument; private constructor( private readonly extensionContext: ExtensionContext, @@ -151,9 +156,8 @@ export class DeployPaneView extends Disposable { return; } - let document: vscode.TextDocument; try { - document = await vscode.workspace.openTextDocument(this.documentUri); + this.document = await vscode.workspace.openTextDocument(this.documentUri); } catch { this.webviewPanel.webview.html = this.createDocumentNotFoundHtml(); return; @@ -168,7 +172,7 @@ export class DeployPaneView extends Disposable { { textDocument: this.languageClient.code2ProtocolConverter.asTextDocumentIdentifier( - document, + this.document, ), }, ); @@ -181,6 +185,7 @@ export class DeployPaneView extends Disposable { await this.webviewPanel.webview.postMessage( createDeploymentDataMessage( this.documentUri.fsPath, + deploymentData.localDeployEnabled, deploymentData.templateJson, deploymentData.parametersJson, deploymentData.errorMessage, @@ -277,6 +282,22 @@ export class DeployPaneView extends Disposable { ); return; } + case "LOCAL_DEPLOY": { + const result = await this.languageClient.sendRequest( + localDeployRequestType, + { + textDocument: + this.languageClient.code2ProtocolConverter.asTextDocumentIdentifier( + this.document!, + ), + }, + ); + + await this.webviewPanel.webview.postMessage( + createLocalDeployResultMessage(result), + ); + return; + } } }