-
Notifications
You must be signed in to change notification settings - Fork 288
feat: add azure.provisioning extension (C# CDK importer) #7463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ext-importer-support
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # yaml-language-server: $schema=../extension.schema.json | ||
| id: azure.provisioning | ||
| namespace: provisioning | ||
| displayName: Azure.Provisioning (C# CDK) | ||
| description: Enables defining Azure infrastructure in C# using Azure.Provisioning instead of Bicep. | ||
| usage: azd provisioning <command> [options] | ||
| version: 0.1.0 | ||
| language: go | ||
| capabilities: | ||
| - importer-provider | ||
| providers: | ||
| - name: csharp | ||
| type: importer | ||
| description: Generates Bicep from C# Azure.Provisioning code |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/extensions/azure.provisioning/internal/project" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/azdext" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func NewRootCommand() *cobra.Command { | ||
| root := &cobra.Command{ | ||
| Use: "provisioning", | ||
| Short: "Azure.Provisioning C# CDK extension", | ||
| } | ||
| root.AddCommand(newListenCommand()) | ||
| return root | ||
| } | ||
|
|
||
| func newListenCommand() *cobra.Command { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] Should be |
||
| return &cobra.Command{ | ||
| Use: "listen", | ||
| Short: "Starts the extension and listens for events.", | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| ctx := azdext.WithAccessToken(cmd.Context()) | ||
|
|
||
| azdClient, err := azdext.NewAzdClient() | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create azd client: %w", err) | ||
| } | ||
| defer azdClient.Close() | ||
|
|
||
| host := azdext.NewExtensionHost(azdClient). | ||
| WithImporter("csharp", func() azdext.ImporterProvider { | ||
| return project.NewCSharpImporterProvider(azdClient) | ||
| }) | ||
|
|
||
| if err := host.Run(ctx); err != nil { | ||
| return fmt.Errorf("failed to run extension: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| }, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,279 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package project | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/pkg/azdext" | ||
| ) | ||
|
|
||
| var _ azdext.ImporterProvider = &CSharpImporterProvider{} | ||
|
|
||
| const defaultInfraDir = "infra" | ||
|
|
||
| // CSharpImporterProvider generates Bicep infrastructure from C# Azure.Provisioning code. | ||
| // It detects .cs files in the infra directory, runs them with `dotnet run`, and captures | ||
| // the generated Bicep output. | ||
| type CSharpImporterProvider struct { | ||
| azdClient *azdext.AzdClient | ||
| } | ||
|
|
||
| func NewCSharpImporterProvider(azdClient *azdext.AzdClient) azdext.ImporterProvider { | ||
| return &CSharpImporterProvider{azdClient: azdClient} | ||
| } | ||
|
|
||
| // CanImport checks if this importer can handle the given service. | ||
| // This importer is infra-only (configured via infra.importer in azure.yaml), | ||
| // so it always returns false for service auto-detection. | ||
| func (p *CSharpImporterProvider) CanImport( | ||
| ctx context.Context, | ||
| svcConfig *azdext.ServiceConfig, | ||
| ) (bool, error) { | ||
| return false, nil | ||
| } | ||
|
|
||
| // Services returns the original service as-is. This importer handles infrastructure | ||
| // generation, not service extraction. | ||
| func (p *CSharpImporterProvider) Services( | ||
| ctx context.Context, | ||
| projectConfig *azdext.ProjectConfig, | ||
| svcConfig *azdext.ServiceConfig, | ||
| ) (map[string]*azdext.ServiceConfig, error) { | ||
| return map[string]*azdext.ServiceConfig{ | ||
| svcConfig.Name: svcConfig, | ||
| }, nil | ||
| } | ||
|
|
||
| // ProjectInfrastructure compiles C# Azure.Provisioning code to Bicep for `azd provision`. | ||
| func (p *CSharpImporterProvider) ProjectInfrastructure( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] |
||
| ctx context.Context, | ||
| projectPath string, | ||
| options map[string]string, | ||
| progress azdext.ProgressReporter, | ||
| ) (*azdext.ImporterProjectInfrastructureResponse, error) { | ||
| infraPath := resolvePath(projectPath, options) | ||
|
|
||
| progress("Detecting C# infrastructure entry point...") | ||
| entryPoint, err := resolveEntryPoint(infraPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("resolving C# entry point: %w", err) | ||
| } | ||
|
|
||
| // Create temp directory for Bicep output | ||
| tempDir, err := os.MkdirTemp("", "azd-csharp-bicep-*") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating temp directory: %w", err) | ||
| } | ||
| defer os.RemoveAll(tempDir) | ||
|
|
||
| progress(fmt.Sprintf("Compiling C# infrastructure from %s...", filepath.Base(entryPoint))) | ||
|
|
||
| // Forward importer options (excluding "path") as --key value args to the C# program | ||
| extraArgs := optionsToArgs(options) | ||
|
|
||
| // Run the C# program | ||
| if err := runDotnet(ctx, entryPoint, tempDir, extraArgs); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Read generated files | ||
| files, err := readGeneratedFiles(tempDir) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading generated Bicep: %w", err) | ||
| } | ||
|
|
||
| if len(files) == 0 { | ||
| return nil, fmt.Errorf( | ||
| "no .bicep files generated by %s. Ensure your program calls Build().Save(outputDir) "+ | ||
| "with the output directory passed as the first argument", entryPoint) | ||
| } | ||
|
|
||
| progress(fmt.Sprintf("Generated %d Bicep file(s)", len(files))) | ||
|
|
||
| return &azdext.ImporterProjectInfrastructureResponse{ | ||
| InfraOptions: &azdext.InfraOptions{ | ||
| Provider: "bicep", | ||
| Module: "main", | ||
| }, | ||
| Files: files, | ||
| }, nil | ||
| } | ||
|
|
||
| // GenerateAllInfrastructure generates Bicep files for `azd infra gen` (ejection). | ||
| func (p *CSharpImporterProvider) GenerateAllInfrastructure( | ||
| ctx context.Context, | ||
| projectPath string, | ||
| options map[string]string, | ||
| ) ([]*azdext.GeneratedFile, error) { | ||
| infraPath := resolvePath(projectPath, options) | ||
|
|
||
| entryPoint, err := resolveEntryPoint(infraPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("resolving C# entry point: %w", err) | ||
| } | ||
|
|
||
| tempDir, err := os.MkdirTemp("", "azd-csharp-bicep-*") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating temp directory: %w", err) | ||
| } | ||
| defer os.RemoveAll(tempDir) | ||
|
|
||
| if err := runDotnet(ctx, entryPoint, tempDir, optionsToArgs(options)); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| files, err := readGeneratedFiles(tempDir) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading generated Bicep: %w", err) | ||
| } | ||
|
|
||
| // Prefix paths with infra/ for ejection | ||
| for _, f := range files { | ||
| f.Path = "infra/" + f.Path | ||
| } | ||
|
|
||
| return files, nil | ||
| } | ||
|
|
||
| // resolvePath determines the directory containing C# infrastructure files. | ||
| func resolvePath(projectPath string, options map[string]string) string { | ||
| dir := defaultInfraDir | ||
| if v, ok := options["path"]; ok && v != "" { | ||
| dir = v | ||
| } | ||
| return filepath.Join(projectPath, dir) | ||
| } | ||
|
|
||
| // hasCSharpInfra checks if a directory contains .cs or .csproj files. | ||
| func hasCSharpInfra(path string) bool { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] Dead code - not called anywhere. The |
||
| entries, err := os.ReadDir(path) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| for _, e := range entries { | ||
| if e.IsDir() { | ||
| continue | ||
| } | ||
| ext := strings.ToLower(filepath.Ext(e.Name())) | ||
| if ext == ".cs" || ext == ".csproj" { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // resolveEntryPoint finds the C# entry point in the given directory. | ||
| // Prefers .csproj over single .cs file. | ||
| func resolveEntryPoint(infraPath string) (string, error) { | ||
| info, err := os.Stat(infraPath) | ||
| if err != nil { | ||
| return "", fmt.Errorf("path '%s' does not exist: %w", infraPath, err) | ||
| } | ||
|
|
||
| if !info.IsDir() { | ||
| ext := strings.ToLower(filepath.Ext(infraPath)) | ||
| if ext == ".cs" || ext == ".csproj" { | ||
| return infraPath, nil | ||
| } | ||
| return "", fmt.Errorf("'%s' is not a .cs or .csproj file", infraPath) | ||
| } | ||
|
|
||
| // Check for .csproj first | ||
| csprojFiles, _ := filepath.Glob(filepath.Join(infraPath, "*.csproj")) | ||
| if len(csprojFiles) > 0 { | ||
| return infraPath, nil | ||
| } | ||
|
|
||
| // Fall back to single .cs file | ||
| csFiles, _ := filepath.Glob(filepath.Join(infraPath, "*.cs")) | ||
| if len(csFiles) == 1 { | ||
| return csFiles[0], nil | ||
| } | ||
| if len(csFiles) > 1 { | ||
| return "", fmt.Errorf( | ||
| "multiple .cs files in '%s' — use a single .cs file or add a .csproj", infraPath) | ||
| } | ||
|
|
||
| return "", fmt.Errorf("no .cs or .csproj files found in '%s'", infraPath) | ||
| } | ||
|
|
||
| // optionsToArgs converts the importer options map to --key value CLI args, | ||
| // excluding the "path" key which is used for directory resolution. | ||
| func optionsToArgs(options map[string]string) []string { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] Go 1.26 convention: use |
||
| // Sort keys for deterministic ordering | ||
| keys := make([]string, 0, len(options)) | ||
| for k := range options { | ||
| if k == "path" { | ||
| continue | ||
| } | ||
| keys = append(keys, k) | ||
| } | ||
| sort.Strings(keys) | ||
|
|
||
| var args []string | ||
| for _, k := range keys { | ||
| args = append(args, "--"+k, options[k]) | ||
| } | ||
| return args | ||
| } | ||
|
|
||
| // runDotnet executes the C# entry point with the output directory as the first argument, | ||
| // followed by any extra args from importer options. | ||
| func runDotnet(ctx context.Context, entryPoint string, outputDir string, extraArgs []string) error { | ||
| var args []string | ||
| if strings.HasSuffix(strings.ToLower(entryPoint), ".cs") { | ||
| args = []string{"run", entryPoint, "--", outputDir} | ||
| } else { | ||
| args = []string{"run", "--project", entryPoint, "--", outputDir} | ||
| } | ||
| args = append(args, extraArgs...) | ||
|
|
||
| cmd := exec.CommandContext(ctx, "dotnet", args...) | ||
| cmd.Env = append(os.Environ(), | ||
| "DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=1", | ||
| "DOTNET_NOLOGO=1", | ||
| ) | ||
|
|
||
| output, err := cmd.CombinedOutput() | ||
| if err != nil { | ||
| return fmt.Errorf("dotnet run failed: %w\nOutput: %s", err, string(output)) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // readGeneratedFiles reads all .bicep and .json files from a directory. | ||
| func readGeneratedFiles(dir string) ([]*azdext.GeneratedFile, error) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] This only reads top-level entries. If the C# program generates nested Bicep modules (e.g., Use |
||
| var files []*azdext.GeneratedFile | ||
|
|
||
| entries, err := os.ReadDir(dir) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| for _, e := range entries { | ||
| if e.IsDir() { | ||
| continue | ||
| } | ||
| ext := strings.ToLower(filepath.Ext(e.Name())) | ||
| if ext == ".bicep" || ext == ".json" { | ||
| content, err := os.ReadFile(filepath.Join(dir, e.Name())) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading %s: %w", e.Name(), err) | ||
| } | ||
| files = append(files, &azdext.GeneratedFile{ | ||
| Path: e.Name(), | ||
| Content: content, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return files, nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "github.com/azure/azure-dev/cli/azd/extensions/azure.provisioning/internal/cmd" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/azdext" | ||
| ) | ||
|
|
||
| func main() { | ||
| azdext.Run(cmd.NewRootCommand()) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[HIGH] Missing standard extension conventions:
SilenceUsage: true,SilenceErrors: true,CompletionOptions, hidden help command,--debug/--no-promptpersistent flags, andmetadata/versionsubcommands. Seeazure.appserviceroot.gofor the pattern.