From 4d7f930f8c9647c86af53789c32a1b03b23bffd6 Mon Sep 17 00:00:00 2001 From: Karishma Chawla Date: Fri, 15 May 2026 14:07:30 -0700 Subject: [PATCH 1/2] Add multi-file merge support to manifest-to-bicep generate command Update the existing 'generate' subcommand of manifest-to-bicep to accept multiple manifest files. When given multiple YAML manifests with the same namespace, their Types maps are merged into a single output (types.json, index.json, index.md). This supports per-type manifest files (e.g. containers.yaml, routes.yaml) as introduced by the automated resource type registration design, where each file defines a single resource type within a namespace. Backward compatible: single-file usage works exactly as before. Part of: unified Bicep extension publishing (PR 1/4) Signed-off-by: Karishma Chawla --- bicep-tools/cmd/manifest-to-bicep/main.go | 129 +++++++++++++++--- .../cmd/manifest-to-bicep/main_test.go | 120 ++++++++++++++++ .../testdata/containers.yaml | 13 ++ .../manifest-to-bicep/testdata/routes.yaml | 16 +++ .../manifest-to-bicep/testdata/secrets.yaml | 11 ++ 5 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 bicep-tools/cmd/manifest-to-bicep/main_test.go create mode 100644 bicep-tools/cmd/manifest-to-bicep/testdata/containers.yaml create mode 100644 bicep-tools/cmd/manifest-to-bicep/testdata/routes.yaml create mode 100644 bicep-tools/cmd/manifest-to-bicep/testdata/secrets.yaml diff --git a/bicep-tools/cmd/manifest-to-bicep/main.go b/bicep-tools/cmd/manifest-to-bicep/main.go index 5c3fc8ed874..1217bebeae4 100644 --- a/bicep-tools/cmd/manifest-to-bicep/main.go +++ b/bicep-tools/cmd/manifest-to-bicep/main.go @@ -6,7 +6,9 @@ import ( "path/filepath" "github.com/radius-project/radius/bicep-tools/pkg/cli" + "github.com/radius-project/radius/bicep-tools/pkg/manifest" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var ( @@ -50,39 +52,60 @@ from your resource provider manifest files.`, } func newGenerateCommand() *cobra.Command { - var manifestFile string - var outputDir string - cmd := &cobra.Command{ - Use: "generate ", - Short: "Generate Bicep extension from Radius Resource Provider manifest", - Long: `Generate Bicep extension files from a Radius Resource Provider manifest. + Use: "generate [manifest2 ...] ", + Short: "Generate Bicep extension from one or more Radius Resource Provider manifests", + Long: `Generate Bicep extension files from one or more Radius Resource Provider manifests. -This command takes a YAML manifest file that defines resource types and their +This command takes YAML manifest files that define resource types and their schemas, and generates three output files: - types.json: Bicep type definitions -- index.json: Type index for Bicep extensions +- index.json: Type index for Bicep extensions - index.md: Markdown documentation -The manifest file should be a YAML file that follows the Radius Resource Provider -manifest format with resource type definitions and API versions.`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - manifestFile = args[0] - outputDir = args[1] +When multiple manifest files are provided, their resource type definitions are +merged into a single output. All manifests must share the same namespace because +the output is written to a single directory representing one namespace/apiVersion +combination. To merge types across namespaces into one Bicep extension, run this +command once per namespace and then rebuild the unified index.json over the +combined output tree (see the rebuild-index step in the build pipeline). + +This supports per-type manifest files (e.g. containers.yaml, routes.yaml) that +each define a single resource type within the same namespace. - return RunGenerate(manifestFile, outputDir) +The last positional argument is always the output directory; all preceding +arguments are manifest files.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + manifestFiles := args[:len(args)-1] + outputDir := args[len(args)-1] + return RunGenerate(manifestFiles, outputDir) }, } return cmd } -func RunGenerate(manifestFile, outputDir string) error { - // Validate input file exists - if _, err := os.Stat(manifestFile); os.IsNotExist(err) { - return fmt.Errorf("manifest file does not exist: %s", manifestFile) +// RunGenerate generates Bicep extension files (types.json, index.json, index.md) from one or more +// manifest files. When multiple manifests are provided, they must share the same namespace and their +// resource type definitions are merged before generation. This supports per-type manifest files +// (e.g. containers.yaml, routes.yaml) where each file defines a single resource type. +// +// The same-namespace restriction exists because the output is written to a single directory +// representing one namespace/apiVersion. Cross-namespace unification into one Bicep extension +// is handled separately by the rebuild-index step, which walks the full output tree and builds +// a unified index.json. +func RunGenerate(manifestFiles []string, outputDir string) error { + if len(manifestFiles) == 0 { + return fmt.Errorf("at least one manifest file is required") + } + + // Validate all input files exist + for _, f := range manifestFiles { + if _, err := os.Stat(f); os.IsNotExist(err) { + return fmt.Errorf("manifest file does not exist: %s", f) + } } // Create output directory if it doesn't exist @@ -99,9 +122,26 @@ func RunGenerate(manifestFile, outputDir string) error { // Use CLI package to perform the conversion generator := cli.NewGenerator() - result, err := generator.GenerateFromFile(manifestFile) - if err != nil { - return fmt.Errorf("failed to generate from manifest: %w", err) + + var result *cli.GenerationResult + var err error + + if len(manifestFiles) == 1 { + // Single manifest - use directly without merging. + result, err = generator.GenerateFromFile(manifestFiles[0]) + if err != nil { + return fmt.Errorf("failed to generate from manifest: %w", err) + } + } else { + // Multiple manifests - merge their Types maps into a single manifest, then generate. + merged, mergeErr := mergeManifestFiles(manifestFiles) + if mergeErr != nil { + return mergeErr + } + result, err = generator.GenerateFromString(merged) + if err != nil { + return fmt.Errorf("failed to generate from merged manifests: %w", err) + } } // Write output files @@ -129,6 +169,51 @@ func RunGenerate(manifestFile, outputDir string) error { return nil } +// mergeManifestFiles reads multiple manifest YAML files, validates they share +// the same namespace, merges their Types maps, and returns a single combined +// YAML string suitable for GenerateFromString. +func mergeManifestFiles(paths []string) (string, error) { + var namespace string + allTypes := make(map[string]manifest.ResourceType) + + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + return "", fmt.Errorf("failed to read manifest file %s: %w", p, err) + } + + provider, err := manifest.ParseManifest(string(data)) + if err != nil { + return "", fmt.Errorf("failed to parse manifest %s: %w", p, err) + } + + if namespace == "" { + namespace = provider.Namespace + } else if provider.Namespace != namespace { + return "", fmt.Errorf("all manifests must share the same namespace: got %q (from %s) and %q", provider.Namespace, p, namespace) + } + + for typeName, typeDef := range provider.Types { + if _, exists := allTypes[typeName]; exists { + return "", fmt.Errorf("duplicate resource type %q found in %s", typeName, p) + } + allTypes[typeName] = typeDef + } + } + + // Re-serialize as YAML so GenerateFromString can parse it. + merged := manifest.ResourceProvider{ + Namespace: namespace, + Types: allTypes, + } + + out, err := yaml.Marshal(&merged) + if err != nil { + return "", fmt.Errorf("failed to marshal merged manifest: %w", err) + } + return string(out), nil +} + func removeIfExists(path string) error { if _, err := os.Stat(path); err == nil { return os.Remove(path) diff --git a/bicep-tools/cmd/manifest-to-bicep/main_test.go b/bicep-tools/cmd/manifest-to-bicep/main_test.go new file mode 100644 index 00000000000..958395bcacc --- /dev/null +++ b/bicep-tools/cmd/manifest-to-bicep/main_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunGenerate_SingleFile(t *testing.T) { + outputDir := t.TempDir() + + err := RunGenerate([]string{"testdata/containers.yaml"}, outputDir) + if err != nil { + t.Fatalf("RunGenerate returned error: %v", err) + } + + // Verify all three output files are created. + for _, filename := range []string{"types.json", "index.json", "index.md"} { + path := filepath.Join(outputDir, filename) + info, err := os.Stat(path) + if err != nil { + t.Errorf("expected %s to exist: %v", filename, err) + continue + } + if info.Size() == 0 { + t.Errorf("expected %s to be non-empty", filename) + } + } +} + +func TestRunGenerate_MultipleFiles_SameNamespace(t *testing.T) { + outputDir := t.TempDir() + + err := RunGenerate([]string{"testdata/containers.yaml", "testdata/routes.yaml"}, outputDir) + if err != nil { + t.Fatalf("RunGenerate returned error: %v", err) + } + + // Verify all three output files are created. + for _, filename := range []string{"types.json", "index.json", "index.md"} { + path := filepath.Join(outputDir, filename) + info, err := os.Stat(path) + if err != nil { + t.Errorf("expected %s to exist: %v", filename, err) + continue + } + if info.Size() == 0 { + t.Errorf("expected %s to be non-empty", filename) + } + } + + // Verify the merged index.json references both resource types. + indexContent, err := os.ReadFile(filepath.Join(outputDir, "index.json")) + if err != nil { + t.Fatalf("failed to read index.json: %v", err) + } + + indexStr := string(indexContent) + for _, typeName := range []string{"Radius.Compute/containers", "Radius.Compute/routes"} { + if !strings.Contains(indexStr, typeName) { + t.Errorf("expected index.json to contain %q, but it was not found", typeName) + } + } +} + +func TestRunGenerate_MultipleFiles_DifferentNamespaces(t *testing.T) { + outputDir := t.TempDir() + + err := RunGenerate([]string{"testdata/containers.yaml", "testdata/secrets.yaml"}, outputDir) + if err == nil { + t.Fatal("expected error when merging manifests with different namespaces, got nil") + } + + expected := "all manifests must share the same namespace" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error containing %q, got %q", expected, err.Error()) + } +} + +func TestRunGenerate_NonexistentFile(t *testing.T) { + outputDir := t.TempDir() + + err := RunGenerate([]string{"testdata/nonexistent.yaml"}, outputDir) + if err == nil { + t.Fatal("expected error for nonexistent manifest file, got nil") + } + + expected := "manifest file does not exist" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error containing %q, got %q", expected, err.Error()) + } +} + +func TestRunGenerate_EmptyManifestList(t *testing.T) { + outputDir := t.TempDir() + + err := RunGenerate([]string{}, outputDir) + if err == nil { + t.Fatal("expected error for empty manifest list, got nil") + } + + expected := "at least one manifest file is required" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error containing %q, got %q", expected, err.Error()) + } +} + +func TestMergeManifestFiles_DuplicateType(t *testing.T) { + // Both files define "containers" in Radius.Compute - should be rejected. + _, err := mergeManifestFiles([]string{"testdata/containers.yaml", "testdata/containers.yaml"}) + if err == nil { + t.Fatal("expected error for duplicate resource type, got nil") + } + + expected := "duplicate resource type" + if !strings.Contains(err.Error(), expected) { + t.Errorf("expected error containing %q, got %q", expected, err.Error()) + } +} diff --git a/bicep-tools/cmd/manifest-to-bicep/testdata/containers.yaml b/bicep-tools/cmd/manifest-to-bicep/testdata/containers.yaml new file mode 100644 index 00000000000..353081df1bb --- /dev/null +++ b/bicep-tools/cmd/manifest-to-bicep/testdata/containers.yaml @@ -0,0 +1,13 @@ +namespace: Radius.Compute +types: + containers: + apiVersions: + '2025-08-01-preview': + schema: + type: object + properties: + environment: + type: string + application: + type: string + required: [environment, application] diff --git a/bicep-tools/cmd/manifest-to-bicep/testdata/routes.yaml b/bicep-tools/cmd/manifest-to-bicep/testdata/routes.yaml new file mode 100644 index 00000000000..983c5662300 --- /dev/null +++ b/bicep-tools/cmd/manifest-to-bicep/testdata/routes.yaml @@ -0,0 +1,16 @@ +namespace: Radius.Compute +types: + routes: + apiVersions: + '2025-08-01-preview': + schema: + type: object + properties: + environment: + type: string + application: + type: string + kind: + type: string + enum: [HTTP, TCP] + required: [environment, application] diff --git a/bicep-tools/cmd/manifest-to-bicep/testdata/secrets.yaml b/bicep-tools/cmd/manifest-to-bicep/testdata/secrets.yaml new file mode 100644 index 00000000000..c75fe5e0553 --- /dev/null +++ b/bicep-tools/cmd/manifest-to-bicep/testdata/secrets.yaml @@ -0,0 +1,11 @@ +namespace: Radius.Security +types: + secrets: + apiVersions: + '2025-08-01-preview': + schema: + type: object + properties: + environment: + type: string + required: [environment] From 76e9a546ecbf01f3ce2ce8ce393677112a875be8 Mon Sep 17 00:00:00 2001 From: Karishma Chawla Date: Fri, 15 May 2026 15:06:13 -0700 Subject: [PATCH 2/2] Retrigger CI checks Signed-off-by: Karishma Chawla