Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 107 additions & 22 deletions bicep-tools/cmd/manifest-to-bicep/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 <manifest> <output>",
Short: "Generate Bicep extension from Radius Resource Provider manifest",
Long: `Generate Bicep extension files from a Radius Resource Provider manifest.
Use: "generate <manifest1> [manifest2 ...] <output>",
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment thread
kachawla marked this conversation as resolved.
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)
}
Comment thread
kachawla marked this conversation as resolved.

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)
Expand Down
120 changes: 120 additions & 0 deletions bicep-tools/cmd/manifest-to-bicep/main_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
13 changes: 13 additions & 0 deletions bicep-tools/cmd/manifest-to-bicep/testdata/containers.yaml
Original file line number Diff line number Diff line change
@@ -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]
16 changes: 16 additions & 0 deletions bicep-tools/cmd/manifest-to-bicep/testdata/routes.yaml
Original file line number Diff line number Diff line change
@@ -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]
11 changes: 11 additions & 0 deletions bicep-tools/cmd/manifest-to-bicep/testdata/secrets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace: Radius.Security
types:
secrets:
apiVersions:
'2025-08-01-preview':
schema:
type: object
properties:
environment:
type: string
required: [environment]
Loading