From df84559cf77512963a9470647e5d5087d8d01906 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 12 May 2026 14:05:26 -0700 Subject: [PATCH 01/16] Add direct module support spec Signed-off-by: Reshma Abdul Rahim --- .../checklists/requirements.md | 38 +++ .../contracts/source-resolver.go | 97 ++++++ specs/001-direct-module-support/data-model.md | 212 ++++++++++++ specs/001-direct-module-support/plan.md | 261 +++++++++++++++ specs/001-direct-module-support/prompt.md | 65 ++++ specs/001-direct-module-support/quickstart.md | 302 ++++++++++++++++++ specs/001-direct-module-support/research.md | 245 ++++++++++++++ specs/001-direct-module-support/spec.md | 212 ++++++++++++ specs/001-direct-module-support/tasks.md | 275 ++++++++++++++++ 9 files changed, 1707 insertions(+) create mode 100644 specs/001-direct-module-support/checklists/requirements.md create mode 100644 specs/001-direct-module-support/contracts/source-resolver.go create mode 100644 specs/001-direct-module-support/data-model.md create mode 100644 specs/001-direct-module-support/plan.md create mode 100644 specs/001-direct-module-support/prompt.md create mode 100644 specs/001-direct-module-support/quickstart.md create mode 100644 specs/001-direct-module-support/research.md create mode 100644 specs/001-direct-module-support/spec.md create mode 100644 specs/001-direct-module-support/tasks.md diff --git a/specs/001-direct-module-support/checklists/requirements.md b/specs/001-direct-module-support/checklists/requirements.md new file mode 100644 index 00000000000..89ff2661c68 --- /dev/null +++ b/specs/001-direct-module-support/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Direct Module Support + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Full spec rewrite on 2026-04-30 to correct framing: eliminated "recipe registration" language, made input/output resolution core P1 capabilities (not advanced), and restructured story progression. +- Terminology corrections: "direct module support" (not "recipe registration"), "input resolution" and "output resolution" (the system handles these externally), recipes are "linked" to environments (already works today). +- Story progression: P1 = Basic Bicep module support (with I/O resolution) + Basic Terraform module support (with I/O resolution); P2 = AVM modules, version pinning, private auth; P3 = schema inspection, link-time validation. +- 7 user stories (2×P1, 3×P2, 2×P3), 21 functional requirements, 8 success criteria, 8 assumptions, 9 edge cases. +- No [NEEDS CLARIFICATION] markers — complete context available from prototype code, research notes, and quickstart examples. diff --git a/specs/001-direct-module-support/contracts/source-resolver.go b/specs/001-direct-module-support/contracts/source-resolver.go new file mode 100644 index 00000000000..4c0231679a5 --- /dev/null +++ b/specs/001-direct-module-support/contracts/source-resolver.go @@ -0,0 +1,97 @@ +// Package source provides module source classification and validation for +// Terraform recipe recipe locations. It determines whether a recipeLocation +// refers to a direct Terraform module source (registry, Git, HTTP) or an +// existing OCI/wrapped recipe path, enabling the recipe system to apply +// appropriate execution and output mapping strategies. +// +// This file defines the contract (interface + types) for the source resolver. +// Implementation will be in resolver.go. +package source + +import "context" + +// SourceType classifies the format of a Terraform module source path. +type SourceType int + +const ( + // SourceTypeUnknown indicates the source format could not be classified. + // The system should fall back to existing OCI/wrapped recipe resolution. + SourceTypeUnknown SourceType = iota + + // SourceTypeTerraformRegistry indicates a standard Terraform registry source. + // Format: "namespace/name/provider" (exactly 3 slash-separated segments, no scheme). + // Example: "hashicorp/consul/aws", "Azure/cosmosdb/azurerm" + SourceTypeTerraformRegistry + + // SourceTypeGit indicates a Git-hosted module source. + // Format: "git::https://..." or "git::ssh://..." + // Supports ref specifiers (?ref=v1.0.0) and subdirectories (//modules/vpc). + SourceTypeGit + + // SourceTypeHTTP indicates an HTTP/HTTPS archive source. + // Format: "https://example.com/module.tar.gz" (without git:: prefix) + SourceTypeHTTP + + // SourceTypeS3 indicates an S3-hosted module source. + // Format: "s3::bucket-name/key" + SourceTypeS3 + + // SourceTypeGCS indicates a GCS-hosted module source. + // Format: "gcs::bucket-name/key" + SourceTypeGCS + + // SourceTypeOCI indicates an OCI registry source (existing wrapped recipe path). + // Format: contains "oci://" or matches OCI image reference patterns. + SourceTypeOCI +) + +// ResolvedSource contains the classification result for a recipe location. +type ResolvedSource struct { + // Type is the classified source type. + Type SourceType + + // OriginalPath is the unmodified recipeLocation value. + OriginalPath string + + // IsDirectModule is true when the source is a direct Terraform module + // (not a wrapped/OCI recipe). This determines output mapping strategy. + IsDirectModule bool +} + +// Resolver classifies and validates Terraform module source paths. +type Resolver interface { + // Classify determines the source type of a recipe location without making + // any network calls. Classification is purely based on string pattern matching. + // + // Returns a ResolvedSource with Type set to the detected source type. + // If the format is not recognized, Type is SourceTypeUnknown and + // IsDirectModule is false (indicating fallback to existing behavior). + Classify(recipeLocation string) ResolvedSource + + // ValidateReachability performs a lightweight network check to verify + // that the module source is accessible. This is called at RecipePack + // creation time per FR-014. + // + // For registry modules: HTTP GET to registry API + // For Git sources: git ls-remote + // For HTTP sources: HTTP HEAD request + // + // Returns nil if the source is reachable, or an error describing why + // it could not be reached. The check has a 30-second timeout. + // + // If the source type is SourceTypeUnknown or SourceTypeOCI, this + // method returns nil (no validation for fallback paths). + ValidateReachability(ctx context.Context, recipeLocation string, templateVersion string) error +} + +// IsDirectModuleSource is a convenience function that classifies the given +// recipeLocation and returns true if it represents a direct Terraform module +// source (registry, git, HTTP, S3, or GCS) rather than a wrapped/OCI recipe. +// +// This is the primary entry point for the terraform driver to determine +// which output mapping strategy to use. +func IsDirectModuleSource(recipeLocation string) bool { + // Implementation delegates to the default resolver's Classify method. + // Defined here as a package-level function for ergonomic usage. + return false // placeholder +} diff --git a/specs/001-direct-module-support/data-model.md b/specs/001-direct-module-support/data-model.md new file mode 100644 index 00000000000..8a5d92121e2 --- /dev/null +++ b/specs/001-direct-module-support/data-model.md @@ -0,0 +1,212 @@ +# Data Model: Direct Terraform and AVM Module Support via Recipe Packs + +## Overview + +This feature extends the existing recipe pack data model with two new fields on `RecipeDefinition` — `recipeParameters` and `outputs` — and broadens the `recipeLocation` field to accept standard Terraform module sources (registry, Git, HTTP, S3, GCS) alongside existing wrapped recipe references. + +## Extended Entities + +### RecipeDefinition (extended schema) + +**Location**: `pkg/corerp/datamodel/recipepack.go` + +```go +type RecipeDefinition struct { + RecipeKind string `json:"recipeKind"` // "terraform" or "bicep" + RecipeLocation string `json:"recipeLocation"` // Template source path/URL + RecipeParameters map[string]any `json:"recipeParameters,omitempty"` // Input parameters with {{context.*}} support + PlainHTTP bool `json:"plainHTTP,omitempty"` // Allow insecure connections + Outputs map[string]string `json:"outputs,omitempty"` // Maps resource property → module output name +} +``` + +**New Fields**: +- `RecipeParameters`: Input parameters passed through to Terraform module variables. Values may contain `{{context.*}}` template expressions resolved at deployment time. +- `Outputs`: Maps resource property names to module output names (e.g., `{"host": "hostname"}` means resource property `host` gets its value from module output `hostname`). When empty/nil, all module outputs pass through with original names. + +**`RecipeLocation` accepts**: +- Terraform Registry: `hashicorp/consul/aws`, `Azure/avm-res-storage-storageaccount/azurerm`, `ballj/postgresql/kubernetes` +- Git URLs: `git::https://github.com/org/terraform-aws-vpc.git` +- Git with ref: `git::https://github.com/org/module.git?ref=v2.0.0` +- Git with subdirectory: `git::https://github.com/org/repo.git//modules/vpc` +- HTTP archives: `https://example.com/modules/vpc.tar.gz` +- S3: `s3::https://bucket.s3.amazonaws.com/module.zip` +- GCS: `gcs::https://bucket.storage.googleapis.com/module.zip` +- Existing OCI/wrapped recipes: `ghcr.io/org/recipe:v1` (unchanged behavior) + +**Template Expressions in RecipeParameters**: Values can contain `{{context.*}}` expressions resolved at deployment time. For example, `{{context.runtime.kubernetes.namespace}}` resolves to the target Kubernetes namespace. Mixed content is supported (e.g., `prefix-{{context.resource.name}}-suffix`). Unrecognized expressions are left as-is. + +**Validation Rules** (at creation time): +- Source must be reachable (lightweight probe, 30s timeout) — definitive failures reject, transient warnings allowed +- Source format must be classifiable by the resolver +- If unclassifiable, accepted without validation (fallback to existing behavior) + +### EnvironmentDefinition (internal, extended) + +**Location**: `pkg/recipes/types.go` + +```go +type EnvironmentDefinition struct { + Name string // Recipe name + Driver string // "terraform" or "bicep" + ResourceType string // Portable resource type + Parameters map[string]any // Default recipe parameters + TemplatePath string // Module source URL/path (expanded behavior) + TemplateVersion string // Module version (used for registry pinning) + PlainHTTP bool // Allow insecure connections + Outputs map[string]string // Maps resource property → module output name +} +``` + +**New Field**: `Outputs` — populated from RecipeDefinition.Outputs via the config loader. + +### RecipeOutput (extended with DirectModule flag) + +**Location**: `pkg/recipes/types.go` + +```go +type RecipeOutput struct { + Resources []string // Deployed resource IDs (from TF state) + Secrets map[string]any // Sensitive output values + Values map[string]any // Non-sensitive output values + Status *rpv1.RecipeStatus + DirectModule bool // True when outputs come from a direct module (skip schema filter) +} +``` + +**Behavioral Change for Direct Modules**: +- `Values`: Populated with ALL non-sensitive Terraform module outputs. If `Outputs` mapping exists, values are renamed (module output name → resource property name). If no mapping, original names pass through. +- `Secrets`: Populated with ALL sensitive Terraform module outputs (same rename logic). +- `DirectModule`: Set to `true` by the TF driver for direct modules. When true, the DynamicProcessor skips schema filtering and dumps all outputs to resource.Properties. +- **`result` output priority**: The system checks for a `result` output FIRST for all sources. If `result` exists and no `outputs` mapping is configured, the module is treated as a wrapped recipe. This prevents misclassifying wrapped recipes hosted on registries. + +**Behavioral Change for Wrapped Recipes** (unchanged): +- Existing logic: looks for `result` output, parses into Resources/Secrets/Values + +## New Internal Types (not persisted) + +### SourceType Enum + +**Location**: `pkg/recipes/source/types.go` + +```go +type SourceType int + +const ( + SourceTypeUnknown SourceType = iota // Unclassified — use fallback + SourceTypeTerraformRegistry // e.g., "hashicorp/consul/aws" + SourceTypeGit // e.g., "git::https://..." + SourceTypeHTTP // e.g., "https://example.com/module.tar.gz" + SourceTypeS3 // e.g., "s3::bucket/key" + SourceTypeGCS // e.g., "gcs::bucket/key" + SourceTypeOCI // Existing OCI/wrapped recipe path +) +``` + +### ResolvedSource + +**Location**: `pkg/recipes/source/types.go` + +```go +type ResolvedSource struct { + Type SourceType // Classified source type + OriginalPath string // Original recipeLocation value + IsDirectModule bool // True if this is a direct TF module (not wrapped) +} +``` + +## State Transitions + +### Recipe Deployment Lifecycle (with direct module) + +``` +┌─────────────────┐ +│ RecipePack │ ← recipeLocation validated at creation +│ Created │ +└────────┬────────┘ + │ Deploy resource using recipe + ▼ +┌─────────────────┐ +│ Source │ ← Classify recipeLocation +│ Classification │ +└────────┬────────┘ + │ Direct module detected + ▼ +┌─────────────────┐ +│ Module │ ← terraform get (fresh download, no cache) +│ Download │ +└────────┬────────┘ + │ Success + ▼ +┌─────────────────┐ +│ Module │ ← Extract variables, outputs, providers +│ Inspection │ +└────────┬────────┘ + │ Generate config with all-output forwarding + ▼ +┌─────────────────┐ +│ Expression │ ← Resolve {{context.*}} in recipeParameters +│ Resolution │ via ResolveParameterExpressions() +└────────┬────────┘ + │ Parameters with resolved context values + ▼ +┌─────────────────┐ +│ Terraform │ ← init + apply +│ Execution │ +└────────┬────────┘ + │ Success + ▼ +┌─────────────────┐ +│ Output │ ← Apply outputs mapping (rename/filter) +│ Mapping │ or pass-through all outputs +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Resource │ ← Outputs accessible via Radius API +│ Deployed │ (bypasses schema filter for direct modules) +└─────────────────┘ +``` + +## Relationships + +``` +RecipePack (1) ──contains──▶ (N) RecipeDefinition + │ │ + │ recipeLocation + recipeParameters + outputs + │ │ + │ ┌─────────┴──────────┐ + │ │ │ + │ Direct Module Wrapped/OCI Recipe + │ (new behavior) (existing behavior) + │ │ │ + │ ┌─────┴─────┐ │ + │ │ │ │ + │ Registry Git/HTTP │ + │ │ │ │ + │ └─────┬─────┘ │ + │ │ │ + ▼ ▼ ▼ +Environment ──uses──▶ TerraformDriver ◀──uses── Environment + (recipePacks, │ + recipeParameters)┌─────┴─────┐ + │ │ + Direct Mode Wrapped Mode + (flat output (result output + + outputs parsing) + mapping) +``` + +## Validation Rules + +| Field | Rule | When Applied | +|-------|------|--------------| +| `RecipeLocation` | Must be non-empty string | Always (existing) | +| `RecipeLocation` | If classifiable as direct module, source must be reachable | RecipePack create/update | +| `RecipeLocation` | Format must match one of: registry, git, http, s3, gcs, or OCI | Soft validation (unknown = fallback) | +| `RecipeKind` | Must be "terraform" for direct module sources | RecipePack create/update | +| `RecipeParameters` | Keys should match module input variable names | At terraform apply time (Terraform validates) | +| `RecipeParameters` | Values may contain `{{context.*}}` template expressions | Resolved at deploy time by ResolveParameterExpressions() | +| `Outputs` | Keys are resource property names, values are module output names | Applied at output mapping time in TF driver | +| `Outputs` | No duplicate target property names allowed | RecipePack create/update (validation pending) | +| `Outputs` | Target property names must not collide with reserved properties (application, environment, status, connections) | RecipePack create/update (validation pending) | diff --git a/specs/001-direct-module-support/plan.md b/specs/001-direct-module-support/plan.md new file mode 100644 index 00000000000..165a95bb4be --- /dev/null +++ b/specs/001-direct-module-support/plan.md @@ -0,0 +1,261 @@ +# Implementation Plan: Direct Module Support + +**Branch**: `001-direct-module-support` | **Date**: 2026-04-30 | **Spec**: `specs/001-direct-module-support/spec.md` +**Input**: Feature specification from `specs/001-direct-module-support/spec.md` + +## Summary + +Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. The system classifies `recipeLocation` at creation time (registry, Git, HTTP, S3, GCS, or OCI) using pattern-matching heuristics, resolves `{{context.*}}` template expressions (including single-level ternary and `context.resource.properties.*` paths) in `recipeParameters` at deployment time, passes parameters through to the module's native inputs, and maps module outputs to the resource type's read-only properties via the `outputs` field on RecipePack. A RecipePack is a collection of recipe configurations keyed by resource type. Existing wrapped recipes continue to function identically. + +The prototype (3 commits on branch `001-direct-module-support`) already implements the Terraform-side core: source classification (`pkg/recipes/source/`), direct output mapping in the Terraform driver, config generation for all outputs, and best-effort reachability validation at RecipePack creation time. + +## Technical Context + +**Language/Version**: Go 1.22+ (per `go.mod`) +**Primary Dependencies**: `hashicorp/terraform-exec`, `hashicorp/terraform-config-inspect`, `hashicorp/go-getter`, ARM deployment client (Bicep), `github.com/radius-project/radius` monorepo +**Storage**: Kubernetes Secrets (Terraform state), ARM deployment tracking (Bicep state) +**Testing**: `go test` with `testify`, table-driven tests; functional tests in `test/functional-portable/` +**Target Platform**: Linux containers (Kubernetes), cross-platform CLI +**Project Type**: Cloud-native platform (API server + IaC drivers) +**Performance Goals**: Direct module deployment within 10% of equivalent wrapped-recipe deployment time (SC-002); inaccessible source fails within 60s (SC-005) +**Constraints**: Zero behavioral changes to existing wrapped recipes (FR-014); no new driver or execution engine (A-001); retry delegated to IaC engine (A-012) +**Scale/Scope**: 5 user stories (3×P1, 2×P2); ~8 packages modified; ~2000 LOC new + ~200 LOC modified + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution (`/.specify/memory/constitution.md`) is a template placeholder — no project-specific principles have been ratified. Gate passes trivially: no constraints to evaluate. + +**Post-Phase 1 re-check**: Design adheres to existing Radius conventions (Go idioms per `.github/instructions/golang.instructions.md`, existing driver architecture, existing test patterns). No violations detected. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-direct-module-support/ +├── plan.md # This file +├── research.md # Phase 0 output — design decisions R-001 through R-011 +├── data-model.md # Phase 1 output — extended RecipeDefinition, SourceType, ResolvedSource +├── quickstart.md # Phase 1 output — usage examples (AVM, Git, registry) +├── contracts/ +│ └── source-resolver.go # Phase 1 output — Resolver interface contract +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +pkg/ +├── recipes/ +│ ├── source/ # NEW — Source classification & validation +│ │ ├── types.go # SourceType enum, ResolvedSource, Resolver interface +│ │ ├── resolver.go # Pattern-matching classifier, reachability probes +│ │ └── resolver_test.go # Table-driven classification + HTTP mock tests +│ ├── driver/ +│ │ ├── terraform/ +│ │ │ └── terraform.go # MODIFIED — prepareRecipeResponse: direct output mapping +│ │ └── bicep/ +│ │ └── bicep.go # MODIFIED — direct Bicep module deployment path +│ ├── terraform/ +│ │ ├── config/ +│ │ │ └── config.go # MODIFIED — AddAllOutputs(), AddDirectModuleContext() +│ │ ├── execute.go # MODIFIED — direct module context injection + output gen +│ │ └── module.go # MODIFIED — ModuleOutputs collection in inspectModule() +│ ├── paramresolver/ # NEW — {{context.*}} template expression resolver +│ │ ├── resolver.go # ResolveParameterExpressions(), buildContextLookup() +│ │ └── resolver_test.go # Expression resolution tests +│ └── types.go # MODIFIED — RecipeOutput.DirectModule flag +├── corerp/ +│ ├── datamodel/ +│ │ └── recipepack.go # MODIFIED — RecipeDefinition: recipeParameters, outputs +│ └── frontend/controller/recipepacks/ +│ └── createorupdaterecipepack.go # MODIFIED — source validation at creation time +typespec/ +└── Radius.Core/ + └── recipePacks.tsp # MODIFIED — outputs, recipeParameters in TypeSpec model + +test/ +└── functional-portable/ + └── recipes/ # EXTENDED — direct module functional tests +``` + +**Structure Decision**: This feature extends the existing monorepo structure. All new code lives in `pkg/recipes/source/` (classification) and `pkg/recipes/paramresolver/` (expression resolution). Modifications are surgical additions to existing driver, config, and controller packages. No new top-level directories. + +## Complexity Tracking + +No constitution violations to justify — the design follows existing patterns throughout. + +--- + +## Phase 0: Outline & Research + +*Status: **Complete** — see `specs/001-direct-module-support/research.md`* + +All technical unknowns have been resolved. Key decisions: + +| ID | Decision | Rationale | +|----|----------|-----------| +| R-001 | Pattern-matching heuristics for source classification | Deterministic, no network calls, simple ordered rules | +| R-002 | Two-phase resolution: classify → direct or OCI fallback | Avoids latency for unambiguous sources | +| R-003 | Flat output mapping for direct modules (Values + Secrets) | Simplest approach; no Radius-specific conventions imposed | +| R-004 | Best-effort reachability validation at creation time | Catches typos early; 30s timeout; transient → warning | +| R-005 | Version embedded in `recipeLocation` (OCI tag, Git ref, registry syntax) | No separate `templateVersion` field needed (A-009); version is part of the source reference | +| R-006 | Runtime detection via `IsDirectModuleSource()` classifier | No new persisted fields needed | +| R-007 | `AddAllOutputs()` generates forwarding output blocks | Terraform requires declared root outputs | +| R-010 | Sensitive outputs → `Secrets` map (via TF `sensitive` flag) | Security without module modifications | +| R-011 | Explicit `{{context.*}}` expressions (not auto-injection) | Unambiguous; platform engineer controls mapping | + +--- + +## Phase 1: Design & Contracts + +*Status: **Complete** — see `specs/001-direct-module-support/data-model.md`, `contracts/`, `quickstart.md`* + +### Data Model + +See `specs/001-direct-module-support/data-model.md` for full details. Summary of entities: + +#### RecipeDefinition (extended) + +| Field | Type | Description | +|-------|------|-------------| +| `recipeKind` | `string` | `"terraform"` or `"bicep"` | +| `recipeLocation` | `string` | Module source (registry path, Git URL, OCI ref, etc.) | +| `recipeParameters` | `map[string]any` | Input parameters; values support `{{context.*}}` expressions | +| `plainHTTP` | `bool` | Allow insecure OCI connections | +| `outputs` | `map[string]string` | Maps resource type's read-only property names → module output names | + +#### SourceType (new internal enum) + +| Value | Pattern | Direct? | +|-------|---------|---------| +| `TerraformRegistry` | `ns/name/provider` (3 segments, no dots in ns) | Yes | +| `Git` | `git::` prefix | Yes | +| `HTTP` | `http://` or `https://` (no `git::`) | Yes | +| `S3` | `s3::` prefix | Yes | +| `GCS` | `gcs::` prefix | Yes | +| `OCI` | `oci://` or hostname-with-dot first segment | No | +| `Unknown` | Unclassifiable | No (fallback) | + +#### RecipeOutput (extended) + +| Field | Change | +|-------|--------| +| `DirectModule bool` | **New** — signals driver to skip `result` parsing and use flat output mapping | + +#### State Transition + +``` +RecipePack Create → Source Classification → Reachability Probe (best-effort) + ↓ +Resource Deploy → Classify source → Download module → Inspect → Generate config + ↓ + Resolve {{context.*}} expressions → terraform init+apply (or ARM deploy) + ↓ + Map outputs (flat + Secrets for sensitive) → Apply outputs mapping to resource type's read-only properties → Resource ready +``` + +### Interface Contracts + +See `specs/001-direct-module-support/contracts/source-resolver.go`. Key contract: + +```go +type Resolver interface { + Classify(templatePath string) ResolvedSource + ValidateReachability(ctx context.Context, templatePath string) error +} +``` + +**Convenience function** (primary entry point for drivers): +```go +func IsDirectModuleSource(templatePath string) bool +``` + +### Quickstart + +See `specs/001-direct-module-support/quickstart.md` for end-to-end usage examples covering: +1. AVM PostgreSQL module with t-shirt sizing (ternary expressions) +2. Git-hosted module with ref/subdirectory +3. HTTP archive module +4. Output mapping for resource property materialization + +--- + +## Prototype Status + +The branch `001-direct-module-support` (3 commits ahead of main) implements the following: + +| Component | File(s) | Status | +|-----------|---------|--------| +| Source classifier | `pkg/recipes/source/types.go`, `resolver.go` | ✅ Implemented + tested | +| Reachability validation | `pkg/recipes/source/resolver.go` | ✅ Registry + HTTP probes | +| RecipePack controller | `pkg/corerp/frontend/controller/recipepacks/` | ✅ Classification at create time | +| TF driver output mapping | `pkg/recipes/driver/terraform/terraform.go` | ✅ Direct module flat mapping | +| TF config: AddAllOutputs | `pkg/recipes/terraform/config/config.go` | ✅ Forward all module outputs | +| TF config: AddDirectModuleContext | `pkg/recipes/terraform/config/config.go` | ✅ Well-known context variable injection | +| TF execute: direct module path | `pkg/recipes/terraform/execute.go` | ✅ Conditional context + output generation | +| TF module inspection | `pkg/recipes/terraform/module.go` | ✅ ModuleOutputs collection | + +### Not yet implemented (remaining work): + +| Component | Description | Priority | +|-----------|-------------|----------| +| `{{context.*}}` expression resolver | `ResolveParameterExpressions()` in param resolver package, including `context.resource.properties.*` paths | P1 | +| Single-level ternary evaluation | Single-level ternary in `{{context.*}}` expressions (nested ternaries out of scope for V1) | P1 | +| Bicep driver: direct module path | ARM deployment of standard Bicep modules without `result` output | P1 | +| Output mapping application | Apply `outputs` field to map module outputs to the resource type's read-only properties | P1 | +| Shallow merge for parameters | Environment-level parameter override with shallow merge semantics | P1 | +| Private module authentication | Credential passthrough for private registries/repos | P2 | +| Functional tests | End-to-end tests with real modules (registry, Git) | P1 | +| TypeSpec model update | `outputs` and `recipeParameters` in `recipePacks.tsp` | P1 | + +--- + +## Implementation Approach (Phase 2 Preview) + +The following outlines the logical work breakdown for task generation. Tasks should be ordered by dependency. + +### Layer 1: Core Infrastructure (no driver dependencies) + +1. **Parameter expression resolver** — `pkg/recipes/paramresolver/`: `ResolveParameterExpressions()`, `buildContextLookup()`, regex-based `{{context.*}}` replacement (including `context.resource.properties.*` paths for application-to-recipe property resolution), single-level ternary evaluation (`{{expr == "val" ? "trueResult" : "falseResult"}}`; nested ternaries out of scope for V1). Table-driven tests. This is the foundational expression engine for US1, US2, and US3 — all P1. +2. **Output mapping utility** — Apply `outputs` map to translate module output names to the resource type's read-only properties in `RecipeOutput.Values`/`Secrets`. Shared by both drivers. +3. **Shallow merge utility** — Merge RecipePack parameters with environment-level overrides (top-level key precedence). + +### Layer 2: Terraform Driver Completion + +4. **Wire expression resolver into TF execute path** — Replace `AddDirectModuleContext` (auto-injection) with `ResolveParameterExpressions()` per R-011 decision. +5. **Wire output mapping** — After `prepareRecipeResponse`, apply `outputs` mapping to map module outputs to resource type's read-only properties. +6. **Handle `result` vs `outputs` precedence** — Per FR-015: `outputs` mapping takes precedence when both are present. + +### Layer 3: Bicep Driver + +7. **Bicep direct module detection** — Classify `recipeLocation` for Bicep (`br:` OCI references that are standard modules vs. wrapped). +8. **Bicep direct deployment** — Pass resolved parameters as ARM deployment parameters; map ARM outputs to resource type's read-only properties via `outputs`. +9. **Bicep cleanup** — Ensure ARM deployment deletion for direct Bicep modules. + +### Layer 4: API & Validation + +10. **TypeSpec model update** — Add `outputs` and `recipeParameters` to RecipePack model (RecipePack is a collection keyed by resource type). +11. **Reachability validation enhancement** — Wire `ValidateReachability` into create/update controller for Bicep OCI references. Best-effort: definitive failures (404, auth denied) reject; transient failures log warnings but allow linking (per SC-009). +12. **Private module auth** — Credential passthrough from secret store to module fetch. + +### Layer 5: Testing & Integration + +13. **Unit tests** — Expression resolver (including ternary and `context.resource.properties.*`), output mapping, shallow merge, classifier edge cases. +14. **Functional tests** — End-to-end with real Terraform registry module, Git module, Bicep AVM module. Must cover `{{context.resource.properties.*}}` resolution and ternary expressions. +15. **Backward compatibility tests** — Verify wrapped recipes remain unchanged. + +--- + +## Generated Artifacts + +| Artifact | Path | Status | +|----------|------|--------| +| Plan | `specs/001-direct-module-support/plan.md` | ✅ This file | +| Research | `specs/001-direct-module-support/research.md` | ✅ Complete (R-001 through R-011) | +| Data Model | `specs/001-direct-module-support/data-model.md` | ✅ Complete | +| Contracts | `specs/001-direct-module-support/contracts/source-resolver.go` | ✅ Complete | +| Quickstart | `specs/001-direct-module-support/quickstart.md` | ✅ Complete | +| Tasks | `specs/001-direct-module-support/tasks.md` | ⏳ Pending (`/speckit.tasks`) | diff --git a/specs/001-direct-module-support/prompt.md b/specs/001-direct-module-support/prompt.md new file mode 100644 index 00000000000..f7a88db7b75 --- /dev/null +++ b/specs/001-direct-module-support/prompt.md @@ -0,0 +1,65 @@ +# Feature: Direct Module Support via Recipe Packs + +Enable platform engineers to use existing Terraform or Azure verified modules directly as recipes within Radius recipe packs — without wrapping, republishing, or creating Radius-specific recipe artifacts. + +## Problem + +Today, to use an existing IaC module with Radius, engineers must wrap the module into a Radius recipe format, publish it to a registry, and then reference it. This is redundant work. Recipe packs should allow pointing directly at an existing Terraform or AVM module and have it work as-is. + +## Recipe Packs as Environment Configuration + +Recipe packs are resources that define a collection of recipes for different resource types. Environments reference recipe packs via the `recipePacks` property. A recipe pack bundles: + +- **Recipe definitions** per resource type (e.g., `Radius.Data/postgreSqlDatabases`) +- **Recipe kind** (`terraform` or `bicep`) +- **Recipe location** (module source — Terraform registry path, Git URL, etc.) +- **Recipe parameters** with support for `{{context.*}}` template expressions +- **Output mappings** (`outputs`) that map resource property names to module output names + +## Input and Output Mapping + +The key design challenge is matching a module's input variables to resource properties and the module's outputs to computed properties in Radius: + +- **Inputs**: Recipe parameters are defined on the recipe pack with `recipeParameters`. Template expressions like `{{context.resource.name}}` allow injecting Radius context at deploy time. Environment-level `recipeParameters` can provide additional defaults per resource type. +- **Outputs**: The `outputs` field on the recipe definition maps resource schema property names to module output names (e.g., `host: 'hostname'` means the resource's `host` property comes from the module's `hostname` output). For direct modules, all outputs are passed through to resource properties, with `outputs` acting as an optional rename/filter layer. + +## Example Bicep + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'pg-recipepack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'ballj/postgresql/kubernetes' + recipeParameters: { + namespace: 'default' + name: '{{context.resource.name}}' + object_prefix: 'myapp-pg' + image_tag: 'latest' + } + outputs: { + host: 'hostname' + port: 'port' + database: 'database_name' + username: 'username' + secretName: 'password_secret' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'my-env' + properties: { + providers: { + kubernetes: { namespace: 'default' } + } + recipePacks: [ + recipepack.id + ] + } +} +``` diff --git a/specs/001-direct-module-support/quickstart.md b/specs/001-direct-module-support/quickstart.md new file mode 100644 index 00000000000..19c44962ddf --- /dev/null +++ b/specs/001-direct-module-support/quickstart.md @@ -0,0 +1,302 @@ +# Quickstart: Direct Terraform and AVM Module Support via Recipe Packs + +## What This Feature Does + +Allows platform engineers to use any standard Terraform module or Azure Verified Modules directly as a recipe's `recipeLocation` in a RecipePack — without wrapping, republishing, or creating Radius-specific artifacts. The system downloads the module at deployment time, passes `recipeParameters` (with `{{context.*}}` expression resolution) as Terraform input variables, and surfaces module outputs as resource properties with optional rename/filter via the `outputs` mapping. + +## Usage Examples + +### Example 1: Azure Verified Module (AVM) — PostgreSQL Flexible Server with T-Shirt Sizing + +Uses the [Azure/avm-res-dbforpostgresql-flexibleserver/azurerm](https://registry.terraform.io/modules/Azure/avm-res-dbforpostgresql-flexibleserver/azurerm) module directly from the Terraform registry. Demonstrates conditional ternary expressions to translate abstract t-shirt sizes (`s`, `m`, `l`) into concrete Azure SKUs and storage configurations. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'azure-postgres-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + recipeParameters: { + name: 'pg-{{context.resource.name}}' + location: 'eastus2' + // Single-level ternary: maps "s" to burstable SKU, else general purpose + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' + tags: { + environment: '{{context.environment.name}}' + application: '{{context.application.name}}' + } + } + outputs: { + host: 'fqdn' + port: 'port' + database: 'database_name' + username: 'administrator_login' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'azure-env' + properties: { + providers: { + azure: { + } + } + recipePacks: [ + recipepack.id + ] + } +} +``` + +Application developers simply specify `size: 's'`, `size: 'm'`, or `size: 'l'` on their resource, and the platform engineer's ternary expressions translate these into the appropriate Azure SKU and storage configuration at deploy time. + +### Example 2: AWS RDS Module — PostgreSQL Database + +Uses the popular [terraform-aws-modules/rds/aws](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws) community module to provision an AWS RDS PostgreSQL instance directly — no wrapping required. Demonstrates ternary expressions for t-shirt size mapping to AWS instance classes and storage. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'aws-data-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'terraform-aws-modules/rds/aws' + recipeParameters: { + identifier: 'pg-{{context.resource.name}}' + instance_class: '{{context.resource.properties.size == "s" ? "db.t3.micro" : "db.r5.large"}}' + allocated_storage: '{{context.resource.properties.size == "s" ? "20" : "100"}}' + db_name: '{{context.resource.name}}' + create_db_subnet_group: true + subnet_ids: ['subnet-12345678', 'subnet-87654321'] + vpc_security_group_ids: ['sg-12345678'] + tags: { + Environment: '{{context.environment.name}}' + Application: '{{context.application.name}}' + } + } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + username: 'db_instance_username' + } + } + } + } +} + +resource myenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-env' + properties: { + providers: { + aws: { + scope: '/planes/aws/aws/accounts/123456789012/regions/us-east-1' + } + } + recipePacks: [ + recipepack.id + ] + } +} +``` + +## How Outputs Work + +The `outputs` mapping on the recipe definition maps resource property names to module output names. Only mapped outputs are surfaced: + +```bicep +outputs: { + host: 'hostname' // resource.properties.host ← module.hostname + port: 'port' // resource.properties.port ← module.port + database: 'database_name' // resource.properties.database ← module.database_name +} +``` + +Sensitive module outputs (marked `sensitive = true` in the module) are stored securely in the `Secrets` map rather than `Values`. + +## How Parameters Work + +`recipeParameters` map directly to Terraform input variables: + +1. **Recipe pack-level parameters** (in `recipeParameters` on recipe definition) → applied to every deployment +2. **Environment-level parameters** (in `recipeParameters` on environment) → merged with recipe pack params, environment takes precedence +3. **No parameter** for optional variables → Terraform uses the module's default value +4. **Missing required variable** → Terraform error surfaced through recipe failure +5. **`{{context.*}}` expressions** → resolved at deploy time against the recipe context + +### Environment-Level Parameter Overrides + +The environment can override or extend recipe pack parameters. This is useful when the same recipe pack is shared across environments (dev, staging, prod) but certain parameters need to differ. + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'pg-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + recipeKind: 'terraform' + recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + recipeParameters: { + name: 'pg-{{context.resource.name}}' + resource_group_name: '{{context.azure.resourceGroup.name}}' + postgresql_version: '16' + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + } + } + } + } +} + +resource devenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'dev' + properties: { + providers: { + azure: { + scope: '{{context.azure.resourceGroup.id}}' + } + } + recipePacks: [ + recipepack.id + ] + recipeParameters: { + backup_retention_days: '2' + geo_redundant_backup_enabled: 'false' + } + } +} +``` + +In this example, the dev environment adds `backup_retention_days` and disables geo-redundant backups. Environment parameters take precedence over recipe pack parameters when both define the same key. + +### Template Expression Resolution + +Parameter values can contain `{{context.*}}` expressions that inject Radius runtime context into Terraform module variables: + +```bicep +recipeParameters: { + // Direct value — passed as-is to Terraform + instance_class: 'db.t3.micro' + + // Context expression — resolved at deploy time + k8s_namespace: '{{context.runtime.kubernetes.namespace}}' + + // Mixed content — expression resolved, literals preserved + resource_prefix: 'app-{{context.resource.name}}-suffix' + + // Resource properties — access user-specified properties from the resource definition + db_size: '{{context.resource.properties.size}}' + + // Ternary expression — conditional value mapping at deploy time + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' +} +``` + +### Ternary Expressions + +Ternary expressions enable conditional value mapping inside `{{...}}` expressions. They are evaluated at deploy time when `context.*` values are resolved. + +**Syntax:** `{{ == "" ? "" : ""}}` + +Single-level ternary only (V1 limitation — chained/nested ternaries are not supported): + +```bicep +recipeParameters: { + // Map t-shirt size to Azure PostgreSQL SKU (use separate parameters for multi-way mapping) + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + + // Map size to storage — simple conditional + storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' +} +``` + +**Behavior:** +- The left-hand side of `==` is resolved against the context lookup (same paths as regular expressions) +- String comparison is exact-match (case-sensitive) +- The else branch must be a literal string value (not another ternary) +- Values are always returned as strings (Terraform handles type conversion) +- If the condition path is unresolvable, the entire expression is left as-is + +> **Note:** For multi-way mapping (e.g., s/m/l → 3 different values), use separate recipe definitions per tier or handle the mapping in the module itself. Chained ternary support is planned for a future release. + +### Available Template Expressions + +| Expression | Description | +|------------|-------------| +| `{{context.resource.name}}` | Deployed resource name | +| `{{context.resource.id}}` | Deployed resource ID | +| `{{context.resource.type}}` | Deployed resource type | +| `{{context.resource.properties.}}` | User-specified resource property (e.g., `size`, `tier`) | +| `{{context.application.name}}` | Application name | +| `{{context.application.id}}` | Application ID | +| `{{context.environment.name}}` | Environment name | +| `{{context.environment.id}}` | Environment ID | +| `{{context.runtime.kubernetes.namespace}}` | Target Kubernetes namespace | +| `{{context.runtime.kubernetes.environmentNamespace}}` | Environment Kubernetes namespace | +| `{{context.azure.resourceGroup.name}}` | Azure resource group name | +| `{{context.azure.resourceGroup.id}}` | Azure resource group ID | +| `{{context.azure.subscription.subscriptionId}}` | Azure subscription ID | +| `{{context.aws.region}}` | AWS region | +| `{{context.aws.account}}` | AWS account ID | + +## Key Behaviors + +| Behavior | Details | +|----------|---------| +| **Module download** | Fresh download every deployment (no caching) | +| **Version pinning** | Embedded in `recipeLocation` for registry modules (e.g., `Azure/module/azurerm` uses latest, append version constraint in module config), `?ref=` for Git | +| **Provider config** | Uses existing `recipeConfig.terraform.providers` from environment | +| **State management** | Same Kubernetes secret backend as existing recipes | +| **Error handling** | Terraform errors surfaced directly in recipe failure response | +| **Existing recipes** | Zero behavioral changes — fully backward compatible | +| **`result` output** | If present AND no `outputs` mapping configured, uses wrapped recipe convention | +| **`outputs` mapping** | Takes precedence over `result` when configured — only mapped outputs flow through | + +## Development Workflow + +### Building + +```bash +make build +``` + +### Running Tests + +```bash +# Unit tests for the shared expression resolver +go test ./pkg/recipes/paramresolver/... + +# Unit tests for the output mapping utility +go test ./pkg/recipes/outputmapping/... + +# Unit tests for the source resolver +go test ./pkg/recipes/source/... + +# Unit tests for terraform driver changes +go test ./pkg/recipes/driver/terraform/... + +# Unit tests for bicep driver changes +go test ./pkg/recipes/driver/bicep/... + +# Unit tests for terraform executor changes +go test ./pkg/recipes/terraform/... + +# All recipe package tests +go test ./pkg/recipes/... + +# All unit tests +make test +``` + +### Linting + +```bash +make lint +make format-check +``` diff --git a/specs/001-direct-module-support/research.md b/specs/001-direct-module-support/research.md new file mode 100644 index 00000000000..eaca1d12f10 --- /dev/null +++ b/specs/001-direct-module-support/research.md @@ -0,0 +1,245 @@ +# Research: Direct Terraform and AVM Module Support via Recipe Packs + +## R-001: Source Format Detection Strategy + +**Decision**: Use pattern-matching heuristics on the `recipeLocation` string to classify source type before attempting resolution. + +**Rationale**: The existing codebase already uses `hashicorp/go-getter` which handles the actual download. However, we need pre-resolution classification to determine: (a) whether to attempt direct module resolution vs. OCI/wrapped path, (b) which validation to apply at creation time, and (c) whether version pinning applies. Pattern-matching is simple, deterministic, and avoids network calls during classification. + +**Classification Rules**: +| Pattern | Source Type | Example | +|---------|-------------|---------| +| `//` (3-segment, no scheme) | Terraform Registry | `hashicorp/consul/aws` | +| `git::` prefix | Git repository | `git::https://github.com/org/module.git` | +| `http://` or `https://` (no `git::` prefix) | HTTP archive | `https://example.com/module.tar.gz` | +| `s3::` prefix | S3 bucket | `s3::bucket/path` | +| `gcs::` prefix | GCS bucket | `gcs::bucket/path` | +| Contains `oci://` or matches existing OCI patterns | OCI/Wrapped (fallback) | `ghcr.io/org/recipe:v1` | + +**Alternatives Considered**: +1. **Always try direct first, catch errors** — Rejected because it would add latency for existing OCI recipes (network timeout before fallback). +2. **Require explicit `terraform::` prefix** — Rejected per FR-012 (no new schema fields/flags) and the principle of using standard Terraform source syntax. +3. **Parse with go-getter's Detect** — Considered but go-getter's detect is designed for runtime, not pre-validation classification. It doesn't distinguish "valid but unreachable" from "not a valid source format." + +--- + +## R-002: Direct Module Resolution vs. OCI/Wrapped Fallback Strategy + +**Decision**: Implement a two-phase resolution in the config loader's `LoadRecipe` path: +1. Classify `recipeLocation` using source format detector +2. If classified as a known Terraform source (registry, git, http, s3, gcs) → mark as direct module +3. If classification is ambiguous or unknown → attempt direct resolution first, then fall back to existing OCI path + +**Rationale**: Per FR-015, the system must try direct resolution first and fall back. However, for unambiguous sources (3-segment registry paths, `git::` prefixed), we can skip the fallback path entirely, improving performance. The ambiguous case (e.g., a plain HTTPS URL that could be either an OCI manifest or a terraform archive) needs the try-then-fallback approach. + +**Alternatives Considered**: +1. **Purely sequential try/fallback for all paths** — Rejected because it adds unnecessary latency for clearly-Terraform sources. +2. **Add a flag to RecipeDefinition** — Rejected per FR-012 (no new fields). + +--- + +## R-003: Output Mapping for Direct Modules (FR-015 Precedence) + +**Decision**: For direct module recipes, apply a three-tier precedence for output handling: +1. If `outputs` mapping is configured → only mapped outputs flow through (sensitive outputs go to Secrets) +2. Elif module has a `result` output → use wrapped recipe convention (backward compat) +3. Else (direct module, no mapping) → pass all outputs through (sensitive → Secrets, non-sensitive → Values) + +**Rationale**: Per FR-015, the `outputs` mapping gives platform engineers explicit control over which module outputs map to which resource properties. When no mapping is configured, pass-through gives visibility into all module outputs. The `result` output is honored for backward compatibility with modules that follow the wrapped recipe convention but don't have an `outputs` mapping configured. + +**Implementation Approach**: +```go +// In prepareRecipeResponse, detect mode via precedence: +switch { +case hasOutputsMapping: + // Only mapped outputs flow through + values, secrets = outputmapping.Apply(rawOutputs, definition.Outputs) +case hasResultOutput: + // Wrapped recipe convention + recipeResponse.PrepareRecipeResponse(resultValue) +case isDirectModule: + // Pass through all outputs (sensitive → Secrets) + values, secrets = outputmapping.Apply(rawOutputs, nil) +} +``` + +**Alternatives Considered**: +1. **Auto-detect Resources/Secrets from output types** — Rejected; sensitivity flag is the only signal used. +2. **`result` takes precedence over `outputs`** — Rejected because `outputs` mapping is a platform engineer's explicit intent and should win. + +--- + +## R-004: RecipePack Creation-Time Validation + +**Decision**: Add a validation step in `CreateOrUpdateRecipePack` controller that, for Terraform recipe definitions with recognized direct module source formats, attempts to resolve the module source to verify reachability. + +**Rationale**: Per FR-014, the system must validate reachability at creation time. This catches typos, inaccessible registries, and bad URLs early rather than at deploy time. The validation uses a lightweight check (e.g., registry API probe for registry modules, HEAD request for HTTP, `git ls-remote` for Git) rather than a full module download. + +**Implementation Approach**: +- Extract validation into a `ValidateModuleSource(ctx, recipeLocation, templateVersion, secrets)` function in `pkg/recipes/source/` +- For registry modules: HTTP GET to `https://registry.terraform.io/v1/modules/{ns}/{name}/{provider}/{version}` (or configured registry) +- For Git sources: `git ls-remote` to verify the ref exists +- For HTTP sources: HTTP HEAD to verify URL responds with 2xx +- Validation is best-effort with a 30-second timeout; transient failures are logged as warnings but don't block creation + +**Alternatives Considered**: +1. **Full module download at creation time** — Rejected because it's expensive and slow; downloads can be large. +2. **No validation at creation time** — Rejected per FR-014. +3. **Async validation (create succeeds, mark status)** — Rejected for simplicity; synchronous validation with timeout is sufficient. + +--- + +## R-005: Version Pinning for Registry Modules + +**Decision**: Use the existing `TemplateVersion` field on `RecipeDefinition`/`EnvironmentDefinition`. When the source is a Terraform registry module, pass `TemplateVersion` as the `version` constraint in the generated `main.tf.json` module block (this already happens in `config.New()`). + +**Rationale**: The existing code in `pkg/recipes/terraform/config/config.go` already handles this: +```go +if moduleVersion != "" { + moduleConfig["version"] = moduleVersion +} +``` +No new code needed for version pinning itself — only for validating that the specified version exists (as part of creation-time validation in R-004). + +**Alternatives Considered**: +1. **Embed version in recipeLocation** (e.g., `hashicorp/consul/aws@1.2.0`) — Rejected because it diverges from Terraform conventions and the existing `TemplateVersion` field already exists. + +--- + +## R-006: Detecting Direct Module Mode at Execution Time + +**Decision**: Add a field or method to distinguish direct-module mode from wrapped-recipe mode during execution. Use the source resolver's classification result stored as a lightweight signal (e.g., a boolean `IsDirectModule` on `EnvironmentDefinition` or detected at runtime from `TemplatePath`). + +**Rationale**: The driver needs to know whether to use flat output mapping (direct) or `result`-based output mapping (wrapped). Since FR-012 prohibits new data model fields for persistence, the detection happens at runtime by re-classifying `TemplatePath` in the driver layer. + +**Implementation**: +```go +// In pkg/recipes/source/resolver.go +func IsDirectModuleSource(recipeLocation string) bool { + sourceType := Classify(recipeLocation) + return sourceType == SourceTypeTerraformRegistry || + sourceType == SourceTypeGit || + sourceType == SourceTypeHTTP || + sourceType == SourceTypeS3 || + sourceType == SourceTypeGCS +} +``` + +This avoids persisting new fields while giving the driver a clear signal. + +**Alternatives Considered**: +1. **Check for `result` output after module inspection** — Could work but adds complexity and breaks the "no changes to wrapped behavior" guarantee if a direct module happens to have a `result` output. +2. **Add a field to the persisted model** — Rejected per FR-012. + +--- + +## R-007: Terraform Config Generation for Direct Modules + +**Decision**: The existing `config.New()` function already generates valid Terraform configuration for any module source. For direct modules, the key differences are: +1. **Outputs**: Generate output blocks for ALL module outputs (not just `result`) +2. **Recipe Context**: Skip adding `recipe_context` variable (direct modules don't know about it) +3. **Parameters**: Pass through as-is (existing behavior works) + +**Rationale**: The existing config generation is already source-agnostic. The main change is in `AddOutputs()` — for direct modules, we need to generate output blocks that forward all module outputs: +```hcl +output "" { + value = module.. + sensitive = +} +``` + +This requires knowing the module's output names before generating config, which is already available from `inspectModule()`. + +**Implementation**: After `downloadAndInspect()`, pass the inspection result to a new `AddAllOutputs(moduleName, moduleOutputs)` method that generates forwarding outputs for each module output. + +**Alternatives Considered**: +1. **Use a wildcard output** — Not supported by Terraform. +2. **Don't generate output blocks, read from state directly** — Rejected because `terraform output` only returns values for declared outputs in the root module. + +--- + +## R-008: GetRecipeMetadata for Direct Modules + +**Decision**: The existing `GetRecipeMetadata` path already works for direct modules because it uses `downloadAndInspect()` which fetches the module and reads its variables. The returned `Parameters` map already contains all module input variables with their metadata. + +**Rationale**: No changes needed to the metadata retrieval flow. The `inspectModule()` function already parses all variables regardless of source type. This satisfies FR-010 and User Story 5. + +**Alternatives Considered**: None — existing code handles this correctly. + +--- + +## R-009: Error Handling for Direct Module Failures + +**Decision**: Surface Terraform errors directly through existing error paths. Use existing `RecipeError` types with appropriate error codes: +- `RecipeDownloadFailed` for source resolution failures +- `RecipeDeploymentFailed` for terraform apply errors (missing variables, provider failures) +- `RecipeDeletionFailed` for terraform destroy errors + +**Rationale**: Per FR-009, errors must be actionable with relevant Terraform error details. The existing error wrapping in the terraform executor and driver already includes the full Terraform error message. No new error types needed. + +**Alternatives Considered**: +1. **Parse and categorize Terraform errors** — Rejected per YAGNI; raw Terraform errors are already actionable for platform engineers. + +--- + +## R-010: Sensitive Output Handling + +**Decision**: For direct modules, outputs marked `sensitive = true` (TF) or `SecureString`/`SecureObject` type (Bicep ARM) are routed to `RecipeOutput.Secrets` instead of `RecipeOutput.Values`. This applies in both pass-through mode (no outputs mapping) and mapped mode (outputs mapping configured). + +**Rationale**: The existing split between Values and Secrets on RecipeOutput provides the security mechanism. The `outputmapping.Apply()` utility in `pkg/recipes/outputmapping/` handles this routing for both TF and Bicep drivers — sensitive outputs go to Secrets, non-sensitive to Values. + +**Implementation** (via shared `outputmapping` package): +```go +// outputmapping.Apply routes based on OutputValue.Sensitive flag +for propertyName, outputName := range mapping { + if output, ok := rawOutputs[outputName]; ok { + if output.Sensitive { + secrets[propertyName] = output.Value + } else { + values[propertyName] = output.Value + } + } +} +``` + +--- + +## R-011: Context Injection Strategy + +**Decision**: Use explicit `{{context.*}}` template expressions in recipe parameters, resolved by `paramresolver.ResolveParameters()`, instead of automatic variable name-matching. + +**Rationale**: Direct Terraform modules have arbitrarily named input variables. An automatic name-matching approach (e.g., injecting the Kubernetes namespace if a module has a variable named `namespace`) is semantically ambiguous — a `namespace` variable might refer to a Terraform provider namespace, a DNS namespace, or something else entirely. Automatic injection based on variable names would silently produce incorrect values with no way for the platform engineer to detect or override the behavior. + +Template expressions give platform engineers explicit control over which context values flow into which module variables. The mapping is visible in the RecipePack definition, auditable, and unambiguous. This also means modules with non-standard variable names (e.g., `k8s_ns` instead of `namespace`) work correctly — the platform engineer simply writes `{{context.runtime.kubernetes.namespace}}` as the parameter value for `k8s_ns`. + +**Supported Expression Paths**: +| Expression | Description | +|------------|-------------| +| `context.resource.name` | Deployed resource name | +| `context.resource.id` | Deployed resource ID | +| `context.resource.type` | Deployed resource type | +| `context.application.name` | Application name | +| `context.application.id` | Application ID | +| `context.environment.name` | Environment name | +| `context.environment.id` | Environment ID | +| `context.runtime.kubernetes.namespace` | Target Kubernetes namespace | +| `context.runtime.kubernetes.environmentNamespace` | Environment Kubernetes namespace | +| `context.azure.resourceGroup.name` | Azure resource group name | +| `context.azure.resourceGroup.id` | Azure resource group ID | +| `context.azure.subscription.subscriptionId` | Azure subscription ID | +| `context.azure.subscription.id` | Azure subscription ID (alias) | +| `context.aws.region` | AWS region | +| `context.aws.account` | AWS account ID | + +**Implementation Approach**: +- `ResolveParameters()` in `pkg/recipes/paramresolver/resolver.go` scans parameter string values for `{{...}}` patterns using regex +- `buildContextLookup()` creates a flat map from the `recipecontext.Context` struct (e.g., `"context.resource.name" → "my-resource"`) +- `resolveStringValue()` uses `regexp.ReplaceAllStringFunc` to replace each `{{context.*}}` match with the looked-up value +- Unrecognized expressions (no match in the lookup map) are left as-is — no silent failure, no empty substitution +- Called from both TF and Bicep drivers in the direct module path (when no `context` variable/parameter exists) +- Works alongside existing `AddRecipeContext` for wrapped recipes (which still use the single `context` variable) + +**Alternatives Considered**: +1. **Automatic name-matching (`AddDirectModuleContext`)** — Rejected due to semantic ambiguity of variable names. A variable named `namespace` could mean many things. Silent injection of wrong values is worse than requiring explicit mapping. +2. **New schema field for context mapping** — Rejected per FR-012 (no new data model fields). Template expressions in existing `Parameters` achieve the same result. +3. **Helm-style `{{ .Values.* }}` syntax** — Rejected because it conflicts with Terraform's own interpolation syntax (`${...}`) and Go template syntax. The `{{context.*}}` prefix is unambiguous and clearly indicates Radius context injection. diff --git a/specs/001-direct-module-support/spec.md b/specs/001-direct-module-support/spec.md new file mode 100644 index 00000000000..c426dd54cbe --- /dev/null +++ b/specs/001-direct-module-support/spec.md @@ -0,0 +1,212 @@ +# Feature Specification: Direct Module Support + +**Feature Branch**: `001-direct-module-support` +**Created**: 2026-04-22 +**Updated**: 2026-04-30 +**Status**: Draft +**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. + +## The Problem + +Today, every Bicep or Terraform module used as a Radius recipe must be wrapped in a Radius-specific shim: + +- **For both Bicep and Terraform**: The wrapper adds a `context` input variable and a structured `result` output (separating `values`, `secrets`, and `resources`) that conforms to Radius recipe conventions. The platform engineer downloads a community module, writes a wrapper that calls it, re-publishes the wrapper, and references that wrapper as the recipe. The problem is identical regardless of IaC language. + +This wrapper tax has real consequences: + +1. **Friction**: Every module requires a bespoke wrapper before it can be used. Wrapping a single module takes 15–60 minutes and requires understanding both the module's interface and Radius conventions. +2. **Maintenance burden**: When the upstream module releases a new version, the wrapper must be updated and re-published. Wrapper drift causes silent failures. +3. **Ecosystem lock-out**: Thousands of production-ready modules exist in the Terraform Registry, MCR (for Bicep), and Git repositories. The wrapper requirement means none of them work out of the box. + +**Direct module support eliminates the wrapper.** Platform engineers point `recipeLocation` at any standard module. The system resolves inputs (injecting Radius context into the module's native variables) and resolves outputs (mapping the module's native outputs to resource properties) — all externally, without modifying the module. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Bicep Module Support (Priority: P1) + +As a platform engineer, I want to set `recipeLocation` to a standard Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and have Radius deploy it directly — passing my `recipeParameters` as the module's ARM parameters and mapping the module's outputs back to Radius resource properties — without writing a Radius-specific wrapper. + +**Today's workflow**: Download the module → write a Radius wrapper that adds the `result` output → publish the wrapper to an OCI registry → reference the wrapper in `recipeLocation`. **With this feature**: set `recipeLocation` to the module's OCI reference directly. + +**Why this priority**: Bicep is Radius's native IaC language. The Bicep driver already handles ARM deployments — extending it to handle standard modules (without the Radius `result` output convention) is the smallest incremental step. This immediately unblocks the AVM Bicep catalog (hundreds of production-ready modules). + +**Independent Test**: Link a recipe pointing to a public AVM Bicep module from MCR, deploy a resource, verify infrastructure is provisioned, and confirm module outputs are accessible as resource properties. + +**Acceptance Scenarios**: + +1. **Given** a RecipePack with `recipeKind: 'bicep'` and `recipeLocation` set to a standard Bicep module OCI reference, **When** a resource using this recipe is deployed with `recipeParameters` providing values for the module's parameters, **Then** the module receives those values and provisions infrastructure successfully. +2. **Given** a Bicep module with output values (e.g., `output endpoint string`), **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping on the RecipePack. +3. **Given** `recipeParameters` containing `{{context.*}}` template expressions (e.g., `name: 'sa-{{context.resource.name}}'`), **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed to the module as ARM parameters. +4. **Given** a `recipeLocation` pointing to a non-existent Bicep module, **When** deployment is attempted, **Then** the system returns a clear error indicating the module cannot be fetched. +5. **Given** a resource deployed via a direct Bicep module recipe, **When** the resource is deleted, **Then** the underlying ARM deployment and provisioned infrastructure are cleaned up. + +--- + +### User Story 2 — Terraform Module Support (Priority: P1) + +As a platform engineer, I want to set `recipeLocation` to a standard Terraform module source — a registry path (e.g., `ballj/postgresql/kubernetes`), a Git URL (e.g., `git::https://github.com/org/module.git`), or an HTTP archive — and have Radius download and execute it directly. The system resolves my `recipeParameters` (including `{{context.*}}` expressions) into the module's input variables, and maps the module's outputs back to Radius resource properties. The module doesn't need a `context` variable, a `result` output, or any knowledge of Radius. + +**Today's workflow**: Download the module → write a wrapper that adds the `context` variable and `result` output → publish the wrapper → reference the wrapper. **With this feature**: set `recipeLocation` directly to the module's native source path. + +**Why this priority**: This is the core value proposition for Terraform users. Unlocking the Terraform module ecosystem — thousands of community and official modules — without intermediate wrapping steps is essential for adoption. + +**Independent Test**: Link a recipe pointing to a public Terraform registry module, deploy a resource, verify the module is downloaded and executed, and confirm outputs are accessible as resource properties. + +**Acceptance Scenarios**: + +1. **Given** a RecipePack with `recipeKind: 'terraform'` and `recipeLocation` set to a Terraform registry path (e.g., `ballj/postgresql/kubernetes`), **When** a resource is deployed with `recipeParameters` providing values for the module's input variables, **Then** the module receives those values and executes successfully. +2. **Given** a recipe with `recipeLocation` set to a Git URL (e.g., `git::https://github.com/org/module.git`), **When** a resource is deployed, **Then** the system clones the module from Git and executes it. +3. **Given** a recipe with `recipeLocation` pointing to a Git URL with a ref specifier (e.g., `?ref=v2.0.0`) or a subdirectory path (e.g., `//modules/vpc`), **When** deployed, **Then** the system uses the specified ref and/or navigates to the subdirectory. +4. **Given** a Terraform module with output values, **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping — non-sensitive outputs in the `Values` map, sensitive outputs (marked `sensitive = true`) in the `Secrets` map. +5. **Given** `recipeParameters` containing `{{context.*}}` expressions, **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed as Terraform input variables. +6. **Given** a module that requires a variable not supplied by any parameter source, **When** deployment is attempted, **Then** Terraform surfaces a clear error indicating which required variable is missing. +7. **Given** a resource deployed via a direct Terraform module recipe, **When** the resource is deleted, **Then** the system runs `terraform destroy` and cleans up all provisioned infrastructure. + +--- + +### User Story 3 — Application-to-Recipe Property Resolution (Priority: P1) + +As a platform engineer, I want `recipeParameters` to resolve properties injected from the application layer (via `context.resource.properties.*`) so that application developers can influence infrastructure configuration through resource properties without knowing the underlying module details. For example, an application developer sets a `size` property on a resource, and the recipe resolves it into a concrete infrastructure SKU using expressions like ternary operators. + +**Why this priority**: This bridges the application and infrastructure layers — application developers express intent through resource properties, and the recipe translates that intent into concrete module inputs. It builds on the direct module support from P1 stories and works identically for any Bicep or Terraform module. + +**Independent Test**: Link a recipe that uses `context.resource.properties.*` expressions in `recipeParameters`, deploy resources with different property values, and verify each deployment passes the correct resolved values to the module. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeParameters` containing `{{context.resource.properties.*}}` expressions, **When** a resource is deployed with specific property values set by the application developer, **Then** those property values are resolved and passed to the module as input parameters. +2. **Given** a recipe with `recipeParameters` containing a ternary expression (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`), **When** resources are deployed with different property values, **Then** each deployment passes the correct resolved value to the module. +3. **Given** a recipe with `recipeParameters` that combine context property expressions with literal text (e.g., `name: 'pg-{{context.resource.name}}'`), **When** a resource is deployed, **Then** the module receives the fully resolved parameter values. +--- + +### User Story 4 — Private Module Authentication (Priority: P2) + +As a platform engineer, I want to use modules hosted in private registries, private Git repositories, or private OCI registries as recipes, authenticating with credentials configured through the existing secret store. + +**Why this priority**: Enterprise teams host modules in private repositories. Without authentication support, direct module support is limited to public modules. + +**Independent Test**: Link a recipe pointing to a private Terraform registry module or Git repository, configure credentials via the existing secret mechanism, deploy, and verify the module is fetched successfully. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeLocation` pointing to a private Terraform registry module, **When** registry credentials are configured via the existing secret store mechanism, **Then** the system authenticates and fetches the module successfully. +2. **Given** a recipe with `recipeLocation` pointing to a private Git repository, **When** Git credentials are configured, **Then** the system clones and executes the module. + +--- + +### User Story 5 — Source Reachability Validation at Link Time (Priority: P2) + +As a platform engineer, I want the system to validate that a `recipeLocation` pointing to a direct module source is reachable when I link the recipe to an environment, so I catch typos and inaccessible sources early rather than at deploy time. + +**Why this priority**: Early validation prevents wasted time debugging deploy failures caused by simple typos or unreachable sources. + +**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module source and verify a validation error is returned. + +**Acceptance Scenarios**: + +1. **Given** a recipe with `recipeLocation` pointing to a non-existent registry module, **When** the recipe is linked to an environment, **Then** the system returns a validation error (definitive failures like 404 or authentication denied reject the operation). +2. **Given** a recipe with a valid `recipeLocation` that experiences a transient network failure during validation, **When** the recipe is linked, **Then** the system logs a warning but allows the operation to proceed. + +--- + +### Edge Cases + +- What happens when a module has no input variables? The recipe deploys successfully with no parameters required. +- What happens when a module has no outputs? The deployment succeeds with an empty output set. +- What happens when the module source becomes unavailable after initial deployments? Existing resources are unaffected; new deployments fail with a clear error. +- How does the system handle modules that expect specific provider configurations? The existing provider configuration mechanism applies — providers are configured through the environment's recipe configuration. +- What happens when a parameter name does not match any module input variable? The underlying IaC engine surfaces a clear error. +- How does the system handle output resolution? The `outputs` mapping on the RecipePack is the preferred path for all recipes (direct and wrapped). If a module also produces a structured `result` output, it is honored for backward compatibility, but the `outputs` mapping takes precedence when both are present. +- What happens when a `{{context.*}}` expression contains a typo? The unrecognized expression is left as a literal string — deliberate design to avoid masking errors. +- What happens when a `{{context.*}}` expression is malformed (e.g., unclosed `{{`, trailing dot)? Malformed expressions are left as literal strings — the IaC engine will surface errors if the literal doesn't match expected input types. +- What happens when a parameter contains multiple `{{context.*}}` expressions? All expressions are independently resolved. +- What happens when a resource deployed via a direct module recipe is updated (e.g., `recipeParameters` change)? The module is re-executed with the new parameters — ARM redeployment for Bicep, `terraform apply` with updated variables for Terraform. This is idempotent by design and consistent with existing recipe behavior. +- What happens when a module has breaking changes between versions? The platform engineer must update the version in `recipeLocation` deliberately — no automatic version bumping occurs. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Input Resolution (Both Bicep and Terraform)**: + +- **FR-001**: System MUST pass `recipeParameters` through to a direct module's native inputs — ARM deployment parameters for Bicep, Terraform input variables for Terraform — without requiring the module to declare any Radius-specific input variables (e.g., no `context` variable needed). +- **FR-002**: System MUST support `{{context.*}}` template expressions in `recipeParameters` values that resolve to Radius runtime context at deployment time. Supported paths include resource metadata (name, id, type, properties), application metadata (name, id), environment metadata (name, id), Kubernetes runtime info (namespace, environmentNamespace), Azure metadata (resource group, subscription), and AWS metadata (region, account). Mixed content (e.g., `prefix-{{context.resource.name}}-suffix`) MUST be resolved while preserving surrounding literal text. Unrecognized paths MUST be left as-is. +- **FR-003**: System MUST support single-level conditional ternary expressions inside `{{...}}` that map context values to concrete configuration values (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`). Nested ternaries are out of scope for V1. +- **FR-004**: System MUST merge `recipeParameters` from the RecipePack and environment-level overrides using shallow merge (top-level keys only). Environment-level parameters take precedence for overlapping keys, replacing the entire value including nested objects. + +**Output Resolution (Both Bicep and Terraform)**: + +- **FR-005**: System MUST surface module output values as resource properties after successful deployment, without requiring the module to produce a Radius-specific structured `result` output. +- **FR-006**: System MUST support an `outputs` field on the RecipePack that maps module output names to the resource type's read-only properties (e.g., `outputs: { host: 'fqdn', port: 'listen_port' }` where keys are resource property names and values are module output names). +- **FR-007**: For Terraform modules, outputs marked `sensitive = true` MUST be routed to the `Secrets` map instead of the `Values` map, without requiring any module modifications. + +**Bicep Module Support**: + +- **FR-008**: System MUST accept a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) as the `recipeLocation` value in a RecipePack with `recipeKind: 'bicep'`, without requiring the module to conform to Radius recipe wrapper conventions. +- **FR-009**: System MUST deploy a direct Bicep module through the existing ARM deployment mechanism, passing resolved `recipeParameters` as ARM deployment parameters. +- **FR-010**: System MUST clean up infrastructure provisioned by a direct Bicep module recipe when the deployed resource is deleted. + +**Terraform Module Support**: + +- **FR-011**: System MUST accept a standard Terraform module source as the `recipeLocation` value in a RecipePack with `recipeKind: 'terraform'`. Supported source formats include: Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...` with optional `?ref=` and `//subdir`), HTTP archive URLs, S3 URLs (`s3::...`), and GCS URLs (`gcs::...`). The module MUST NOT be required to include any Radius-specific conventions. +- **FR-012**: System MUST resolve and download the Terraform module at deployment time using standard Terraform module retrieval mechanisms. +- **FR-013**: System MUST execute `terraform destroy` when a resource deployed via a direct Terraform module recipe is deleted. + +**Backward Compatibility**: + +- **FR-014**: System MUST ensure existing recipe workflows (wrapped recipes with `context` variable and `result` output) continue to function identically — zero behavioral changes to existing deployments. +- **FR-015**: System MUST support the `outputs` mapping for both direct modules and existing wrapped recipes. If a module produces a structured `result` output (with `values`, `secrets`, `resources`), it is still honored for backward compatibility. However, the `outputs` mapping on the RecipePack is the preferred path for output resolution and works uniformly across all recipe types. When both `result` and `outputs` are present, the `outputs` mapping takes precedence. + +**Error Handling**: + +- **FR-016**: System MUST surface module execution errors (missing variables, provider failures, permission errors) as recipe deployment failures with actionable error messages including the relevant IaC engine error details. +- **FR-017**: System MUST handle modules with no input variables (deploy with no parameters) and modules with no outputs (succeed with empty output set) without errors. + +**Authentication**: + +- **FR-018**: System MUST support authentication for private module sources (private registries, private Git repositories, private OCI registries) using the existing secret store and credential configuration mechanisms. + +**Validation (Best-Effort)**: + +- **FR-019**: System SHOULD perform best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time, using lightweight probes with a reasonable timeout. Definitive failures (404, authentication denied) SHOULD reject the operation. Transient failures SHOULD be logged as warnings but SHOULD NOT block linking. + +### Key Entities + +- **RecipePack (`Radius.Core/recipePacks`)**: The primary API resource for this feature. A RecipePack is a collection of recipe configurations keyed by resource type. Each recipe entry has `recipeLocation` that accepts direct module references — Bicep OCI references (`br:...`), Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...`), and other Terraform source formats — alongside existing wrapped recipe references. Key fields per recipe entry: `recipeKind` (terraform or bicep), `recipeLocation` (module source, version included in the reference), `recipeParameters` (input values with `{{context.*}}` expression support), and `outputs` (maps module output names to the resource type's read-only properties). +- **Environment**: Configures the deployment context. Recipes are linked to environments (this already works today). Environments can provide environment-level `recipeParameters` that merge with (and override) recipe-level parameters. +- **Recipe Output (from module outputs)**: For direct modules, module output values are mapped to the resource type's read-only properties via the `outputs` mapping. For Terraform, outputs marked `sensitive = true` are routed to the `Secrets` map. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A platform engineer can configure a recipe using an existing Bicep or Terraform module in under 1 minute by setting `recipeLocation` directly — zero wrapping, zero republishing. +- **SC-002**: End-to-end provisioning time for a direct module recipe is within 10% of an equivalent wrapped-recipe deployment — no significant overhead from the direct path. +- **SC-003**: Module output values are mapped to the resource type's read-only properties after deployment via the `outputs` mapping on the RecipePack. +- **SC-004**: Deleting a resource deployed via a direct module recipe fully destroys the underlying infrastructure with zero orphaned resources. +- **SC-005**: Deployment with an inaccessible `recipeLocation` fails within 60 seconds with a clear, actionable error message. +- **SC-006**: Existing wrapped-recipe workflows continue to function with zero behavioral changes — full backward compatibility. +- **SC-007**: Any standard Bicep module from an OCI registry or Terraform module from the public registry/Git (including AVM modules) that does not require Radius-specific conventions can be used directly as a recipe without modification. +- **SC-008**: Platform engineers can use `{{context.*}}` expressions to inject runtime context (resource name, namespace, environment, etc.) into any module's parameters without modifying the module. +- **SC-009**: Linking a recipe with a `recipeLocation` pointing to a non-existent or unreachable module source performs best-effort validation at link time. Definitive failures (404, authentication denied) return a validation error before deployment. Transient failures are logged as warnings but do not block linking. + +## Assumptions + +- **A-001**: The existing Bicep driver (ARM deployments) and Terraform driver (module execution) are extended to handle direct module references. No new driver or execution engine is introduced. +- **A-002**: Module input variable types are passed through without type transformation. Type checking is delegated to the underlying IaC engine (ARM for Bicep, Terraform CLI for Terraform), which produces clear errors for type mismatches. +- **A-003**: This feature operates alongside the existing recipe workflow. Wrapped recipes continue to work exactly as before. +- **A-004**: Infrastructure state management uses existing mechanisms — ARM deployment tracking for Bicep, Kubernetes secret-backed Terraform state for Terraform. +- **A-005**: Provider configuration uses existing mechanisms — Azure provider context for Bicep, `recipes.Configuration` for Terraform. +- **A-006**: Local filesystem paths as `recipeLocation` are out of scope. The initial scope covers OCI-hosted Bicep modules, Terraform registry modules, Git-hosted modules, and HTTP/S3/GCS archives. +- **A-007**: AVM modules are treated identically to any other direct module — no special handling. The "AVM" designation is purely organizational. +- **A-008**: Recipe linking to environments already works today and is not part of this feature. This feature is solely about what happens when `recipeLocation` points to a standard module instead of a wrapped one. +- **A-009**: Module version is specified as part of `recipeLocation` (e.g., OCI tag for Bicep, `?ref=` for Git, registry version syntax for Terraform). There is no separate `templateVersion` field. +- **A-010**: Direct module deployments use the same observability mechanisms (logging, tracing, metrics) as existing wrapped-recipe deployments. No new observability infrastructure is introduced. +- **A-011**: Sensitive input parameters (e.g., database passwords, API keys) are handled by the existing Radius secret store mechanism. No new secret handling is introduced for direct module support. +- **A-012**: Transient failure retry during module fetch at deploy time is delegated to the underlying IaC engine (Terraform and ARM both have built-in retry for transient failures). No custom Radius-level retry logic is introduced. + +## Future Steps + +The following are good-to-have capabilities that build on the core feature but are not required for initial delivery: + +- **Inspect Module Schema Before Deployment**: Retrieve a module's input variables and outputs before deploying, enabling discoverability and reducing trial-and-error. Engineers can consult module documentation as a workaround today. diff --git a/specs/001-direct-module-support/tasks.md b/specs/001-direct-module-support/tasks.md new file mode 100644 index 00000000000..8590eb70e2b --- /dev/null +++ b/specs/001-direct-module-support/tasks.md @@ -0,0 +1,275 @@ + +# Tasks: Direct Module Support + +**Input**: Design documents from `specs/001-direct-module-support/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Prototype Status**: Branch `001-direct-module-support` (3 commits ahead of main) already implements source classification (`pkg/recipes/source/`), TF direct output mapping, TF config generation (`AddAllOutputs`, `AddDirectModuleContext`), reachability validation, and RecipePack controller wiring. Tasks below cover **remaining work only**. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Verify prototype state and prepare for remaining implementation + +- [ ] T001 Rebase branch `001-direct-module-support` onto latest main and verify `make build` succeeds +- [ ] T002 Run existing unit tests (`go test ./pkg/recipes/source/... ./pkg/recipes/driver/terraform/... ./pkg/recipes/terraform/...`) and confirm green baseline + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core utilities shared by ALL P1 user stories (US1, US2, US3) — MUST complete before any story work begins. The expression resolver (including `context.resource.properties.*` paths and single-level ternary evaluation) is the foundational engine for all three P1 stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T003 Implement `ResolveParameterExpressions()` and `buildContextLookup()` in `pkg/recipes/paramresolver/resolver.go` — regex-based `{{context.*}}` replacement using flat context map built from `recipecontext.Context` struct; include `context.resource.properties.*` dynamic key enumeration so any user-defined property is accessible (US3 property resolution); unrecognized expressions left as-is per R-011 +- [ ] T004 Implement single-level ternary expression evaluation in `pkg/recipes/paramresolver/resolver.go` — parse `{{expr == "val" ? "trueResult" : "falseResult"}}` syntax; single-level only (nested/chained ternaries are out of scope for V1); string comparison is exact-match, case-sensitive; ternary with unresolvable condition path left as-is +- [ ] T005 Write table-driven unit tests for `ResolveParameterExpressions()` in `pkg/recipes/paramresolver/resolver_test.go` — cover: single expression, multiple expressions in one value, mixed literal+expression, unrecognized expression left as-is, empty map, nil values, nested map traversal, `context.resource.properties.*` resolution (existing property resolves, missing property left as-is, property with special characters, multiple property expressions in one string) +- [ ] T006 Write table-driven unit tests for ternary evaluation in `pkg/recipes/paramresolver/resolver_test.go` — cover: simple ternary true/false, context property lookup in condition, unresolvable condition path (left as-is), mixed ternary + literal text, nested/chained ternary explicitly out of scope (verify left as-is) +- [ ] T007 [P] Implement output mapping utility function `ApplyOutputsMapping(values map[string]any, secrets map[string]any, outputsMap map[string]string) (map[string]any, map[string]any)` in `pkg/recipes/util/outputs.go` — map module output names to the resource type's read-only property names per `outputs` map (keys = resource property names, values = module output names); when `outputs` is nil/empty, pass through all outputs unchanged +- [ ] T008 [P] Write table-driven unit tests for `ApplyOutputsMapping()` in `pkg/recipes/util/outputs_test.go` — cover: output-to-property mapping, pass-through when nil, missing output key in values (skip silently), sensitive output mapping, empty maps +- [ ] T009 [P] Implement shallow merge utility `ShallowMergeParameters(base map[string]any, override map[string]any) map[string]any` in `pkg/recipes/util/merge.go` — top-level key precedence from override per FR-004; nested objects replaced entirely (not deep-merged) +- [ ] T010 [P] Write table-driven unit tests for `ShallowMergeParameters()` in `pkg/recipes/util/merge_test.go` — cover: disjoint keys, overlapping keys (override wins), nested object replaced not merged, nil base, nil override, both nil +- [ ] T011 Add `Outputs map[string]string` field to `EnvironmentDefinition` in `pkg/recipes/types.go` and propagate from `RecipeDefinition.Outputs` through the config loader path + +**Checkpoint**: Foundation ready — expression resolver (with `context.resource.properties.*` and single-level ternary), output mapping, shallow merge, and EnvironmentDefinition.Outputs all available for driver integration. All three P1 stories (US1, US2, US3) can now proceed. + +--- + +## Phase 3: User Story 2 — Terraform Module Support (Priority: P1) 🎯 MVP + +**Goal**: Platform engineers set `recipeLocation` to a Terraform registry path, Git URL, or HTTP archive. The system downloads the module, resolves `{{context.*}}` expressions (including `context.resource.properties.*` and single-level ternary) in parameters, executes it, and maps outputs to the resource type's read-only properties. + +**Independent Test**: Link a recipe to a public Terraform registry module (e.g., `ballj/postgresql/kubernetes`), deploy a resource with `recipeParameters` including `{{context.resource.name}}`, verify module executes and outputs are accessible as resource properties. + +### Implementation for User Story 2 + +- [ ] T012 [US2] Replace `AddDirectModuleContext` auto-injection with `ResolveParameterExpressions()` call in `pkg/recipes/terraform/execute.go` — in the direct module code path, resolve expressions in `EnvironmentDefinition.Parameters` before writing them to the generated config; remove the old well-known variable name matching per R-011 +- [ ] T013 [US2] Wire `ShallowMergeParameters()` into the Terraform execute path in `pkg/recipes/terraform/execute.go` — merge RecipePack-level `recipeParameters` with environment-level parameters (environment wins) before expression resolution +- [ ] T014 [US2] Wire `ApplyOutputsMapping()` into `prepareRecipeResponse()` in `pkg/recipes/driver/terraform/terraform.go` — after flat output collection for direct modules, apply `EnvironmentDefinition.Outputs` to map module output names to the resource type's read-only properties in `RecipeOutput.Values` and `RecipeOutput.Secrets` +- [ ] T015 [US2] Implement `result` vs `outputs` precedence logic in `pkg/recipes/driver/terraform/terraform.go` — per FR-015: if module has a `result` output AND no `outputs` mapping is configured, treat as wrapped recipe; if `outputs` mapping exists, it takes precedence +- [ ] T016 [US2] Update unit tests in `pkg/recipes/driver/terraform/terraform_test.go` — add test cases for: direct module with outputs mapping to read-only properties, direct module without outputs mapping (pass-through), direct module with `result` output and no `outputs` mapping (wrapped behavior), direct module with both `result` and `outputs` (outputs wins) +- [ ] T017 [US2] Update unit tests in `pkg/recipes/terraform/execute_test.go` — add test cases for: expression resolution in direct module path (including `context.resource.properties.*` and ternary), shallow merge of parameters, context lookup populated from recipe context + +**Checkpoint**: Terraform direct module path fully wired — expression resolution (with property paths and ternary), parameter merge, output mapping to read-only properties, result/outputs precedence + +--- + +## Phase 4: User Story 1 — Bicep Module Support (Priority: P1) + +**Goal**: Platform engineers set `recipeLocation` to a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`). The system deploys it via ARM, passes resolved parameters (including `context.resource.properties.*` and ternary expressions), and maps ARM outputs to the resource type's read-only properties. + +**Independent Test**: Link a recipe to a public AVM Bicep module from MCR, deploy a resource with `recipeParameters`, verify ARM deployment succeeds and module outputs are accessible as resource properties. + +### Implementation for User Story 1 + +- [ ] T018 [US1] Implement direct Bicep module detection in `pkg/recipes/driver/bicep/bicep.go` — detect when `recipeLocation` is a standard Bicep module (no Radius `result` output convention); use a flag or heuristic (e.g., absence of `result` in module outputs after ARM template inspection, or explicit `outputs` mapping presence) +- [ ] T019 [US1] Implement direct Bicep deployment path in `pkg/recipes/driver/bicep/bicep.go` — resolve `{{context.*}}` expressions in parameters via `ResolveParameterExpressions()`, pass resolved parameters as ARM deployment parameters, invoke ARM deployment +- [ ] T020 [US1] Wire `ShallowMergeParameters()` into the Bicep driver path in `pkg/recipes/driver/bicep/bicep.go` — merge RecipePack-level and environment-level parameters before expression resolution +- [ ] T021 [US1] Map ARM deployment outputs to `RecipeOutput` in `pkg/recipes/driver/bicep/bicep.go` — apply `ApplyOutputsMapping()` to map module output names to the resource type's read-only property names per `outputs` field +- [ ] T022 [US1] Ensure ARM deployment cleanup for direct Bicep modules in `pkg/recipes/driver/bicep/bicep.go` — verify ARM deployment deletion works for direct module deployments on resource delete +- [ ] T023 [US1] Write unit tests for Bicep direct module path in `pkg/recipes/driver/bicep/bicep_test.go` — cover: direct module detection, parameter resolution, output mapping to read-only properties, ARM deployment creation, ARM deployment deletion/cleanup + +**Checkpoint**: Bicep direct module path complete — all three P1 user stories (Terraform + Bicep + Property Resolution) are now functional + +--- + +## Phase 5: User Story 4 — Private Module Authentication (Priority: P2) + +**Goal**: Platform engineers use modules from private registries, Git repos, or OCI registries by configuring credentials through the existing secret store. + +**Independent Test**: Link a recipe to a private Terraform registry module, configure credentials via existing secret mechanism, deploy, and verify module is fetched successfully. + +### Implementation for User Story 4 + +- [ ] T024 [US4] Implement credential passthrough for private Terraform registry modules in `pkg/recipes/terraform/execute.go` — read credentials from the existing secret store and pass as Terraform CLI config (`.terraformrc` or `TF_TOKEN_*` environment variables) before `terraform init` +- [ ] T025 [US4] Implement credential passthrough for private Git repositories in `pkg/recipes/terraform/execute.go` — configure Git credentials (`GIT_ASKPASS` or credential helper) from secret store before module download +- [ ] T026 [US4] Write unit tests for private module credential injection in `pkg/recipes/terraform/execute_test.go` — cover: registry token injection, Git credential configuration, missing credentials (fall through gracefully), credential scoping (credentials only applied when source matches) + +**Checkpoint**: Private module authentication works for Terraform registry and Git sources + +--- + +## Phase 6: User Story 5 — Source Reachability Validation at Link Time (Priority: P2) + +**Goal**: The system performs best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time. Per SC-009: definitive failures (404, authentication denied) reject the operation; transient failures log warnings but allow linking. + +**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module and verify a validation error is returned. Link with a transiently unreachable source and verify a warning is logged but the operation succeeds. + +### Implementation for User Story 5 + +- [ ] T027 [US5] Wire `ValidateReachability()` into `CreateOrUpdateRecipePack` controller for Bicep OCI references in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack.go` — extend existing Terraform reachability validation to also cover `br:` Bicep module references (HEAD request to OCI manifest); per SC-009: definitive failures (404, auth denied) reject, transient failures log warning but allow linking +- [ ] T028 [US5] Write unit tests for Bicep OCI reachability validation in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go` — cover: valid Bicep OCI reference passes, non-existent reference returns rejection error (definitive failure), transient network failure logs warning but allows creation, authentication denied returns rejection error + +**Checkpoint**: Reachability validation covers both Terraform and Bicep module sources with best-effort semantics (SC-009) + +--- + +## Phase 7: TypeSpec Model Alignment (Priority: P1) + +**Goal**: Align the TypeSpec API model with the implementation — add `outputs` and `recipeParameters` fields. A RecipePack is a collection of recipe configurations keyed by resource type. + +- [ ] T029 Add `outputs` field (type `Record`) and `recipeParameters` field (type `Record`) to the RecipePack recipe definition model in `typespec/Radius.Core/recipePacks.tsp` — RecipePack is a collection keyed by resource type; no `templateVersion` field (version is part of `recipeLocation` per A-009) +- [ ] T030 Regenerate API client code from updated TypeSpec model (`make generate`) and fix any compilation errors in generated code +- [ ] T031 Update `RecipeDefinition` struct in `pkg/corerp/datamodel/recipepack.go` to align JSON tags with generated API model — ensure `recipeParameters` and `outputs` fields serialize correctly for round-trip API calls + +**Checkpoint**: TypeSpec model, generated code, and internal data model are aligned + +--- + +## Phase 8: Functional Tests (Priority: P1) + +**Goal**: End-to-end validation with real modules — must cover `{{context.resource.properties.*}}` resolution and ternary expressions + +- [ ] T032 [P] Create functional test for Terraform registry module deployment in `test/functional-portable/recipes/` — use a lightweight public registry module (e.g., `ballj/postgresql/kubernetes`), configure `recipeParameters` with `{{context.*}}` expressions including `{{context.resource.properties.*}}` and ternary, verify deployment succeeds and outputs are mapped to resource read-only properties +- [ ] T033 [P] Create functional test for Git-hosted module deployment in `test/functional-portable/recipes/` — use `git::https://` source with `?ref=` version pin, verify module download, execution, and output mapping to read-only properties +- [ ] T034 [P] Create functional test for Bicep AVM module deployment in `test/functional-portable/recipes/` — use a public AVM Bicep module from MCR (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`), configure `recipeParameters`, verify ARM deployment succeeds and outputs are mapped to resource read-only properties +- [ ] T035 Create functional test for backward compatibility in `test/functional-portable/recipes/` — deploy a wrapped recipe (with `context` variable and `result` output) and verify zero behavioral changes + +**Checkpoint**: End-to-end tests validate the complete direct module flow for Terraform and Bicep, including property resolution and ternary expressions + +--- + +## Phase 9: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories + +- [ ] T036 [P] Run full linting pass (`make lint && make format-check`) and fix any issues across all modified packages +- [ ] T037 [P] Update inline code documentation (GoDoc comments) for all new exported functions and types in `pkg/recipes/paramresolver/`, `pkg/recipes/util/`, and modified driver files +- [ ] T038 Verify `make build` succeeds and run full unit test suite (`make test`) — confirm no regressions across the codebase +- [ ] T039 Run `specs/001-direct-module-support/quickstart.md` scenarios manually against a local Radius environment to validate end-to-end UX + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories +- **US2 Terraform (Phase 3)**: Depends on Foundational — first MVP increment +- **US1 Bicep (Phase 4)**: Depends on Foundational — can run in parallel with Phase 3 +- **US4 Private Auth (Phase 5)**: Depends on Foundational — can run in parallel with Phases 3–4 +- **US5 Reachability (Phase 6)**: Depends on Foundational — can run in parallel with Phases 3–5 +- **TypeSpec (Phase 7)**: No story dependency — can run in parallel with any phase after Setup +- **Functional Tests (Phase 8)**: Depends on Phases 3 + 4 (Terraform + Bicep paths wired with expression resolver) +- **Polish (Phase 9)**: Depends on all other phases + +### User Story Dependencies + +- **US1 (P1 Bicep)**: Can start after Foundational — no dependencies on other stories +- **US2 (P1 Terraform)**: Can start after Foundational — no dependencies on other stories +- **US3 (P1 Property Resolution)**: Implemented in Foundational phase — expression resolver includes `context.resource.properties.*` and ternary; validated through US1/US2 driver wiring and functional tests +- **US4 (P2 Private Auth)**: Independent of all other stories +- **US5 (P2 Reachability)**: Independent of all other stories (extends existing validation) + +### Within Each User Story + +- Core implementation before integration wiring +- Wiring before unit tests +- All tasks within a story are sequential unless marked [P] + +### Parallel Opportunities + +``` +After Foundational completes (which includes US3 expression engine), +all of these can run simultaneously: + + ┌─ Phase 3: US2 Terraform ─────────────┐ + ├─ Phase 4: US1 Bicep ─────────────────┤ + ├─ Phase 5: US4 Private Auth ──────────┤──▶ Phase 8: Functional Tests + ├─ Phase 6: US5 Reachability ──────────┤ + └─ Phase 7: TypeSpec ──────────────────┘ +``` + +Within Foundational: T007+T008, T009+T010 can all run in parallel with each other (and with the T003→T004→T005→T006 sequential chain). + +--- + +## Parallel Example: Foundational Phase + +```bash +# Sequential (has dependency — same file, expression resolver): +Task T003: "Implement ResolveParameterExpressions() with context.resource.properties.* in pkg/recipes/paramresolver/resolver.go" +Task T004: "Implement single-level ternary evaluation in pkg/recipes/paramresolver/resolver.go" +Task T005: "Write tests for expression resolver (basic + properties) in pkg/recipes/paramresolver/resolver_test.go" +Task T006: "Write tests for ternary evaluation in pkg/recipes/paramresolver/resolver_test.go" + +# Parallel with T003–T006 (different files, no dependencies): +Task T007: "Implement ApplyOutputsMapping() in pkg/recipes/util/outputs.go" +Task T008: "Write tests for ApplyOutputsMapping() in pkg/recipes/util/outputs_test.go" +Task T009: "Implement ShallowMergeParameters() in pkg/recipes/util/merge.go" +Task T010: "Write tests for ShallowMergeParameters() in pkg/recipes/util/merge_test.go" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 2 — Terraform Only) + +1. Complete Phase 1: Setup (verify prototype baseline) +2. Complete Phase 2: Foundational (expression resolver with properties + ternary, output mapping, shallow merge) +3. Complete Phase 3: User Story 2 (Terraform direct module path) +4. **STOP and VALIDATE**: Test with a real Terraform registry module end-to-end (including `context.resource.properties.*` and ternary) +5. Deploy/demo if ready — this alone unlocks the Terraform module ecosystem with property resolution + +### Incremental Delivery + +1. Setup + Foundational → Foundation ready (US3 expression engine included) +2. Add US2 (Terraform) → Test independently → Demo (**MVP!** — includes property resolution + ternary) +3. Add US1 (Bicep) → Test independently → Demo (full P1 delivery — all 3 P1 stories complete) +4. Add US4 + US5 → Test independently → Demo (enterprise features) +5. TypeSpec + Functional Tests + Polish → Release ready + +### Parallel Team Strategy + +With multiple developers after Foundational completes: + +- **Developer A**: US2 Terraform (Phase 3) then Functional Tests (Phase 8) +- **Developer B**: US1 Bicep (Phase 4) then US5 Reachability (Phase 6) +- **Developer C**: US4 Private Auth (Phase 5) then Polish (Phase 9) +- **Developer D**: TypeSpec (Phase 7) then Polish (Phase 9) + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| **Total tasks** | 39 | +| **Setup** | 2 tasks (Phase 1) | +| **Foundational (incl. US3 engine)** | 9 tasks (Phase 2) | +| **US2 Terraform (P1)** | 6 tasks (Phase 3) | +| **US1 Bicep (P1)** | 6 tasks (Phase 4) | +| **US4 Private Auth (P2)** | 3 tasks (Phase 5) | +| **US5 Reachability (P2)** | 2 tasks (Phase 6) | +| **TypeSpec Alignment (P1)** | 3 tasks (Phase 7) | +| **Functional Tests (P1)** | 4 tasks (Phase 8) | +| **Polish** | 4 tasks (Phase 9) | +| **Parallel opportunities** | 5 phases can run simultaneously after Foundational | +| **Suggested MVP** | Phases 1–3 (US2 Terraform: 17 tasks, includes US3 property resolution) | +| **P1 stories** | US1, US2, US3 — all delivered by end of Phase 4 | + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story for traceability +- **US3 (Property Resolution) is P1** — its expression engine (ternary + `context.resource.properties.*`) is built in Foundational and ships with US1/US2 +- Prototype code (source classifier, TF output mapping, config generation) is already implemented — not re-tasked +- RecipePack is a collection of recipe configurations keyed by resource type +- Module version is part of `recipeLocation` (OCI tag, Git ref, registry syntax) — no separate `templateVersion` field (A-009) +- Output mapping maps module outputs to the resource type's read-only properties (not rename/filter) +- Reachability validation is best-effort: definitive failures reject, transient failures warn (SC-009) +- Each user story is independently completable and testable +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently From 8e935343d0fb0297b924d85f9215f7277920d65c Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Date: Tue, 12 May 2026 14:18:12 -0700 Subject: [PATCH 02/16] Update spec.md Signed-off-by: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Signed-off-by: Reshma Abdul Rahim --- specs/001-direct-module-support/spec.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/specs/001-direct-module-support/spec.md b/specs/001-direct-module-support/spec.md index c426dd54cbe..5db0e471ad6 100644 --- a/specs/001-direct-module-support/spec.md +++ b/specs/001-direct-module-support/spec.md @@ -4,21 +4,16 @@ **Created**: 2026-04-22 **Updated**: 2026-04-30 **Status**: Draft -**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. +**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius Recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. ## The Problem -Today, every Bicep or Terraform module used as a Radius recipe must be wrapped in a Radius-specific shim: +Today, every Bicep or Terraform module used as a Radius Recipe must be wrapped in a Radius-specific adapter. The wrapper adds a context input and a structured result output that conforms to Radius recipe conventions. To use a community module, platform engineers must create the wrapper, publish it to a distribution source (Git for Terraform, OCI for Bicep), and keep it updated as the upstream module evolves. -- **For both Bicep and Terraform**: The wrapper adds a `context` input variable and a structured `result` output (separating `values`, `secrets`, and `resources`) that conforms to Radius recipe conventions. The platform engineer downloads a community module, writes a wrapper that calls it, re-publishes the wrapper, and references that wrapper as the recipe. The problem is identical regardless of IaC language. +1. Adoption friction — every module requires a custom recipe wrapper that must be published and versioned separately from the original module before it can be used. +1. Maintenance burden — upstream module updates require wrapper changes, validation, and republishing, creating version drift over time. -This wrapper tax has real consequences: - -1. **Friction**: Every module requires a bespoke wrapper before it can be used. Wrapping a single module takes 15–60 minutes and requires understanding both the module's interface and Radius conventions. -2. **Maintenance burden**: When the upstream module releases a new version, the wrapper must be updated and re-published. Wrapper drift causes silent failures. -3. **Ecosystem lock-out**: Thousands of production-ready modules exist in the Terraform Registry, MCR (for Bicep), and Git repositories. The wrapper requirement means none of them work out of the box. - -**Direct module support eliminates the wrapper.** Platform engineers point `recipeLocation` at any standard module. The system resolves inputs (injecting Radius context into the module's native variables) and resolves outputs (mapping the module's native outputs to resource properties) — all externally, without modifying the module. +Direct module support eliminates the Recipe wrapper. Platform engineers point recipeLocation directly at any standard module, and Radius handles context injection and output mapping externally—without modifying, republishing, or forking the upstream module. ## User Scenarios & Testing *(mandatory)* From 7c7d25a2b94922035f85921159b15cdd34fe1450 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Mon, 18 May 2026 21:53:38 -0700 Subject: [PATCH 03/16] Speckit spec to Traditional feature spec Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 224 +++++++++++++ .../checklists/requirements.md | 38 --- .../contracts/source-resolver.go | 97 ------ specs/001-direct-module-support/data-model.md | 212 ------------ specs/001-direct-module-support/plan.md | 261 --------------- specs/001-direct-module-support/prompt.md | 65 ---- specs/001-direct-module-support/quickstart.md | 302 ------------------ specs/001-direct-module-support/research.md | 245 -------------- specs/001-direct-module-support/spec.md | 207 ------------ specs/001-direct-module-support/tasks.md | 275 ---------------- 10 files changed, 224 insertions(+), 1702 deletions(-) create mode 100644 eng/design-notes/recipes/2026-05-direct-recipe-modules.md delete mode 100644 specs/001-direct-module-support/checklists/requirements.md delete mode 100644 specs/001-direct-module-support/contracts/source-resolver.go delete mode 100644 specs/001-direct-module-support/data-model.md delete mode 100644 specs/001-direct-module-support/plan.md delete mode 100644 specs/001-direct-module-support/prompt.md delete mode 100644 specs/001-direct-module-support/quickstart.md delete mode 100644 specs/001-direct-module-support/research.md delete mode 100644 specs/001-direct-module-support/spec.md delete mode 100644 specs/001-direct-module-support/tasks.md diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md new file mode 100644 index 00000000000..da4384014f0 --- /dev/null +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -0,0 +1,224 @@ +# Topic: Direct Recipe Modules + +* **Author**: Reshma Abdul Rahim (@Reshrahim) + +## Topic Summary + +Direct Recipe Module Support enables platform engineers to use any standard Bicep or Terraform module as a Radius Recipe without writing a Radius-specific wrapper. Today, using a module as a Radius Recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `location` directly at a standard module, and the system handles input resolution (injecting Radius context) and output resolution (mapping module outputs to resource properties) externally. + +### Top level goals + +- Enable platform engineers to leverage community owned Terraform registry modules and Azure Verified Modules directly as Radius Recipes +- Eliminate the need to write a Recipe wrapper, publish, and maintain these Recipes separately from the underlying module +- Eliminate the need for Radius to maintain a catalog of wrapped Recipes in the `resource-types-contrib` repository thus reducing maintenance overhead and surface area for supply chain attacks + +### Non-goals (out of scope) + +- Local filesystem paths as `location` sources +- Chained/nested ternary expressions (V1 supports single-level only) +- Automatic module version bumping +- Custom retry logic for module fetch failures (delegated to underlying IaC engine) +- New observability infrastructure (uses existing logging, tracing, metrics) + +## User profile and challenges + +### User persona(s) + +**Platform Engineer**: Responsible for defining infrastructure recipes that application developers consume. Manages recipe packs, environments, and provider configurations. Typically works across multiple teams and maintains a catalog of infrastructure patterns. + +**Application Developer**: Consumes recipes by declaring resources with properties (e.g., `database: 'mydb'`, `size: 's'`). Does not need to understand the underlying IaC module or infrastructure details. + +### Challenge(s) faced by the user + +1. **Adoption friction** — Every module requires a custom recipe wrapper that must be published and versioned separately before it can be used. Using a community Terraform module requires writing a Recipe wrapper, publishing the wrapper to a distribution source and then adding it to Recipe packs for consumption. + +2. **Maintenance burden** — Upstream module updates require wrapper changes, validation, and republishing, creating version drift over time. Platform engineers must track upstream releases and update wrappers accordingly. + +3. **Ecosystem lockout** — Thousands of production-ready community modules (Terraform registry, Azure Verified Modules) cannot be used directly, limiting the value proposition of Radius recipes. + +### Positive user outcome + +Platform engineers can easily configure a recipe using any existing module setting `location` directly — zero wrapping, zero republishing. The entire Terraform and Bicep module ecosystem becomes immediately usable as Radius recipes. + +## Key scenarios + +### Scenario 1: Terraform Registry Module + +A platform engineer sets `location` to a Terraform registry path (e.g., `terraform-aws-modules/rds/aws`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to resource properties via the `outputs` field. + +### Scenario 2: Azure Verified Modules (OCI) + +A platform engineer sets `location` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to resource properties via the `outputs` field. + +### Scenario 3: Git-hosted Terraform Module + +A platform engineer references a private or public Git-hosted module (`git::https://github.com/org/module.git//subdir?ref=v2.0`) and the system clones and executes it. + +## Key dependencies and risks + +- **Community module coverage** — Not every Radius resource type will have a suitable community module available. Platform engineers may still need to author custom modules for resource types without community coverage or where community modules don't align with organizational requirements. +- **Terraform version compatibility** — Direct modules may use features from newer Terraform versions. Mitigation: Terraform settings feature enables specifying the Terraform version. +- **Module API stability** — Direct modules expose platform engineers to upstream breaking changes. Mitigation: version pinning via `?ref=` for Git or registry version constraints. +- **Expression resolution correctness** — Unresolved `{{context.*}}` expressions are left as literal strings, which may cause cryptic downstream errors from the IaC engine. Mitigation: clear documentation of available expression paths. + +## Current state + +Radius currently supports recipes through wrapped modules that conform to specific conventions: +- **Terraform**: Module must declare a `context` input variable and produce a structured `result` output +- **Bicep**: Module must accept a `context` parameter and produce a `result` output + +Recipe Packs (`Radius.Core/recipePacks`) were introduced to group recipe definitions by resource type. This feature builds on Recipe Packs by extending `RecipeDefinition` with `outputs` mapping and broadening `location` to accept standard module sources. + +## Details of user problem + +When I want to use a community Terraform module (like `terraform-aws-modules/rds/aws`) as a Radius recipe, I have to write a wrapper module that adds the `context` variable and `result` output, publish my wrapper to a Git repository or OCI registry based on the IaC engine, and then reference my wrapper in the recipe definition. If the upstream module releases a new version, I have to update my wrapper, test it, and republish. This creates a maintenance burden that scales with the number of modules in my catalog. + +## Desired user experience outcome + +After this feature, I can set `location` directly to `terraform-aws-modules/rds/aws` on my recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my resource type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. + +### Detailed user experience + +1. Platform engineer creates a RecipePack pointing directly at an upstream module: + + ```bicep + resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'data-pack' + properties: { + recipes: { + 'Radius.Data/mySqlDatabases': { + kind: 'terraform' + location: 'terraform-aws-modules/rds/aws' + parameters: { + identifier: '{{context.resource.name}}' + db_name: '{{context.resource.properties.database}}' + engine: 'mysql' + instance_class: 'db.t3.micro' + } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + } + } + } + } + } + ``` + +2. Application developer deploys a resource with properties: + + ```bicep + resource mysql 'Radius.Data/mySqlDatabases@2025-08-01-preview' = { + name: 'orders-db' + properties: { + environment: environment + application: app.id + database: 'ordersdb' + } + } + ``` + +3. System resolves expressions (`{{context.resource.name}}` → `'orders-db'`, `{{context.resource.properties.database}}` → `'ordersdb'`), merges parameters, and executes Terraform against the upstream module +4. Module outputs are mapped to resource properties via the `outputs` definition +5. Application developer reads `resource.properties.host`, `resource.properties.port` etc. + +## Key investments + +### Feature 1: Direct Module Execution + +Use any standard Bicep or Terraform module directly as a recipe by pointing `location` at the module source. The system automatically detects that the module is not a Radius wrapper (no `context` variable), downloads it, and executes it through the existing driver — no wrapper needed. + +### Feature 2: Template Expression Resolution + +A `{{context.*}}` expression system that resolves Radius application runtime context values into recipe parameters at deploy time. Supports resource metadata, application/environment info, Kubernetes runtime, Azure, and AWS provider context. Includes single-level ternary expressions for conditional value mapping. + +### Feature 3: Output Mapping + +An `outputs` field on `RecipeDefinition` that maps module output names to resource property names. Provides a stable property interface for resource consumers regardless of the underlying module's output naming. Sensitive outputs are automatically routed to the Secrets map. + +--- + +## Usage Examples + +### Terraform Registry Module (AWS RDS) + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'aws-data-pack' + properties: { + recipes: { + 'Radius.Data/mySqlDatabases': { + kind: 'terraform' + location: 'terraform-aws-modules/rds/aws' + parameters: { + identifier: '{{context.resource.name}}' + db_name: '{{context.resource.properties.database}}' + engine: 'mysql' + engine_version: '8.0' + instance_class: 'db.t3.micro' + manage_master_user_password: true + create_db_subnet_group: true + subnet_ids: subnetIds + vpc_security_group_ids: vpcSecurityGroupIds + } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + secretName: 'db_instance_master_user_secret_arn' + } + } + } + } +} +``` + +### Azure Verified Module (PostgreSQL) with T-Shirt Sizing + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'azure-postgres-pack' + properties: { + recipes: { + 'Radius.Data/postgreSqlDatabases': { + kind: 'terraform' + location: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + parameters: { + name: 'pg-{{context.resource.name}}' + location: 'eastus2' + sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' + storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' + tags: { + environment: '{{context.environment.name}}' + application: '{{context.application.name}}' + } + } + outputs: { + host: 'fqdn' + port: 'port' + database: 'database_name' + username: 'administrator_login' + } + } + } + } +} +``` + +### Environment-Level Parameter Overrides + +```bicep +resource devenv 'Radius.Core/environments@2025-08-01-preview' = { + name: 'dev' + properties: { + recipePacks: [recipepack.id] + recipeParameters: { + backup_retention_days: '2' + geo_redundant_backup_enabled: 'false' + } + } +} +``` + +Environment parameters merge with recipe-level parameters. Environment takes precedence for overlapping keys. diff --git a/specs/001-direct-module-support/checklists/requirements.md b/specs/001-direct-module-support/checklists/requirements.md deleted file mode 100644 index 89ff2661c68..00000000000 --- a/specs/001-direct-module-support/checklists/requirements.md +++ /dev/null @@ -1,38 +0,0 @@ -# Specification Quality Checklist: Direct Module Support - -**Purpose**: Validate specification completeness and quality before proceeding to planning -**Created**: 2026-04-30 -**Feature**: [spec.md](../spec.md) - -## Content Quality - -- [x] No implementation details (languages, frameworks, APIs) -- [x] Focused on user value and business needs -- [x] Written for non-technical stakeholders -- [x] All mandatory sections completed - -## Requirement Completeness - -- [x] No [NEEDS CLARIFICATION] markers remain -- [x] Requirements are testable and unambiguous -- [x] Success criteria are measurable -- [x] Success criteria are technology-agnostic (no implementation details) -- [x] All acceptance scenarios are defined -- [x] Edge cases are identified -- [x] Scope is clearly bounded -- [x] Dependencies and assumptions identified - -## Feature Readiness - -- [x] All functional requirements have clear acceptance criteria -- [x] User scenarios cover primary flows -- [x] Feature meets measurable outcomes defined in Success Criteria -- [x] No implementation details leak into specification - -## Notes - -- Full spec rewrite on 2026-04-30 to correct framing: eliminated "recipe registration" language, made input/output resolution core P1 capabilities (not advanced), and restructured story progression. -- Terminology corrections: "direct module support" (not "recipe registration"), "input resolution" and "output resolution" (the system handles these externally), recipes are "linked" to environments (already works today). -- Story progression: P1 = Basic Bicep module support (with I/O resolution) + Basic Terraform module support (with I/O resolution); P2 = AVM modules, version pinning, private auth; P3 = schema inspection, link-time validation. -- 7 user stories (2×P1, 3×P2, 2×P3), 21 functional requirements, 8 success criteria, 8 assumptions, 9 edge cases. -- No [NEEDS CLARIFICATION] markers — complete context available from prototype code, research notes, and quickstart examples. diff --git a/specs/001-direct-module-support/contracts/source-resolver.go b/specs/001-direct-module-support/contracts/source-resolver.go deleted file mode 100644 index 4c0231679a5..00000000000 --- a/specs/001-direct-module-support/contracts/source-resolver.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package source provides module source classification and validation for -// Terraform recipe recipe locations. It determines whether a recipeLocation -// refers to a direct Terraform module source (registry, Git, HTTP) or an -// existing OCI/wrapped recipe path, enabling the recipe system to apply -// appropriate execution and output mapping strategies. -// -// This file defines the contract (interface + types) for the source resolver. -// Implementation will be in resolver.go. -package source - -import "context" - -// SourceType classifies the format of a Terraform module source path. -type SourceType int - -const ( - // SourceTypeUnknown indicates the source format could not be classified. - // The system should fall back to existing OCI/wrapped recipe resolution. - SourceTypeUnknown SourceType = iota - - // SourceTypeTerraformRegistry indicates a standard Terraform registry source. - // Format: "namespace/name/provider" (exactly 3 slash-separated segments, no scheme). - // Example: "hashicorp/consul/aws", "Azure/cosmosdb/azurerm" - SourceTypeTerraformRegistry - - // SourceTypeGit indicates a Git-hosted module source. - // Format: "git::https://..." or "git::ssh://..." - // Supports ref specifiers (?ref=v1.0.0) and subdirectories (//modules/vpc). - SourceTypeGit - - // SourceTypeHTTP indicates an HTTP/HTTPS archive source. - // Format: "https://example.com/module.tar.gz" (without git:: prefix) - SourceTypeHTTP - - // SourceTypeS3 indicates an S3-hosted module source. - // Format: "s3::bucket-name/key" - SourceTypeS3 - - // SourceTypeGCS indicates a GCS-hosted module source. - // Format: "gcs::bucket-name/key" - SourceTypeGCS - - // SourceTypeOCI indicates an OCI registry source (existing wrapped recipe path). - // Format: contains "oci://" or matches OCI image reference patterns. - SourceTypeOCI -) - -// ResolvedSource contains the classification result for a recipe location. -type ResolvedSource struct { - // Type is the classified source type. - Type SourceType - - // OriginalPath is the unmodified recipeLocation value. - OriginalPath string - - // IsDirectModule is true when the source is a direct Terraform module - // (not a wrapped/OCI recipe). This determines output mapping strategy. - IsDirectModule bool -} - -// Resolver classifies and validates Terraform module source paths. -type Resolver interface { - // Classify determines the source type of a recipe location without making - // any network calls. Classification is purely based on string pattern matching. - // - // Returns a ResolvedSource with Type set to the detected source type. - // If the format is not recognized, Type is SourceTypeUnknown and - // IsDirectModule is false (indicating fallback to existing behavior). - Classify(recipeLocation string) ResolvedSource - - // ValidateReachability performs a lightweight network check to verify - // that the module source is accessible. This is called at RecipePack - // creation time per FR-014. - // - // For registry modules: HTTP GET to registry API - // For Git sources: git ls-remote - // For HTTP sources: HTTP HEAD request - // - // Returns nil if the source is reachable, or an error describing why - // it could not be reached. The check has a 30-second timeout. - // - // If the source type is SourceTypeUnknown or SourceTypeOCI, this - // method returns nil (no validation for fallback paths). - ValidateReachability(ctx context.Context, recipeLocation string, templateVersion string) error -} - -// IsDirectModuleSource is a convenience function that classifies the given -// recipeLocation and returns true if it represents a direct Terraform module -// source (registry, git, HTTP, S3, or GCS) rather than a wrapped/OCI recipe. -// -// This is the primary entry point for the terraform driver to determine -// which output mapping strategy to use. -func IsDirectModuleSource(recipeLocation string) bool { - // Implementation delegates to the default resolver's Classify method. - // Defined here as a package-level function for ergonomic usage. - return false // placeholder -} diff --git a/specs/001-direct-module-support/data-model.md b/specs/001-direct-module-support/data-model.md deleted file mode 100644 index 8a5d92121e2..00000000000 --- a/specs/001-direct-module-support/data-model.md +++ /dev/null @@ -1,212 +0,0 @@ -# Data Model: Direct Terraform and AVM Module Support via Recipe Packs - -## Overview - -This feature extends the existing recipe pack data model with two new fields on `RecipeDefinition` — `recipeParameters` and `outputs` — and broadens the `recipeLocation` field to accept standard Terraform module sources (registry, Git, HTTP, S3, GCS) alongside existing wrapped recipe references. - -## Extended Entities - -### RecipeDefinition (extended schema) - -**Location**: `pkg/corerp/datamodel/recipepack.go` - -```go -type RecipeDefinition struct { - RecipeKind string `json:"recipeKind"` // "terraform" or "bicep" - RecipeLocation string `json:"recipeLocation"` // Template source path/URL - RecipeParameters map[string]any `json:"recipeParameters,omitempty"` // Input parameters with {{context.*}} support - PlainHTTP bool `json:"plainHTTP,omitempty"` // Allow insecure connections - Outputs map[string]string `json:"outputs,omitempty"` // Maps resource property → module output name -} -``` - -**New Fields**: -- `RecipeParameters`: Input parameters passed through to Terraform module variables. Values may contain `{{context.*}}` template expressions resolved at deployment time. -- `Outputs`: Maps resource property names to module output names (e.g., `{"host": "hostname"}` means resource property `host` gets its value from module output `hostname`). When empty/nil, all module outputs pass through with original names. - -**`RecipeLocation` accepts**: -- Terraform Registry: `hashicorp/consul/aws`, `Azure/avm-res-storage-storageaccount/azurerm`, `ballj/postgresql/kubernetes` -- Git URLs: `git::https://github.com/org/terraform-aws-vpc.git` -- Git with ref: `git::https://github.com/org/module.git?ref=v2.0.0` -- Git with subdirectory: `git::https://github.com/org/repo.git//modules/vpc` -- HTTP archives: `https://example.com/modules/vpc.tar.gz` -- S3: `s3::https://bucket.s3.amazonaws.com/module.zip` -- GCS: `gcs::https://bucket.storage.googleapis.com/module.zip` -- Existing OCI/wrapped recipes: `ghcr.io/org/recipe:v1` (unchanged behavior) - -**Template Expressions in RecipeParameters**: Values can contain `{{context.*}}` expressions resolved at deployment time. For example, `{{context.runtime.kubernetes.namespace}}` resolves to the target Kubernetes namespace. Mixed content is supported (e.g., `prefix-{{context.resource.name}}-suffix`). Unrecognized expressions are left as-is. - -**Validation Rules** (at creation time): -- Source must be reachable (lightweight probe, 30s timeout) — definitive failures reject, transient warnings allowed -- Source format must be classifiable by the resolver -- If unclassifiable, accepted without validation (fallback to existing behavior) - -### EnvironmentDefinition (internal, extended) - -**Location**: `pkg/recipes/types.go` - -```go -type EnvironmentDefinition struct { - Name string // Recipe name - Driver string // "terraform" or "bicep" - ResourceType string // Portable resource type - Parameters map[string]any // Default recipe parameters - TemplatePath string // Module source URL/path (expanded behavior) - TemplateVersion string // Module version (used for registry pinning) - PlainHTTP bool // Allow insecure connections - Outputs map[string]string // Maps resource property → module output name -} -``` - -**New Field**: `Outputs` — populated from RecipeDefinition.Outputs via the config loader. - -### RecipeOutput (extended with DirectModule flag) - -**Location**: `pkg/recipes/types.go` - -```go -type RecipeOutput struct { - Resources []string // Deployed resource IDs (from TF state) - Secrets map[string]any // Sensitive output values - Values map[string]any // Non-sensitive output values - Status *rpv1.RecipeStatus - DirectModule bool // True when outputs come from a direct module (skip schema filter) -} -``` - -**Behavioral Change for Direct Modules**: -- `Values`: Populated with ALL non-sensitive Terraform module outputs. If `Outputs` mapping exists, values are renamed (module output name → resource property name). If no mapping, original names pass through. -- `Secrets`: Populated with ALL sensitive Terraform module outputs (same rename logic). -- `DirectModule`: Set to `true` by the TF driver for direct modules. When true, the DynamicProcessor skips schema filtering and dumps all outputs to resource.Properties. -- **`result` output priority**: The system checks for a `result` output FIRST for all sources. If `result` exists and no `outputs` mapping is configured, the module is treated as a wrapped recipe. This prevents misclassifying wrapped recipes hosted on registries. - -**Behavioral Change for Wrapped Recipes** (unchanged): -- Existing logic: looks for `result` output, parses into Resources/Secrets/Values - -## New Internal Types (not persisted) - -### SourceType Enum - -**Location**: `pkg/recipes/source/types.go` - -```go -type SourceType int - -const ( - SourceTypeUnknown SourceType = iota // Unclassified — use fallback - SourceTypeTerraformRegistry // e.g., "hashicorp/consul/aws" - SourceTypeGit // e.g., "git::https://..." - SourceTypeHTTP // e.g., "https://example.com/module.tar.gz" - SourceTypeS3 // e.g., "s3::bucket/key" - SourceTypeGCS // e.g., "gcs::bucket/key" - SourceTypeOCI // Existing OCI/wrapped recipe path -) -``` - -### ResolvedSource - -**Location**: `pkg/recipes/source/types.go` - -```go -type ResolvedSource struct { - Type SourceType // Classified source type - OriginalPath string // Original recipeLocation value - IsDirectModule bool // True if this is a direct TF module (not wrapped) -} -``` - -## State Transitions - -### Recipe Deployment Lifecycle (with direct module) - -``` -┌─────────────────┐ -│ RecipePack │ ← recipeLocation validated at creation -│ Created │ -└────────┬────────┘ - │ Deploy resource using recipe - ▼ -┌─────────────────┐ -│ Source │ ← Classify recipeLocation -│ Classification │ -└────────┬────────┘ - │ Direct module detected - ▼ -┌─────────────────┐ -│ Module │ ← terraform get (fresh download, no cache) -│ Download │ -└────────┬────────┘ - │ Success - ▼ -┌─────────────────┐ -│ Module │ ← Extract variables, outputs, providers -│ Inspection │ -└────────┬────────┘ - │ Generate config with all-output forwarding - ▼ -┌─────────────────┐ -│ Expression │ ← Resolve {{context.*}} in recipeParameters -│ Resolution │ via ResolveParameterExpressions() -└────────┬────────┘ - │ Parameters with resolved context values - ▼ -┌─────────────────┐ -│ Terraform │ ← init + apply -│ Execution │ -└────────┬────────┘ - │ Success - ▼ -┌─────────────────┐ -│ Output │ ← Apply outputs mapping (rename/filter) -│ Mapping │ or pass-through all outputs -└────────┬────────┘ - │ - ▼ -┌─────────────────┐ -│ Resource │ ← Outputs accessible via Radius API -│ Deployed │ (bypasses schema filter for direct modules) -└─────────────────┘ -``` - -## Relationships - -``` -RecipePack (1) ──contains──▶ (N) RecipeDefinition - │ │ - │ recipeLocation + recipeParameters + outputs - │ │ - │ ┌─────────┴──────────┐ - │ │ │ - │ Direct Module Wrapped/OCI Recipe - │ (new behavior) (existing behavior) - │ │ │ - │ ┌─────┴─────┐ │ - │ │ │ │ - │ Registry Git/HTTP │ - │ │ │ │ - │ └─────┬─────┘ │ - │ │ │ - ▼ ▼ ▼ -Environment ──uses──▶ TerraformDriver ◀──uses── Environment - (recipePacks, │ - recipeParameters)┌─────┴─────┐ - │ │ - Direct Mode Wrapped Mode - (flat output (result output - + outputs parsing) - mapping) -``` - -## Validation Rules - -| Field | Rule | When Applied | -|-------|------|--------------| -| `RecipeLocation` | Must be non-empty string | Always (existing) | -| `RecipeLocation` | If classifiable as direct module, source must be reachable | RecipePack create/update | -| `RecipeLocation` | Format must match one of: registry, git, http, s3, gcs, or OCI | Soft validation (unknown = fallback) | -| `RecipeKind` | Must be "terraform" for direct module sources | RecipePack create/update | -| `RecipeParameters` | Keys should match module input variable names | At terraform apply time (Terraform validates) | -| `RecipeParameters` | Values may contain `{{context.*}}` template expressions | Resolved at deploy time by ResolveParameterExpressions() | -| `Outputs` | Keys are resource property names, values are module output names | Applied at output mapping time in TF driver | -| `Outputs` | No duplicate target property names allowed | RecipePack create/update (validation pending) | -| `Outputs` | Target property names must not collide with reserved properties (application, environment, status, connections) | RecipePack create/update (validation pending) | diff --git a/specs/001-direct-module-support/plan.md b/specs/001-direct-module-support/plan.md deleted file mode 100644 index 165a95bb4be..00000000000 --- a/specs/001-direct-module-support/plan.md +++ /dev/null @@ -1,261 +0,0 @@ -# Implementation Plan: Direct Module Support - -**Branch**: `001-direct-module-support` | **Date**: 2026-04-30 | **Spec**: `specs/001-direct-module-support/spec.md` -**Input**: Feature specification from `specs/001-direct-module-support/spec.md` - -## Summary - -Enable platform engineers to use any standard Bicep or Terraform module as a Radius recipe — without writing a Radius-specific wrapper. The system classifies `recipeLocation` at creation time (registry, Git, HTTP, S3, GCS, or OCI) using pattern-matching heuristics, resolves `{{context.*}}` template expressions (including single-level ternary and `context.resource.properties.*` paths) in `recipeParameters` at deployment time, passes parameters through to the module's native inputs, and maps module outputs to the resource type's read-only properties via the `outputs` field on RecipePack. A RecipePack is a collection of recipe configurations keyed by resource type. Existing wrapped recipes continue to function identically. - -The prototype (3 commits on branch `001-direct-module-support`) already implements the Terraform-side core: source classification (`pkg/recipes/source/`), direct output mapping in the Terraform driver, config generation for all outputs, and best-effort reachability validation at RecipePack creation time. - -## Technical Context - -**Language/Version**: Go 1.22+ (per `go.mod`) -**Primary Dependencies**: `hashicorp/terraform-exec`, `hashicorp/terraform-config-inspect`, `hashicorp/go-getter`, ARM deployment client (Bicep), `github.com/radius-project/radius` monorepo -**Storage**: Kubernetes Secrets (Terraform state), ARM deployment tracking (Bicep state) -**Testing**: `go test` with `testify`, table-driven tests; functional tests in `test/functional-portable/` -**Target Platform**: Linux containers (Kubernetes), cross-platform CLI -**Project Type**: Cloud-native platform (API server + IaC drivers) -**Performance Goals**: Direct module deployment within 10% of equivalent wrapped-recipe deployment time (SC-002); inaccessible source fails within 60s (SC-005) -**Constraints**: Zero behavioral changes to existing wrapped recipes (FR-014); no new driver or execution engine (A-001); retry delegated to IaC engine (A-012) -**Scale/Scope**: 5 user stories (3×P1, 2×P2); ~8 packages modified; ~2000 LOC new + ~200 LOC modified - -## Constitution Check - -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - -The project constitution (`/.specify/memory/constitution.md`) is a template placeholder — no project-specific principles have been ratified. Gate passes trivially: no constraints to evaluate. - -**Post-Phase 1 re-check**: Design adheres to existing Radius conventions (Go idioms per `.github/instructions/golang.instructions.md`, existing driver architecture, existing test patterns). No violations detected. - -## Project Structure - -### Documentation (this feature) - -```text -specs/001-direct-module-support/ -├── plan.md # This file -├── research.md # Phase 0 output — design decisions R-001 through R-011 -├── data-model.md # Phase 1 output — extended RecipeDefinition, SourceType, ResolvedSource -├── quickstart.md # Phase 1 output — usage examples (AVM, Git, registry) -├── contracts/ -│ └── source-resolver.go # Phase 1 output — Resolver interface contract -└── tasks.md # Phase 2 output (created by /speckit.tasks) -``` - -### Source Code (repository root) - -```text -pkg/ -├── recipes/ -│ ├── source/ # NEW — Source classification & validation -│ │ ├── types.go # SourceType enum, ResolvedSource, Resolver interface -│ │ ├── resolver.go # Pattern-matching classifier, reachability probes -│ │ └── resolver_test.go # Table-driven classification + HTTP mock tests -│ ├── driver/ -│ │ ├── terraform/ -│ │ │ └── terraform.go # MODIFIED — prepareRecipeResponse: direct output mapping -│ │ └── bicep/ -│ │ └── bicep.go # MODIFIED — direct Bicep module deployment path -│ ├── terraform/ -│ │ ├── config/ -│ │ │ └── config.go # MODIFIED — AddAllOutputs(), AddDirectModuleContext() -│ │ ├── execute.go # MODIFIED — direct module context injection + output gen -│ │ └── module.go # MODIFIED — ModuleOutputs collection in inspectModule() -│ ├── paramresolver/ # NEW — {{context.*}} template expression resolver -│ │ ├── resolver.go # ResolveParameterExpressions(), buildContextLookup() -│ │ └── resolver_test.go # Expression resolution tests -│ └── types.go # MODIFIED — RecipeOutput.DirectModule flag -├── corerp/ -│ ├── datamodel/ -│ │ └── recipepack.go # MODIFIED — RecipeDefinition: recipeParameters, outputs -│ └── frontend/controller/recipepacks/ -│ └── createorupdaterecipepack.go # MODIFIED — source validation at creation time -typespec/ -└── Radius.Core/ - └── recipePacks.tsp # MODIFIED — outputs, recipeParameters in TypeSpec model - -test/ -└── functional-portable/ - └── recipes/ # EXTENDED — direct module functional tests -``` - -**Structure Decision**: This feature extends the existing monorepo structure. All new code lives in `pkg/recipes/source/` (classification) and `pkg/recipes/paramresolver/` (expression resolution). Modifications are surgical additions to existing driver, config, and controller packages. No new top-level directories. - -## Complexity Tracking - -No constitution violations to justify — the design follows existing patterns throughout. - ---- - -## Phase 0: Outline & Research - -*Status: **Complete** — see `specs/001-direct-module-support/research.md`* - -All technical unknowns have been resolved. Key decisions: - -| ID | Decision | Rationale | -|----|----------|-----------| -| R-001 | Pattern-matching heuristics for source classification | Deterministic, no network calls, simple ordered rules | -| R-002 | Two-phase resolution: classify → direct or OCI fallback | Avoids latency for unambiguous sources | -| R-003 | Flat output mapping for direct modules (Values + Secrets) | Simplest approach; no Radius-specific conventions imposed | -| R-004 | Best-effort reachability validation at creation time | Catches typos early; 30s timeout; transient → warning | -| R-005 | Version embedded in `recipeLocation` (OCI tag, Git ref, registry syntax) | No separate `templateVersion` field needed (A-009); version is part of the source reference | -| R-006 | Runtime detection via `IsDirectModuleSource()` classifier | No new persisted fields needed | -| R-007 | `AddAllOutputs()` generates forwarding output blocks | Terraform requires declared root outputs | -| R-010 | Sensitive outputs → `Secrets` map (via TF `sensitive` flag) | Security without module modifications | -| R-011 | Explicit `{{context.*}}` expressions (not auto-injection) | Unambiguous; platform engineer controls mapping | - ---- - -## Phase 1: Design & Contracts - -*Status: **Complete** — see `specs/001-direct-module-support/data-model.md`, `contracts/`, `quickstart.md`* - -### Data Model - -See `specs/001-direct-module-support/data-model.md` for full details. Summary of entities: - -#### RecipeDefinition (extended) - -| Field | Type | Description | -|-------|------|-------------| -| `recipeKind` | `string` | `"terraform"` or `"bicep"` | -| `recipeLocation` | `string` | Module source (registry path, Git URL, OCI ref, etc.) | -| `recipeParameters` | `map[string]any` | Input parameters; values support `{{context.*}}` expressions | -| `plainHTTP` | `bool` | Allow insecure OCI connections | -| `outputs` | `map[string]string` | Maps resource type's read-only property names → module output names | - -#### SourceType (new internal enum) - -| Value | Pattern | Direct? | -|-------|---------|---------| -| `TerraformRegistry` | `ns/name/provider` (3 segments, no dots in ns) | Yes | -| `Git` | `git::` prefix | Yes | -| `HTTP` | `http://` or `https://` (no `git::`) | Yes | -| `S3` | `s3::` prefix | Yes | -| `GCS` | `gcs::` prefix | Yes | -| `OCI` | `oci://` or hostname-with-dot first segment | No | -| `Unknown` | Unclassifiable | No (fallback) | - -#### RecipeOutput (extended) - -| Field | Change | -|-------|--------| -| `DirectModule bool` | **New** — signals driver to skip `result` parsing and use flat output mapping | - -#### State Transition - -``` -RecipePack Create → Source Classification → Reachability Probe (best-effort) - ↓ -Resource Deploy → Classify source → Download module → Inspect → Generate config - ↓ - Resolve {{context.*}} expressions → terraform init+apply (or ARM deploy) - ↓ - Map outputs (flat + Secrets for sensitive) → Apply outputs mapping to resource type's read-only properties → Resource ready -``` - -### Interface Contracts - -See `specs/001-direct-module-support/contracts/source-resolver.go`. Key contract: - -```go -type Resolver interface { - Classify(templatePath string) ResolvedSource - ValidateReachability(ctx context.Context, templatePath string) error -} -``` - -**Convenience function** (primary entry point for drivers): -```go -func IsDirectModuleSource(templatePath string) bool -``` - -### Quickstart - -See `specs/001-direct-module-support/quickstart.md` for end-to-end usage examples covering: -1. AVM PostgreSQL module with t-shirt sizing (ternary expressions) -2. Git-hosted module with ref/subdirectory -3. HTTP archive module -4. Output mapping for resource property materialization - ---- - -## Prototype Status - -The branch `001-direct-module-support` (3 commits ahead of main) implements the following: - -| Component | File(s) | Status | -|-----------|---------|--------| -| Source classifier | `pkg/recipes/source/types.go`, `resolver.go` | ✅ Implemented + tested | -| Reachability validation | `pkg/recipes/source/resolver.go` | ✅ Registry + HTTP probes | -| RecipePack controller | `pkg/corerp/frontend/controller/recipepacks/` | ✅ Classification at create time | -| TF driver output mapping | `pkg/recipes/driver/terraform/terraform.go` | ✅ Direct module flat mapping | -| TF config: AddAllOutputs | `pkg/recipes/terraform/config/config.go` | ✅ Forward all module outputs | -| TF config: AddDirectModuleContext | `pkg/recipes/terraform/config/config.go` | ✅ Well-known context variable injection | -| TF execute: direct module path | `pkg/recipes/terraform/execute.go` | ✅ Conditional context + output generation | -| TF module inspection | `pkg/recipes/terraform/module.go` | ✅ ModuleOutputs collection | - -### Not yet implemented (remaining work): - -| Component | Description | Priority | -|-----------|-------------|----------| -| `{{context.*}}` expression resolver | `ResolveParameterExpressions()` in param resolver package, including `context.resource.properties.*` paths | P1 | -| Single-level ternary evaluation | Single-level ternary in `{{context.*}}` expressions (nested ternaries out of scope for V1) | P1 | -| Bicep driver: direct module path | ARM deployment of standard Bicep modules without `result` output | P1 | -| Output mapping application | Apply `outputs` field to map module outputs to the resource type's read-only properties | P1 | -| Shallow merge for parameters | Environment-level parameter override with shallow merge semantics | P1 | -| Private module authentication | Credential passthrough for private registries/repos | P2 | -| Functional tests | End-to-end tests with real modules (registry, Git) | P1 | -| TypeSpec model update | `outputs` and `recipeParameters` in `recipePacks.tsp` | P1 | - ---- - -## Implementation Approach (Phase 2 Preview) - -The following outlines the logical work breakdown for task generation. Tasks should be ordered by dependency. - -### Layer 1: Core Infrastructure (no driver dependencies) - -1. **Parameter expression resolver** — `pkg/recipes/paramresolver/`: `ResolveParameterExpressions()`, `buildContextLookup()`, regex-based `{{context.*}}` replacement (including `context.resource.properties.*` paths for application-to-recipe property resolution), single-level ternary evaluation (`{{expr == "val" ? "trueResult" : "falseResult"}}`; nested ternaries out of scope for V1). Table-driven tests. This is the foundational expression engine for US1, US2, and US3 — all P1. -2. **Output mapping utility** — Apply `outputs` map to translate module output names to the resource type's read-only properties in `RecipeOutput.Values`/`Secrets`. Shared by both drivers. -3. **Shallow merge utility** — Merge RecipePack parameters with environment-level overrides (top-level key precedence). - -### Layer 2: Terraform Driver Completion - -4. **Wire expression resolver into TF execute path** — Replace `AddDirectModuleContext` (auto-injection) with `ResolveParameterExpressions()` per R-011 decision. -5. **Wire output mapping** — After `prepareRecipeResponse`, apply `outputs` mapping to map module outputs to resource type's read-only properties. -6. **Handle `result` vs `outputs` precedence** — Per FR-015: `outputs` mapping takes precedence when both are present. - -### Layer 3: Bicep Driver - -7. **Bicep direct module detection** — Classify `recipeLocation` for Bicep (`br:` OCI references that are standard modules vs. wrapped). -8. **Bicep direct deployment** — Pass resolved parameters as ARM deployment parameters; map ARM outputs to resource type's read-only properties via `outputs`. -9. **Bicep cleanup** — Ensure ARM deployment deletion for direct Bicep modules. - -### Layer 4: API & Validation - -10. **TypeSpec model update** — Add `outputs` and `recipeParameters` to RecipePack model (RecipePack is a collection keyed by resource type). -11. **Reachability validation enhancement** — Wire `ValidateReachability` into create/update controller for Bicep OCI references. Best-effort: definitive failures (404, auth denied) reject; transient failures log warnings but allow linking (per SC-009). -12. **Private module auth** — Credential passthrough from secret store to module fetch. - -### Layer 5: Testing & Integration - -13. **Unit tests** — Expression resolver (including ternary and `context.resource.properties.*`), output mapping, shallow merge, classifier edge cases. -14. **Functional tests** — End-to-end with real Terraform registry module, Git module, Bicep AVM module. Must cover `{{context.resource.properties.*}}` resolution and ternary expressions. -15. **Backward compatibility tests** — Verify wrapped recipes remain unchanged. - ---- - -## Generated Artifacts - -| Artifact | Path | Status | -|----------|------|--------| -| Plan | `specs/001-direct-module-support/plan.md` | ✅ This file | -| Research | `specs/001-direct-module-support/research.md` | ✅ Complete (R-001 through R-011) | -| Data Model | `specs/001-direct-module-support/data-model.md` | ✅ Complete | -| Contracts | `specs/001-direct-module-support/contracts/source-resolver.go` | ✅ Complete | -| Quickstart | `specs/001-direct-module-support/quickstart.md` | ✅ Complete | -| Tasks | `specs/001-direct-module-support/tasks.md` | ⏳ Pending (`/speckit.tasks`) | diff --git a/specs/001-direct-module-support/prompt.md b/specs/001-direct-module-support/prompt.md deleted file mode 100644 index f7a88db7b75..00000000000 --- a/specs/001-direct-module-support/prompt.md +++ /dev/null @@ -1,65 +0,0 @@ -# Feature: Direct Module Support via Recipe Packs - -Enable platform engineers to use existing Terraform or Azure verified modules directly as recipes within Radius recipe packs — without wrapping, republishing, or creating Radius-specific recipe artifacts. - -## Problem - -Today, to use an existing IaC module with Radius, engineers must wrap the module into a Radius recipe format, publish it to a registry, and then reference it. This is redundant work. Recipe packs should allow pointing directly at an existing Terraform or AVM module and have it work as-is. - -## Recipe Packs as Environment Configuration - -Recipe packs are resources that define a collection of recipes for different resource types. Environments reference recipe packs via the `recipePacks` property. A recipe pack bundles: - -- **Recipe definitions** per resource type (e.g., `Radius.Data/postgreSqlDatabases`) -- **Recipe kind** (`terraform` or `bicep`) -- **Recipe location** (module source — Terraform registry path, Git URL, etc.) -- **Recipe parameters** with support for `{{context.*}}` template expressions -- **Output mappings** (`outputs`) that map resource property names to module output names - -## Input and Output Mapping - -The key design challenge is matching a module's input variables to resource properties and the module's outputs to computed properties in Radius: - -- **Inputs**: Recipe parameters are defined on the recipe pack with `recipeParameters`. Template expressions like `{{context.resource.name}}` allow injecting Radius context at deploy time. Environment-level `recipeParameters` can provide additional defaults per resource type. -- **Outputs**: The `outputs` field on the recipe definition maps resource schema property names to module output names (e.g., `host: 'hostname'` means the resource's `host` property comes from the module's `hostname` output). For direct modules, all outputs are passed through to resource properties, with `outputs` acting as an optional rename/filter layer. - -## Example Bicep - -```bicep -resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { - name: 'pg-recipepack' - properties: { - recipes: { - 'Radius.Data/postgreSqlDatabases': { - recipeKind: 'terraform' - recipeLocation: 'ballj/postgresql/kubernetes' - recipeParameters: { - namespace: 'default' - name: '{{context.resource.name}}' - object_prefix: 'myapp-pg' - image_tag: 'latest' - } - outputs: { - host: 'hostname' - port: 'port' - database: 'database_name' - username: 'username' - secretName: 'password_secret' - } - } - } - } -} - -resource myenv 'Radius.Core/environments@2025-08-01-preview' = { - name: 'my-env' - properties: { - providers: { - kubernetes: { namespace: 'default' } - } - recipePacks: [ - recipepack.id - ] - } -} -``` diff --git a/specs/001-direct-module-support/quickstart.md b/specs/001-direct-module-support/quickstart.md deleted file mode 100644 index 19c44962ddf..00000000000 --- a/specs/001-direct-module-support/quickstart.md +++ /dev/null @@ -1,302 +0,0 @@ -# Quickstart: Direct Terraform and AVM Module Support via Recipe Packs - -## What This Feature Does - -Allows platform engineers to use any standard Terraform module or Azure Verified Modules directly as a recipe's `recipeLocation` in a RecipePack — without wrapping, republishing, or creating Radius-specific artifacts. The system downloads the module at deployment time, passes `recipeParameters` (with `{{context.*}}` expression resolution) as Terraform input variables, and surfaces module outputs as resource properties with optional rename/filter via the `outputs` mapping. - -## Usage Examples - -### Example 1: Azure Verified Module (AVM) — PostgreSQL Flexible Server with T-Shirt Sizing - -Uses the [Azure/avm-res-dbforpostgresql-flexibleserver/azurerm](https://registry.terraform.io/modules/Azure/avm-res-dbforpostgresql-flexibleserver/azurerm) module directly from the Terraform registry. Demonstrates conditional ternary expressions to translate abstract t-shirt sizes (`s`, `m`, `l`) into concrete Azure SKUs and storage configurations. - -```bicep -resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { - name: 'azure-postgres-pack' - properties: { - recipes: { - 'Radius.Data/postgreSqlDatabases': { - recipeKind: 'terraform' - recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' - recipeParameters: { - name: 'pg-{{context.resource.name}}' - location: 'eastus2' - // Single-level ternary: maps "s" to burstable SKU, else general purpose - sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' - storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' - tags: { - environment: '{{context.environment.name}}' - application: '{{context.application.name}}' - } - } - outputs: { - host: 'fqdn' - port: 'port' - database: 'database_name' - username: 'administrator_login' - } - } - } - } -} - -resource myenv 'Radius.Core/environments@2025-08-01-preview' = { - name: 'azure-env' - properties: { - providers: { - azure: { - } - } - recipePacks: [ - recipepack.id - ] - } -} -``` - -Application developers simply specify `size: 's'`, `size: 'm'`, or `size: 'l'` on their resource, and the platform engineer's ternary expressions translate these into the appropriate Azure SKU and storage configuration at deploy time. - -### Example 2: AWS RDS Module — PostgreSQL Database - -Uses the popular [terraform-aws-modules/rds/aws](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws) community module to provision an AWS RDS PostgreSQL instance directly — no wrapping required. Demonstrates ternary expressions for t-shirt size mapping to AWS instance classes and storage. - -```bicep -resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { - name: 'aws-data-pack' - properties: { - recipes: { - 'Radius.Data/postgreSqlDatabases': { - recipeKind: 'terraform' - recipeLocation: 'terraform-aws-modules/rds/aws' - recipeParameters: { - identifier: 'pg-{{context.resource.name}}' - instance_class: '{{context.resource.properties.size == "s" ? "db.t3.micro" : "db.r5.large"}}' - allocated_storage: '{{context.resource.properties.size == "s" ? "20" : "100"}}' - db_name: '{{context.resource.name}}' - create_db_subnet_group: true - subnet_ids: ['subnet-12345678', 'subnet-87654321'] - vpc_security_group_ids: ['sg-12345678'] - tags: { - Environment: '{{context.environment.name}}' - Application: '{{context.application.name}}' - } - } - outputs: { - host: 'db_instance_address' - port: 'db_instance_port' - database: 'db_instance_name' - username: 'db_instance_username' - } - } - } - } -} - -resource myenv 'Radius.Core/environments@2025-08-01-preview' = { - name: 'aws-env' - properties: { - providers: { - aws: { - scope: '/planes/aws/aws/accounts/123456789012/regions/us-east-1' - } - } - recipePacks: [ - recipepack.id - ] - } -} -``` - -## How Outputs Work - -The `outputs` mapping on the recipe definition maps resource property names to module output names. Only mapped outputs are surfaced: - -```bicep -outputs: { - host: 'hostname' // resource.properties.host ← module.hostname - port: 'port' // resource.properties.port ← module.port - database: 'database_name' // resource.properties.database ← module.database_name -} -``` - -Sensitive module outputs (marked `sensitive = true` in the module) are stored securely in the `Secrets` map rather than `Values`. - -## How Parameters Work - -`recipeParameters` map directly to Terraform input variables: - -1. **Recipe pack-level parameters** (in `recipeParameters` on recipe definition) → applied to every deployment -2. **Environment-level parameters** (in `recipeParameters` on environment) → merged with recipe pack params, environment takes precedence -3. **No parameter** for optional variables → Terraform uses the module's default value -4. **Missing required variable** → Terraform error surfaced through recipe failure -5. **`{{context.*}}` expressions** → resolved at deploy time against the recipe context - -### Environment-Level Parameter Overrides - -The environment can override or extend recipe pack parameters. This is useful when the same recipe pack is shared across environments (dev, staging, prod) but certain parameters need to differ. - -```bicep -resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { - name: 'pg-pack' - properties: { - recipes: { - 'Radius.Data/postgreSqlDatabases': { - recipeKind: 'terraform' - recipeLocation: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' - recipeParameters: { - name: 'pg-{{context.resource.name}}' - resource_group_name: '{{context.azure.resourceGroup.name}}' - postgresql_version: '16' - sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' - } - } - } - } -} - -resource devenv 'Radius.Core/environments@2025-08-01-preview' = { - name: 'dev' - properties: { - providers: { - azure: { - scope: '{{context.azure.resourceGroup.id}}' - } - } - recipePacks: [ - recipepack.id - ] - recipeParameters: { - backup_retention_days: '2' - geo_redundant_backup_enabled: 'false' - } - } -} -``` - -In this example, the dev environment adds `backup_retention_days` and disables geo-redundant backups. Environment parameters take precedence over recipe pack parameters when both define the same key. - -### Template Expression Resolution - -Parameter values can contain `{{context.*}}` expressions that inject Radius runtime context into Terraform module variables: - -```bicep -recipeParameters: { - // Direct value — passed as-is to Terraform - instance_class: 'db.t3.micro' - - // Context expression — resolved at deploy time - k8s_namespace: '{{context.runtime.kubernetes.namespace}}' - - // Mixed content — expression resolved, literals preserved - resource_prefix: 'app-{{context.resource.name}}-suffix' - - // Resource properties — access user-specified properties from the resource definition - db_size: '{{context.resource.properties.size}}' - - // Ternary expression — conditional value mapping at deploy time - sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' -} -``` - -### Ternary Expressions - -Ternary expressions enable conditional value mapping inside `{{...}}` expressions. They are evaluated at deploy time when `context.*` values are resolved. - -**Syntax:** `{{ == "" ? "" : ""}}` - -Single-level ternary only (V1 limitation — chained/nested ternaries are not supported): - -```bicep -recipeParameters: { - // Map t-shirt size to Azure PostgreSQL SKU (use separate parameters for multi-way mapping) - sku_name: '{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}' - - // Map size to storage — simple conditional - storage_mb: '{{context.resource.properties.size == "s" ? "32768" : "65536"}}' -} -``` - -**Behavior:** -- The left-hand side of `==` is resolved against the context lookup (same paths as regular expressions) -- String comparison is exact-match (case-sensitive) -- The else branch must be a literal string value (not another ternary) -- Values are always returned as strings (Terraform handles type conversion) -- If the condition path is unresolvable, the entire expression is left as-is - -> **Note:** For multi-way mapping (e.g., s/m/l → 3 different values), use separate recipe definitions per tier or handle the mapping in the module itself. Chained ternary support is planned for a future release. - -### Available Template Expressions - -| Expression | Description | -|------------|-------------| -| `{{context.resource.name}}` | Deployed resource name | -| `{{context.resource.id}}` | Deployed resource ID | -| `{{context.resource.type}}` | Deployed resource type | -| `{{context.resource.properties.}}` | User-specified resource property (e.g., `size`, `tier`) | -| `{{context.application.name}}` | Application name | -| `{{context.application.id}}` | Application ID | -| `{{context.environment.name}}` | Environment name | -| `{{context.environment.id}}` | Environment ID | -| `{{context.runtime.kubernetes.namespace}}` | Target Kubernetes namespace | -| `{{context.runtime.kubernetes.environmentNamespace}}` | Environment Kubernetes namespace | -| `{{context.azure.resourceGroup.name}}` | Azure resource group name | -| `{{context.azure.resourceGroup.id}}` | Azure resource group ID | -| `{{context.azure.subscription.subscriptionId}}` | Azure subscription ID | -| `{{context.aws.region}}` | AWS region | -| `{{context.aws.account}}` | AWS account ID | - -## Key Behaviors - -| Behavior | Details | -|----------|---------| -| **Module download** | Fresh download every deployment (no caching) | -| **Version pinning** | Embedded in `recipeLocation` for registry modules (e.g., `Azure/module/azurerm` uses latest, append version constraint in module config), `?ref=` for Git | -| **Provider config** | Uses existing `recipeConfig.terraform.providers` from environment | -| **State management** | Same Kubernetes secret backend as existing recipes | -| **Error handling** | Terraform errors surfaced directly in recipe failure response | -| **Existing recipes** | Zero behavioral changes — fully backward compatible | -| **`result` output** | If present AND no `outputs` mapping configured, uses wrapped recipe convention | -| **`outputs` mapping** | Takes precedence over `result` when configured — only mapped outputs flow through | - -## Development Workflow - -### Building - -```bash -make build -``` - -### Running Tests - -```bash -# Unit tests for the shared expression resolver -go test ./pkg/recipes/paramresolver/... - -# Unit tests for the output mapping utility -go test ./pkg/recipes/outputmapping/... - -# Unit tests for the source resolver -go test ./pkg/recipes/source/... - -# Unit tests for terraform driver changes -go test ./pkg/recipes/driver/terraform/... - -# Unit tests for bicep driver changes -go test ./pkg/recipes/driver/bicep/... - -# Unit tests for terraform executor changes -go test ./pkg/recipes/terraform/... - -# All recipe package tests -go test ./pkg/recipes/... - -# All unit tests -make test -``` - -### Linting - -```bash -make lint -make format-check -``` diff --git a/specs/001-direct-module-support/research.md b/specs/001-direct-module-support/research.md deleted file mode 100644 index eaca1d12f10..00000000000 --- a/specs/001-direct-module-support/research.md +++ /dev/null @@ -1,245 +0,0 @@ -# Research: Direct Terraform and AVM Module Support via Recipe Packs - -## R-001: Source Format Detection Strategy - -**Decision**: Use pattern-matching heuristics on the `recipeLocation` string to classify source type before attempting resolution. - -**Rationale**: The existing codebase already uses `hashicorp/go-getter` which handles the actual download. However, we need pre-resolution classification to determine: (a) whether to attempt direct module resolution vs. OCI/wrapped path, (b) which validation to apply at creation time, and (c) whether version pinning applies. Pattern-matching is simple, deterministic, and avoids network calls during classification. - -**Classification Rules**: -| Pattern | Source Type | Example | -|---------|-------------|---------| -| `//` (3-segment, no scheme) | Terraform Registry | `hashicorp/consul/aws` | -| `git::` prefix | Git repository | `git::https://github.com/org/module.git` | -| `http://` or `https://` (no `git::` prefix) | HTTP archive | `https://example.com/module.tar.gz` | -| `s3::` prefix | S3 bucket | `s3::bucket/path` | -| `gcs::` prefix | GCS bucket | `gcs::bucket/path` | -| Contains `oci://` or matches existing OCI patterns | OCI/Wrapped (fallback) | `ghcr.io/org/recipe:v1` | - -**Alternatives Considered**: -1. **Always try direct first, catch errors** — Rejected because it would add latency for existing OCI recipes (network timeout before fallback). -2. **Require explicit `terraform::` prefix** — Rejected per FR-012 (no new schema fields/flags) and the principle of using standard Terraform source syntax. -3. **Parse with go-getter's Detect** — Considered but go-getter's detect is designed for runtime, not pre-validation classification. It doesn't distinguish "valid but unreachable" from "not a valid source format." - ---- - -## R-002: Direct Module Resolution vs. OCI/Wrapped Fallback Strategy - -**Decision**: Implement a two-phase resolution in the config loader's `LoadRecipe` path: -1. Classify `recipeLocation` using source format detector -2. If classified as a known Terraform source (registry, git, http, s3, gcs) → mark as direct module -3. If classification is ambiguous or unknown → attempt direct resolution first, then fall back to existing OCI path - -**Rationale**: Per FR-015, the system must try direct resolution first and fall back. However, for unambiguous sources (3-segment registry paths, `git::` prefixed), we can skip the fallback path entirely, improving performance. The ambiguous case (e.g., a plain HTTPS URL that could be either an OCI manifest or a terraform archive) needs the try-then-fallback approach. - -**Alternatives Considered**: -1. **Purely sequential try/fallback for all paths** — Rejected because it adds unnecessary latency for clearly-Terraform sources. -2. **Add a flag to RecipeDefinition** — Rejected per FR-012 (no new fields). - ---- - -## R-003: Output Mapping for Direct Modules (FR-015 Precedence) - -**Decision**: For direct module recipes, apply a three-tier precedence for output handling: -1. If `outputs` mapping is configured → only mapped outputs flow through (sensitive outputs go to Secrets) -2. Elif module has a `result` output → use wrapped recipe convention (backward compat) -3. Else (direct module, no mapping) → pass all outputs through (sensitive → Secrets, non-sensitive → Values) - -**Rationale**: Per FR-015, the `outputs` mapping gives platform engineers explicit control over which module outputs map to which resource properties. When no mapping is configured, pass-through gives visibility into all module outputs. The `result` output is honored for backward compatibility with modules that follow the wrapped recipe convention but don't have an `outputs` mapping configured. - -**Implementation Approach**: -```go -// In prepareRecipeResponse, detect mode via precedence: -switch { -case hasOutputsMapping: - // Only mapped outputs flow through - values, secrets = outputmapping.Apply(rawOutputs, definition.Outputs) -case hasResultOutput: - // Wrapped recipe convention - recipeResponse.PrepareRecipeResponse(resultValue) -case isDirectModule: - // Pass through all outputs (sensitive → Secrets) - values, secrets = outputmapping.Apply(rawOutputs, nil) -} -``` - -**Alternatives Considered**: -1. **Auto-detect Resources/Secrets from output types** — Rejected; sensitivity flag is the only signal used. -2. **`result` takes precedence over `outputs`** — Rejected because `outputs` mapping is a platform engineer's explicit intent and should win. - ---- - -## R-004: RecipePack Creation-Time Validation - -**Decision**: Add a validation step in `CreateOrUpdateRecipePack` controller that, for Terraform recipe definitions with recognized direct module source formats, attempts to resolve the module source to verify reachability. - -**Rationale**: Per FR-014, the system must validate reachability at creation time. This catches typos, inaccessible registries, and bad URLs early rather than at deploy time. The validation uses a lightweight check (e.g., registry API probe for registry modules, HEAD request for HTTP, `git ls-remote` for Git) rather than a full module download. - -**Implementation Approach**: -- Extract validation into a `ValidateModuleSource(ctx, recipeLocation, templateVersion, secrets)` function in `pkg/recipes/source/` -- For registry modules: HTTP GET to `https://registry.terraform.io/v1/modules/{ns}/{name}/{provider}/{version}` (or configured registry) -- For Git sources: `git ls-remote` to verify the ref exists -- For HTTP sources: HTTP HEAD to verify URL responds with 2xx -- Validation is best-effort with a 30-second timeout; transient failures are logged as warnings but don't block creation - -**Alternatives Considered**: -1. **Full module download at creation time** — Rejected because it's expensive and slow; downloads can be large. -2. **No validation at creation time** — Rejected per FR-014. -3. **Async validation (create succeeds, mark status)** — Rejected for simplicity; synchronous validation with timeout is sufficient. - ---- - -## R-005: Version Pinning for Registry Modules - -**Decision**: Use the existing `TemplateVersion` field on `RecipeDefinition`/`EnvironmentDefinition`. When the source is a Terraform registry module, pass `TemplateVersion` as the `version` constraint in the generated `main.tf.json` module block (this already happens in `config.New()`). - -**Rationale**: The existing code in `pkg/recipes/terraform/config/config.go` already handles this: -```go -if moduleVersion != "" { - moduleConfig["version"] = moduleVersion -} -``` -No new code needed for version pinning itself — only for validating that the specified version exists (as part of creation-time validation in R-004). - -**Alternatives Considered**: -1. **Embed version in recipeLocation** (e.g., `hashicorp/consul/aws@1.2.0`) — Rejected because it diverges from Terraform conventions and the existing `TemplateVersion` field already exists. - ---- - -## R-006: Detecting Direct Module Mode at Execution Time - -**Decision**: Add a field or method to distinguish direct-module mode from wrapped-recipe mode during execution. Use the source resolver's classification result stored as a lightweight signal (e.g., a boolean `IsDirectModule` on `EnvironmentDefinition` or detected at runtime from `TemplatePath`). - -**Rationale**: The driver needs to know whether to use flat output mapping (direct) or `result`-based output mapping (wrapped). Since FR-012 prohibits new data model fields for persistence, the detection happens at runtime by re-classifying `TemplatePath` in the driver layer. - -**Implementation**: -```go -// In pkg/recipes/source/resolver.go -func IsDirectModuleSource(recipeLocation string) bool { - sourceType := Classify(recipeLocation) - return sourceType == SourceTypeTerraformRegistry || - sourceType == SourceTypeGit || - sourceType == SourceTypeHTTP || - sourceType == SourceTypeS3 || - sourceType == SourceTypeGCS -} -``` - -This avoids persisting new fields while giving the driver a clear signal. - -**Alternatives Considered**: -1. **Check for `result` output after module inspection** — Could work but adds complexity and breaks the "no changes to wrapped behavior" guarantee if a direct module happens to have a `result` output. -2. **Add a field to the persisted model** — Rejected per FR-012. - ---- - -## R-007: Terraform Config Generation for Direct Modules - -**Decision**: The existing `config.New()` function already generates valid Terraform configuration for any module source. For direct modules, the key differences are: -1. **Outputs**: Generate output blocks for ALL module outputs (not just `result`) -2. **Recipe Context**: Skip adding `recipe_context` variable (direct modules don't know about it) -3. **Parameters**: Pass through as-is (existing behavior works) - -**Rationale**: The existing config generation is already source-agnostic. The main change is in `AddOutputs()` — for direct modules, we need to generate output blocks that forward all module outputs: -```hcl -output "" { - value = module.. - sensitive = -} -``` - -This requires knowing the module's output names before generating config, which is already available from `inspectModule()`. - -**Implementation**: After `downloadAndInspect()`, pass the inspection result to a new `AddAllOutputs(moduleName, moduleOutputs)` method that generates forwarding outputs for each module output. - -**Alternatives Considered**: -1. **Use a wildcard output** — Not supported by Terraform. -2. **Don't generate output blocks, read from state directly** — Rejected because `terraform output` only returns values for declared outputs in the root module. - ---- - -## R-008: GetRecipeMetadata for Direct Modules - -**Decision**: The existing `GetRecipeMetadata` path already works for direct modules because it uses `downloadAndInspect()` which fetches the module and reads its variables. The returned `Parameters` map already contains all module input variables with their metadata. - -**Rationale**: No changes needed to the metadata retrieval flow. The `inspectModule()` function already parses all variables regardless of source type. This satisfies FR-010 and User Story 5. - -**Alternatives Considered**: None — existing code handles this correctly. - ---- - -## R-009: Error Handling for Direct Module Failures - -**Decision**: Surface Terraform errors directly through existing error paths. Use existing `RecipeError` types with appropriate error codes: -- `RecipeDownloadFailed` for source resolution failures -- `RecipeDeploymentFailed` for terraform apply errors (missing variables, provider failures) -- `RecipeDeletionFailed` for terraform destroy errors - -**Rationale**: Per FR-009, errors must be actionable with relevant Terraform error details. The existing error wrapping in the terraform executor and driver already includes the full Terraform error message. No new error types needed. - -**Alternatives Considered**: -1. **Parse and categorize Terraform errors** — Rejected per YAGNI; raw Terraform errors are already actionable for platform engineers. - ---- - -## R-010: Sensitive Output Handling - -**Decision**: For direct modules, outputs marked `sensitive = true` (TF) or `SecureString`/`SecureObject` type (Bicep ARM) are routed to `RecipeOutput.Secrets` instead of `RecipeOutput.Values`. This applies in both pass-through mode (no outputs mapping) and mapped mode (outputs mapping configured). - -**Rationale**: The existing split between Values and Secrets on RecipeOutput provides the security mechanism. The `outputmapping.Apply()` utility in `pkg/recipes/outputmapping/` handles this routing for both TF and Bicep drivers — sensitive outputs go to Secrets, non-sensitive to Values. - -**Implementation** (via shared `outputmapping` package): -```go -// outputmapping.Apply routes based on OutputValue.Sensitive flag -for propertyName, outputName := range mapping { - if output, ok := rawOutputs[outputName]; ok { - if output.Sensitive { - secrets[propertyName] = output.Value - } else { - values[propertyName] = output.Value - } - } -} -``` - ---- - -## R-011: Context Injection Strategy - -**Decision**: Use explicit `{{context.*}}` template expressions in recipe parameters, resolved by `paramresolver.ResolveParameters()`, instead of automatic variable name-matching. - -**Rationale**: Direct Terraform modules have arbitrarily named input variables. An automatic name-matching approach (e.g., injecting the Kubernetes namespace if a module has a variable named `namespace`) is semantically ambiguous — a `namespace` variable might refer to a Terraform provider namespace, a DNS namespace, or something else entirely. Automatic injection based on variable names would silently produce incorrect values with no way for the platform engineer to detect or override the behavior. - -Template expressions give platform engineers explicit control over which context values flow into which module variables. The mapping is visible in the RecipePack definition, auditable, and unambiguous. This also means modules with non-standard variable names (e.g., `k8s_ns` instead of `namespace`) work correctly — the platform engineer simply writes `{{context.runtime.kubernetes.namespace}}` as the parameter value for `k8s_ns`. - -**Supported Expression Paths**: -| Expression | Description | -|------------|-------------| -| `context.resource.name` | Deployed resource name | -| `context.resource.id` | Deployed resource ID | -| `context.resource.type` | Deployed resource type | -| `context.application.name` | Application name | -| `context.application.id` | Application ID | -| `context.environment.name` | Environment name | -| `context.environment.id` | Environment ID | -| `context.runtime.kubernetes.namespace` | Target Kubernetes namespace | -| `context.runtime.kubernetes.environmentNamespace` | Environment Kubernetes namespace | -| `context.azure.resourceGroup.name` | Azure resource group name | -| `context.azure.resourceGroup.id` | Azure resource group ID | -| `context.azure.subscription.subscriptionId` | Azure subscription ID | -| `context.azure.subscription.id` | Azure subscription ID (alias) | -| `context.aws.region` | AWS region | -| `context.aws.account` | AWS account ID | - -**Implementation Approach**: -- `ResolveParameters()` in `pkg/recipes/paramresolver/resolver.go` scans parameter string values for `{{...}}` patterns using regex -- `buildContextLookup()` creates a flat map from the `recipecontext.Context` struct (e.g., `"context.resource.name" → "my-resource"`) -- `resolveStringValue()` uses `regexp.ReplaceAllStringFunc` to replace each `{{context.*}}` match with the looked-up value -- Unrecognized expressions (no match in the lookup map) are left as-is — no silent failure, no empty substitution -- Called from both TF and Bicep drivers in the direct module path (when no `context` variable/parameter exists) -- Works alongside existing `AddRecipeContext` for wrapped recipes (which still use the single `context` variable) - -**Alternatives Considered**: -1. **Automatic name-matching (`AddDirectModuleContext`)** — Rejected due to semantic ambiguity of variable names. A variable named `namespace` could mean many things. Silent injection of wrong values is worse than requiring explicit mapping. -2. **New schema field for context mapping** — Rejected per FR-012 (no new data model fields). Template expressions in existing `Parameters` achieve the same result. -3. **Helm-style `{{ .Values.* }}` syntax** — Rejected because it conflicts with Terraform's own interpolation syntax (`${...}`) and Go template syntax. The `{{context.*}}` prefix is unambiguous and clearly indicates Radius context injection. diff --git a/specs/001-direct-module-support/spec.md b/specs/001-direct-module-support/spec.md deleted file mode 100644 index 5db0e471ad6..00000000000 --- a/specs/001-direct-module-support/spec.md +++ /dev/null @@ -1,207 +0,0 @@ -# Feature Specification: Direct Module Support - -**Feature Branch**: `001-direct-module-support` -**Created**: 2026-04-22 -**Updated**: 2026-04-30 -**Status**: Draft -**Input**: Enable platform engineers to use any standard Bicep or Terraform module as a Radius Recipe — without writing a Radius-specific wrapper. Today, using a module as a recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `recipeLocation` directly at a standard module, and the system handles input resolution (injecting Radius context like resource name, namespace, etc.) and output resolution (mapping module outputs to resource properties) externally, outside the module. The module doesn't need to know about Radius. - -## The Problem - -Today, every Bicep or Terraform module used as a Radius Recipe must be wrapped in a Radius-specific adapter. The wrapper adds a context input and a structured result output that conforms to Radius recipe conventions. To use a community module, platform engineers must create the wrapper, publish it to a distribution source (Git for Terraform, OCI for Bicep), and keep it updated as the upstream module evolves. - -1. Adoption friction — every module requires a custom recipe wrapper that must be published and versioned separately from the original module before it can be used. -1. Maintenance burden — upstream module updates require wrapper changes, validation, and republishing, creating version drift over time. - -Direct module support eliminates the Recipe wrapper. Platform engineers point recipeLocation directly at any standard module, and Radius handles context injection and output mapping externally—without modifying, republishing, or forking the upstream module. - -## User Scenarios & Testing *(mandatory)* - -### User Story 1 — Bicep Module Support (Priority: P1) - -As a platform engineer, I want to set `recipeLocation` to a standard Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and have Radius deploy it directly — passing my `recipeParameters` as the module's ARM parameters and mapping the module's outputs back to Radius resource properties — without writing a Radius-specific wrapper. - -**Today's workflow**: Download the module → write a Radius wrapper that adds the `result` output → publish the wrapper to an OCI registry → reference the wrapper in `recipeLocation`. **With this feature**: set `recipeLocation` to the module's OCI reference directly. - -**Why this priority**: Bicep is Radius's native IaC language. The Bicep driver already handles ARM deployments — extending it to handle standard modules (without the Radius `result` output convention) is the smallest incremental step. This immediately unblocks the AVM Bicep catalog (hundreds of production-ready modules). - -**Independent Test**: Link a recipe pointing to a public AVM Bicep module from MCR, deploy a resource, verify infrastructure is provisioned, and confirm module outputs are accessible as resource properties. - -**Acceptance Scenarios**: - -1. **Given** a RecipePack with `recipeKind: 'bicep'` and `recipeLocation` set to a standard Bicep module OCI reference, **When** a resource using this recipe is deployed with `recipeParameters` providing values for the module's parameters, **Then** the module receives those values and provisions infrastructure successfully. -2. **Given** a Bicep module with output values (e.g., `output endpoint string`), **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping on the RecipePack. -3. **Given** `recipeParameters` containing `{{context.*}}` template expressions (e.g., `name: 'sa-{{context.resource.name}}'`), **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed to the module as ARM parameters. -4. **Given** a `recipeLocation` pointing to a non-existent Bicep module, **When** deployment is attempted, **Then** the system returns a clear error indicating the module cannot be fetched. -5. **Given** a resource deployed via a direct Bicep module recipe, **When** the resource is deleted, **Then** the underlying ARM deployment and provisioned infrastructure are cleaned up. - ---- - -### User Story 2 — Terraform Module Support (Priority: P1) - -As a platform engineer, I want to set `recipeLocation` to a standard Terraform module source — a registry path (e.g., `ballj/postgresql/kubernetes`), a Git URL (e.g., `git::https://github.com/org/module.git`), or an HTTP archive — and have Radius download and execute it directly. The system resolves my `recipeParameters` (including `{{context.*}}` expressions) into the module's input variables, and maps the module's outputs back to Radius resource properties. The module doesn't need a `context` variable, a `result` output, or any knowledge of Radius. - -**Today's workflow**: Download the module → write a wrapper that adds the `context` variable and `result` output → publish the wrapper → reference the wrapper. **With this feature**: set `recipeLocation` directly to the module's native source path. - -**Why this priority**: This is the core value proposition for Terraform users. Unlocking the Terraform module ecosystem — thousands of community and official modules — without intermediate wrapping steps is essential for adoption. - -**Independent Test**: Link a recipe pointing to a public Terraform registry module, deploy a resource, verify the module is downloaded and executed, and confirm outputs are accessible as resource properties. - -**Acceptance Scenarios**: - -1. **Given** a RecipePack with `recipeKind: 'terraform'` and `recipeLocation` set to a Terraform registry path (e.g., `ballj/postgresql/kubernetes`), **When** a resource is deployed with `recipeParameters` providing values for the module's input variables, **Then** the module receives those values and executes successfully. -2. **Given** a recipe with `recipeLocation` set to a Git URL (e.g., `git::https://github.com/org/module.git`), **When** a resource is deployed, **Then** the system clones the module from Git and executes it. -3. **Given** a recipe with `recipeLocation` pointing to a Git URL with a ref specifier (e.g., `?ref=v2.0.0`) or a subdirectory path (e.g., `//modules/vpc`), **When** deployed, **Then** the system uses the specified ref and/or navigates to the subdirectory. -4. **Given** a Terraform module with output values, **When** the resource is deployed, **Then** the module's outputs are mapped to the resource type's read-only properties via the `outputs` mapping — non-sensitive outputs in the `Values` map, sensitive outputs (marked `sensitive = true`) in the `Secrets` map. -5. **Given** `recipeParameters` containing `{{context.*}}` expressions, **When** the resource is deployed, **Then** expressions are resolved to actual Radius context values before being passed as Terraform input variables. -6. **Given** a module that requires a variable not supplied by any parameter source, **When** deployment is attempted, **Then** Terraform surfaces a clear error indicating which required variable is missing. -7. **Given** a resource deployed via a direct Terraform module recipe, **When** the resource is deleted, **Then** the system runs `terraform destroy` and cleans up all provisioned infrastructure. - ---- - -### User Story 3 — Application-to-Recipe Property Resolution (Priority: P1) - -As a platform engineer, I want `recipeParameters` to resolve properties injected from the application layer (via `context.resource.properties.*`) so that application developers can influence infrastructure configuration through resource properties without knowing the underlying module details. For example, an application developer sets a `size` property on a resource, and the recipe resolves it into a concrete infrastructure SKU using expressions like ternary operators. - -**Why this priority**: This bridges the application and infrastructure layers — application developers express intent through resource properties, and the recipe translates that intent into concrete module inputs. It builds on the direct module support from P1 stories and works identically for any Bicep or Terraform module. - -**Independent Test**: Link a recipe that uses `context.resource.properties.*` expressions in `recipeParameters`, deploy resources with different property values, and verify each deployment passes the correct resolved values to the module. - -**Acceptance Scenarios**: - -1. **Given** a recipe with `recipeParameters` containing `{{context.resource.properties.*}}` expressions, **When** a resource is deployed with specific property values set by the application developer, **Then** those property values are resolved and passed to the module as input parameters. -2. **Given** a recipe with `recipeParameters` containing a ternary expression (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`), **When** resources are deployed with different property values, **Then** each deployment passes the correct resolved value to the module. -3. **Given** a recipe with `recipeParameters` that combine context property expressions with literal text (e.g., `name: 'pg-{{context.resource.name}}'`), **When** a resource is deployed, **Then** the module receives the fully resolved parameter values. ---- - -### User Story 4 — Private Module Authentication (Priority: P2) - -As a platform engineer, I want to use modules hosted in private registries, private Git repositories, or private OCI registries as recipes, authenticating with credentials configured through the existing secret store. - -**Why this priority**: Enterprise teams host modules in private repositories. Without authentication support, direct module support is limited to public modules. - -**Independent Test**: Link a recipe pointing to a private Terraform registry module or Git repository, configure credentials via the existing secret mechanism, deploy, and verify the module is fetched successfully. - -**Acceptance Scenarios**: - -1. **Given** a recipe with `recipeLocation` pointing to a private Terraform registry module, **When** registry credentials are configured via the existing secret store mechanism, **Then** the system authenticates and fetches the module successfully. -2. **Given** a recipe with `recipeLocation` pointing to a private Git repository, **When** Git credentials are configured, **Then** the system clones and executes the module. - ---- - -### User Story 5 — Source Reachability Validation at Link Time (Priority: P2) - -As a platform engineer, I want the system to validate that a `recipeLocation` pointing to a direct module source is reachable when I link the recipe to an environment, so I catch typos and inaccessible sources early rather than at deploy time. - -**Why this priority**: Early validation prevents wasted time debugging deploy failures caused by simple typos or unreachable sources. - -**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module source and verify a validation error is returned. - -**Acceptance Scenarios**: - -1. **Given** a recipe with `recipeLocation` pointing to a non-existent registry module, **When** the recipe is linked to an environment, **Then** the system returns a validation error (definitive failures like 404 or authentication denied reject the operation). -2. **Given** a recipe with a valid `recipeLocation` that experiences a transient network failure during validation, **When** the recipe is linked, **Then** the system logs a warning but allows the operation to proceed. - ---- - -### Edge Cases - -- What happens when a module has no input variables? The recipe deploys successfully with no parameters required. -- What happens when a module has no outputs? The deployment succeeds with an empty output set. -- What happens when the module source becomes unavailable after initial deployments? Existing resources are unaffected; new deployments fail with a clear error. -- How does the system handle modules that expect specific provider configurations? The existing provider configuration mechanism applies — providers are configured through the environment's recipe configuration. -- What happens when a parameter name does not match any module input variable? The underlying IaC engine surfaces a clear error. -- How does the system handle output resolution? The `outputs` mapping on the RecipePack is the preferred path for all recipes (direct and wrapped). If a module also produces a structured `result` output, it is honored for backward compatibility, but the `outputs` mapping takes precedence when both are present. -- What happens when a `{{context.*}}` expression contains a typo? The unrecognized expression is left as a literal string — deliberate design to avoid masking errors. -- What happens when a `{{context.*}}` expression is malformed (e.g., unclosed `{{`, trailing dot)? Malformed expressions are left as literal strings — the IaC engine will surface errors if the literal doesn't match expected input types. -- What happens when a parameter contains multiple `{{context.*}}` expressions? All expressions are independently resolved. -- What happens when a resource deployed via a direct module recipe is updated (e.g., `recipeParameters` change)? The module is re-executed with the new parameters — ARM redeployment for Bicep, `terraform apply` with updated variables for Terraform. This is idempotent by design and consistent with existing recipe behavior. -- What happens when a module has breaking changes between versions? The platform engineer must update the version in `recipeLocation` deliberately — no automatic version bumping occurs. - -## Requirements *(mandatory)* - -### Functional Requirements - -**Input Resolution (Both Bicep and Terraform)**: - -- **FR-001**: System MUST pass `recipeParameters` through to a direct module's native inputs — ARM deployment parameters for Bicep, Terraform input variables for Terraform — without requiring the module to declare any Radius-specific input variables (e.g., no `context` variable needed). -- **FR-002**: System MUST support `{{context.*}}` template expressions in `recipeParameters` values that resolve to Radius runtime context at deployment time. Supported paths include resource metadata (name, id, type, properties), application metadata (name, id), environment metadata (name, id), Kubernetes runtime info (namespace, environmentNamespace), Azure metadata (resource group, subscription), and AWS metadata (region, account). Mixed content (e.g., `prefix-{{context.resource.name}}-suffix`) MUST be resolved while preserving surrounding literal text. Unrecognized paths MUST be left as-is. -- **FR-003**: System MUST support single-level conditional ternary expressions inside `{{...}}` that map context values to concrete configuration values (e.g., `{{context.resource.properties.size == "s" ? "B_Standard_B1ms" : "GP_Standard_D2s_v3"}}`). Nested ternaries are out of scope for V1. -- **FR-004**: System MUST merge `recipeParameters` from the RecipePack and environment-level overrides using shallow merge (top-level keys only). Environment-level parameters take precedence for overlapping keys, replacing the entire value including nested objects. - -**Output Resolution (Both Bicep and Terraform)**: - -- **FR-005**: System MUST surface module output values as resource properties after successful deployment, without requiring the module to produce a Radius-specific structured `result` output. -- **FR-006**: System MUST support an `outputs` field on the RecipePack that maps module output names to the resource type's read-only properties (e.g., `outputs: { host: 'fqdn', port: 'listen_port' }` where keys are resource property names and values are module output names). -- **FR-007**: For Terraform modules, outputs marked `sensitive = true` MUST be routed to the `Secrets` map instead of the `Values` map, without requiring any module modifications. - -**Bicep Module Support**: - -- **FR-008**: System MUST accept a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) as the `recipeLocation` value in a RecipePack with `recipeKind: 'bicep'`, without requiring the module to conform to Radius recipe wrapper conventions. -- **FR-009**: System MUST deploy a direct Bicep module through the existing ARM deployment mechanism, passing resolved `recipeParameters` as ARM deployment parameters. -- **FR-010**: System MUST clean up infrastructure provisioned by a direct Bicep module recipe when the deployed resource is deleted. - -**Terraform Module Support**: - -- **FR-011**: System MUST accept a standard Terraform module source as the `recipeLocation` value in a RecipePack with `recipeKind: 'terraform'`. Supported source formats include: Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...` with optional `?ref=` and `//subdir`), HTTP archive URLs, S3 URLs (`s3::...`), and GCS URLs (`gcs::...`). The module MUST NOT be required to include any Radius-specific conventions. -- **FR-012**: System MUST resolve and download the Terraform module at deployment time using standard Terraform module retrieval mechanisms. -- **FR-013**: System MUST execute `terraform destroy` when a resource deployed via a direct Terraform module recipe is deleted. - -**Backward Compatibility**: - -- **FR-014**: System MUST ensure existing recipe workflows (wrapped recipes with `context` variable and `result` output) continue to function identically — zero behavioral changes to existing deployments. -- **FR-015**: System MUST support the `outputs` mapping for both direct modules and existing wrapped recipes. If a module produces a structured `result` output (with `values`, `secrets`, `resources`), it is still honored for backward compatibility. However, the `outputs` mapping on the RecipePack is the preferred path for output resolution and works uniformly across all recipe types. When both `result` and `outputs` are present, the `outputs` mapping takes precedence. - -**Error Handling**: - -- **FR-016**: System MUST surface module execution errors (missing variables, provider failures, permission errors) as recipe deployment failures with actionable error messages including the relevant IaC engine error details. -- **FR-017**: System MUST handle modules with no input variables (deploy with no parameters) and modules with no outputs (succeed with empty output set) without errors. - -**Authentication**: - -- **FR-018**: System MUST support authentication for private module sources (private registries, private Git repositories, private OCI registries) using the existing secret store and credential configuration mechanisms. - -**Validation (Best-Effort)**: - -- **FR-019**: System SHOULD perform best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time, using lightweight probes with a reasonable timeout. Definitive failures (404, authentication denied) SHOULD reject the operation. Transient failures SHOULD be logged as warnings but SHOULD NOT block linking. - -### Key Entities - -- **RecipePack (`Radius.Core/recipePacks`)**: The primary API resource for this feature. A RecipePack is a collection of recipe configurations keyed by resource type. Each recipe entry has `recipeLocation` that accepts direct module references — Bicep OCI references (`br:...`), Terraform registry paths (`namespace/name/provider`), Git URLs (`git::https://...`), and other Terraform source formats — alongside existing wrapped recipe references. Key fields per recipe entry: `recipeKind` (terraform or bicep), `recipeLocation` (module source, version included in the reference), `recipeParameters` (input values with `{{context.*}}` expression support), and `outputs` (maps module output names to the resource type's read-only properties). -- **Environment**: Configures the deployment context. Recipes are linked to environments (this already works today). Environments can provide environment-level `recipeParameters` that merge with (and override) recipe-level parameters. -- **Recipe Output (from module outputs)**: For direct modules, module output values are mapped to the resource type's read-only properties via the `outputs` mapping. For Terraform, outputs marked `sensitive = true` are routed to the `Secrets` map. - -## Success Criteria *(mandatory)* - -### Measurable Outcomes - -- **SC-001**: A platform engineer can configure a recipe using an existing Bicep or Terraform module in under 1 minute by setting `recipeLocation` directly — zero wrapping, zero republishing. -- **SC-002**: End-to-end provisioning time for a direct module recipe is within 10% of an equivalent wrapped-recipe deployment — no significant overhead from the direct path. -- **SC-003**: Module output values are mapped to the resource type's read-only properties after deployment via the `outputs` mapping on the RecipePack. -- **SC-004**: Deleting a resource deployed via a direct module recipe fully destroys the underlying infrastructure with zero orphaned resources. -- **SC-005**: Deployment with an inaccessible `recipeLocation` fails within 60 seconds with a clear, actionable error message. -- **SC-006**: Existing wrapped-recipe workflows continue to function with zero behavioral changes — full backward compatibility. -- **SC-007**: Any standard Bicep module from an OCI registry or Terraform module from the public registry/Git (including AVM modules) that does not require Radius-specific conventions can be used directly as a recipe without modification. -- **SC-008**: Platform engineers can use `{{context.*}}` expressions to inject runtime context (resource name, namespace, environment, etc.) into any module's parameters without modifying the module. -- **SC-009**: Linking a recipe with a `recipeLocation` pointing to a non-existent or unreachable module source performs best-effort validation at link time. Definitive failures (404, authentication denied) return a validation error before deployment. Transient failures are logged as warnings but do not block linking. - -## Assumptions - -- **A-001**: The existing Bicep driver (ARM deployments) and Terraform driver (module execution) are extended to handle direct module references. No new driver or execution engine is introduced. -- **A-002**: Module input variable types are passed through without type transformation. Type checking is delegated to the underlying IaC engine (ARM for Bicep, Terraform CLI for Terraform), which produces clear errors for type mismatches. -- **A-003**: This feature operates alongside the existing recipe workflow. Wrapped recipes continue to work exactly as before. -- **A-004**: Infrastructure state management uses existing mechanisms — ARM deployment tracking for Bicep, Kubernetes secret-backed Terraform state for Terraform. -- **A-005**: Provider configuration uses existing mechanisms — Azure provider context for Bicep, `recipes.Configuration` for Terraform. -- **A-006**: Local filesystem paths as `recipeLocation` are out of scope. The initial scope covers OCI-hosted Bicep modules, Terraform registry modules, Git-hosted modules, and HTTP/S3/GCS archives. -- **A-007**: AVM modules are treated identically to any other direct module — no special handling. The "AVM" designation is purely organizational. -- **A-008**: Recipe linking to environments already works today and is not part of this feature. This feature is solely about what happens when `recipeLocation` points to a standard module instead of a wrapped one. -- **A-009**: Module version is specified as part of `recipeLocation` (e.g., OCI tag for Bicep, `?ref=` for Git, registry version syntax for Terraform). There is no separate `templateVersion` field. -- **A-010**: Direct module deployments use the same observability mechanisms (logging, tracing, metrics) as existing wrapped-recipe deployments. No new observability infrastructure is introduced. -- **A-011**: Sensitive input parameters (e.g., database passwords, API keys) are handled by the existing Radius secret store mechanism. No new secret handling is introduced for direct module support. -- **A-012**: Transient failure retry during module fetch at deploy time is delegated to the underlying IaC engine (Terraform and ARM both have built-in retry for transient failures). No custom Radius-level retry logic is introduced. - -## Future Steps - -The following are good-to-have capabilities that build on the core feature but are not required for initial delivery: - -- **Inspect Module Schema Before Deployment**: Retrieve a module's input variables and outputs before deploying, enabling discoverability and reducing trial-and-error. Engineers can consult module documentation as a workaround today. diff --git a/specs/001-direct-module-support/tasks.md b/specs/001-direct-module-support/tasks.md deleted file mode 100644 index 8590eb70e2b..00000000000 --- a/specs/001-direct-module-support/tasks.md +++ /dev/null @@ -1,275 +0,0 @@ - -# Tasks: Direct Module Support - -**Input**: Design documents from `specs/001-direct-module-support/` -**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ - -**Prototype Status**: Branch `001-direct-module-support` (3 commits ahead of main) already implements source classification (`pkg/recipes/source/`), TF direct output mapping, TF config generation (`AddAllOutputs`, `AddDirectModuleContext`), reachability validation, and RecipePack controller wiring. Tasks below cover **remaining work only**. - -## Format: `[ID] [P?] [Story] Description` - -- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) -- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) -- Include exact file paths in descriptions - ---- - -## Phase 1: Setup - -**Purpose**: Verify prototype state and prepare for remaining implementation - -- [ ] T001 Rebase branch `001-direct-module-support` onto latest main and verify `make build` succeeds -- [ ] T002 Run existing unit tests (`go test ./pkg/recipes/source/... ./pkg/recipes/driver/terraform/... ./pkg/recipes/terraform/...`) and confirm green baseline - ---- - -## Phase 2: Foundational (Blocking Prerequisites) - -**Purpose**: Core utilities shared by ALL P1 user stories (US1, US2, US3) — MUST complete before any story work begins. The expression resolver (including `context.resource.properties.*` paths and single-level ternary evaluation) is the foundational engine for all three P1 stories. - -**⚠️ CRITICAL**: No user story work can begin until this phase is complete - -- [ ] T003 Implement `ResolveParameterExpressions()` and `buildContextLookup()` in `pkg/recipes/paramresolver/resolver.go` — regex-based `{{context.*}}` replacement using flat context map built from `recipecontext.Context` struct; include `context.resource.properties.*` dynamic key enumeration so any user-defined property is accessible (US3 property resolution); unrecognized expressions left as-is per R-011 -- [ ] T004 Implement single-level ternary expression evaluation in `pkg/recipes/paramresolver/resolver.go` — parse `{{expr == "val" ? "trueResult" : "falseResult"}}` syntax; single-level only (nested/chained ternaries are out of scope for V1); string comparison is exact-match, case-sensitive; ternary with unresolvable condition path left as-is -- [ ] T005 Write table-driven unit tests for `ResolveParameterExpressions()` in `pkg/recipes/paramresolver/resolver_test.go` — cover: single expression, multiple expressions in one value, mixed literal+expression, unrecognized expression left as-is, empty map, nil values, nested map traversal, `context.resource.properties.*` resolution (existing property resolves, missing property left as-is, property with special characters, multiple property expressions in one string) -- [ ] T006 Write table-driven unit tests for ternary evaluation in `pkg/recipes/paramresolver/resolver_test.go` — cover: simple ternary true/false, context property lookup in condition, unresolvable condition path (left as-is), mixed ternary + literal text, nested/chained ternary explicitly out of scope (verify left as-is) -- [ ] T007 [P] Implement output mapping utility function `ApplyOutputsMapping(values map[string]any, secrets map[string]any, outputsMap map[string]string) (map[string]any, map[string]any)` in `pkg/recipes/util/outputs.go` — map module output names to the resource type's read-only property names per `outputs` map (keys = resource property names, values = module output names); when `outputs` is nil/empty, pass through all outputs unchanged -- [ ] T008 [P] Write table-driven unit tests for `ApplyOutputsMapping()` in `pkg/recipes/util/outputs_test.go` — cover: output-to-property mapping, pass-through when nil, missing output key in values (skip silently), sensitive output mapping, empty maps -- [ ] T009 [P] Implement shallow merge utility `ShallowMergeParameters(base map[string]any, override map[string]any) map[string]any` in `pkg/recipes/util/merge.go` — top-level key precedence from override per FR-004; nested objects replaced entirely (not deep-merged) -- [ ] T010 [P] Write table-driven unit tests for `ShallowMergeParameters()` in `pkg/recipes/util/merge_test.go` — cover: disjoint keys, overlapping keys (override wins), nested object replaced not merged, nil base, nil override, both nil -- [ ] T011 Add `Outputs map[string]string` field to `EnvironmentDefinition` in `pkg/recipes/types.go` and propagate from `RecipeDefinition.Outputs` through the config loader path - -**Checkpoint**: Foundation ready — expression resolver (with `context.resource.properties.*` and single-level ternary), output mapping, shallow merge, and EnvironmentDefinition.Outputs all available for driver integration. All three P1 stories (US1, US2, US3) can now proceed. - ---- - -## Phase 3: User Story 2 — Terraform Module Support (Priority: P1) 🎯 MVP - -**Goal**: Platform engineers set `recipeLocation` to a Terraform registry path, Git URL, or HTTP archive. The system downloads the module, resolves `{{context.*}}` expressions (including `context.resource.properties.*` and single-level ternary) in parameters, executes it, and maps outputs to the resource type's read-only properties. - -**Independent Test**: Link a recipe to a public Terraform registry module (e.g., `ballj/postgresql/kubernetes`), deploy a resource with `recipeParameters` including `{{context.resource.name}}`, verify module executes and outputs are accessible as resource properties. - -### Implementation for User Story 2 - -- [ ] T012 [US2] Replace `AddDirectModuleContext` auto-injection with `ResolveParameterExpressions()` call in `pkg/recipes/terraform/execute.go` — in the direct module code path, resolve expressions in `EnvironmentDefinition.Parameters` before writing them to the generated config; remove the old well-known variable name matching per R-011 -- [ ] T013 [US2] Wire `ShallowMergeParameters()` into the Terraform execute path in `pkg/recipes/terraform/execute.go` — merge RecipePack-level `recipeParameters` with environment-level parameters (environment wins) before expression resolution -- [ ] T014 [US2] Wire `ApplyOutputsMapping()` into `prepareRecipeResponse()` in `pkg/recipes/driver/terraform/terraform.go` — after flat output collection for direct modules, apply `EnvironmentDefinition.Outputs` to map module output names to the resource type's read-only properties in `RecipeOutput.Values` and `RecipeOutput.Secrets` -- [ ] T015 [US2] Implement `result` vs `outputs` precedence logic in `pkg/recipes/driver/terraform/terraform.go` — per FR-015: if module has a `result` output AND no `outputs` mapping is configured, treat as wrapped recipe; if `outputs` mapping exists, it takes precedence -- [ ] T016 [US2] Update unit tests in `pkg/recipes/driver/terraform/terraform_test.go` — add test cases for: direct module with outputs mapping to read-only properties, direct module without outputs mapping (pass-through), direct module with `result` output and no `outputs` mapping (wrapped behavior), direct module with both `result` and `outputs` (outputs wins) -- [ ] T017 [US2] Update unit tests in `pkg/recipes/terraform/execute_test.go` — add test cases for: expression resolution in direct module path (including `context.resource.properties.*` and ternary), shallow merge of parameters, context lookup populated from recipe context - -**Checkpoint**: Terraform direct module path fully wired — expression resolution (with property paths and ternary), parameter merge, output mapping to read-only properties, result/outputs precedence - ---- - -## Phase 4: User Story 1 — Bicep Module Support (Priority: P1) - -**Goal**: Platform engineers set `recipeLocation` to a Bicep module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`). The system deploys it via ARM, passes resolved parameters (including `context.resource.properties.*` and ternary expressions), and maps ARM outputs to the resource type's read-only properties. - -**Independent Test**: Link a recipe to a public AVM Bicep module from MCR, deploy a resource with `recipeParameters`, verify ARM deployment succeeds and module outputs are accessible as resource properties. - -### Implementation for User Story 1 - -- [ ] T018 [US1] Implement direct Bicep module detection in `pkg/recipes/driver/bicep/bicep.go` — detect when `recipeLocation` is a standard Bicep module (no Radius `result` output convention); use a flag or heuristic (e.g., absence of `result` in module outputs after ARM template inspection, or explicit `outputs` mapping presence) -- [ ] T019 [US1] Implement direct Bicep deployment path in `pkg/recipes/driver/bicep/bicep.go` — resolve `{{context.*}}` expressions in parameters via `ResolveParameterExpressions()`, pass resolved parameters as ARM deployment parameters, invoke ARM deployment -- [ ] T020 [US1] Wire `ShallowMergeParameters()` into the Bicep driver path in `pkg/recipes/driver/bicep/bicep.go` — merge RecipePack-level and environment-level parameters before expression resolution -- [ ] T021 [US1] Map ARM deployment outputs to `RecipeOutput` in `pkg/recipes/driver/bicep/bicep.go` — apply `ApplyOutputsMapping()` to map module output names to the resource type's read-only property names per `outputs` field -- [ ] T022 [US1] Ensure ARM deployment cleanup for direct Bicep modules in `pkg/recipes/driver/bicep/bicep.go` — verify ARM deployment deletion works for direct module deployments on resource delete -- [ ] T023 [US1] Write unit tests for Bicep direct module path in `pkg/recipes/driver/bicep/bicep_test.go` — cover: direct module detection, parameter resolution, output mapping to read-only properties, ARM deployment creation, ARM deployment deletion/cleanup - -**Checkpoint**: Bicep direct module path complete — all three P1 user stories (Terraform + Bicep + Property Resolution) are now functional - ---- - -## Phase 5: User Story 4 — Private Module Authentication (Priority: P2) - -**Goal**: Platform engineers use modules from private registries, Git repos, or OCI registries by configuring credentials through the existing secret store. - -**Independent Test**: Link a recipe to a private Terraform registry module, configure credentials via existing secret mechanism, deploy, and verify module is fetched successfully. - -### Implementation for User Story 4 - -- [ ] T024 [US4] Implement credential passthrough for private Terraform registry modules in `pkg/recipes/terraform/execute.go` — read credentials from the existing secret store and pass as Terraform CLI config (`.terraformrc` or `TF_TOKEN_*` environment variables) before `terraform init` -- [ ] T025 [US4] Implement credential passthrough for private Git repositories in `pkg/recipes/terraform/execute.go` — configure Git credentials (`GIT_ASKPASS` or credential helper) from secret store before module download -- [ ] T026 [US4] Write unit tests for private module credential injection in `pkg/recipes/terraform/execute_test.go` — cover: registry token injection, Git credential configuration, missing credentials (fall through gracefully), credential scoping (credentials only applied when source matches) - -**Checkpoint**: Private module authentication works for Terraform registry and Git sources - ---- - -## Phase 6: User Story 5 — Source Reachability Validation at Link Time (Priority: P2) - -**Goal**: The system performs best-effort validation that a `recipeLocation` pointing to a direct module source is reachable at recipe link time. Per SC-009: definitive failures (404, authentication denied) reject the operation; transient failures log warnings but allow linking. - -**Independent Test**: Link a recipe with a `recipeLocation` pointing to a non-existent module and verify a validation error is returned. Link with a transiently unreachable source and verify a warning is logged but the operation succeeds. - -### Implementation for User Story 5 - -- [ ] T027 [US5] Wire `ValidateReachability()` into `CreateOrUpdateRecipePack` controller for Bicep OCI references in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack.go` — extend existing Terraform reachability validation to also cover `br:` Bicep module references (HEAD request to OCI manifest); per SC-009: definitive failures (404, auth denied) reject, transient failures log warning but allow linking -- [ ] T028 [US5] Write unit tests for Bicep OCI reachability validation in `pkg/corerp/frontend/controller/recipepacks/createorupdaterecipepack_test.go` — cover: valid Bicep OCI reference passes, non-existent reference returns rejection error (definitive failure), transient network failure logs warning but allows creation, authentication denied returns rejection error - -**Checkpoint**: Reachability validation covers both Terraform and Bicep module sources with best-effort semantics (SC-009) - ---- - -## Phase 7: TypeSpec Model Alignment (Priority: P1) - -**Goal**: Align the TypeSpec API model with the implementation — add `outputs` and `recipeParameters` fields. A RecipePack is a collection of recipe configurations keyed by resource type. - -- [ ] T029 Add `outputs` field (type `Record`) and `recipeParameters` field (type `Record`) to the RecipePack recipe definition model in `typespec/Radius.Core/recipePacks.tsp` — RecipePack is a collection keyed by resource type; no `templateVersion` field (version is part of `recipeLocation` per A-009) -- [ ] T030 Regenerate API client code from updated TypeSpec model (`make generate`) and fix any compilation errors in generated code -- [ ] T031 Update `RecipeDefinition` struct in `pkg/corerp/datamodel/recipepack.go` to align JSON tags with generated API model — ensure `recipeParameters` and `outputs` fields serialize correctly for round-trip API calls - -**Checkpoint**: TypeSpec model, generated code, and internal data model are aligned - ---- - -## Phase 8: Functional Tests (Priority: P1) - -**Goal**: End-to-end validation with real modules — must cover `{{context.resource.properties.*}}` resolution and ternary expressions - -- [ ] T032 [P] Create functional test for Terraform registry module deployment in `test/functional-portable/recipes/` — use a lightweight public registry module (e.g., `ballj/postgresql/kubernetes`), configure `recipeParameters` with `{{context.*}}` expressions including `{{context.resource.properties.*}}` and ternary, verify deployment succeeds and outputs are mapped to resource read-only properties -- [ ] T033 [P] Create functional test for Git-hosted module deployment in `test/functional-portable/recipes/` — use `git::https://` source with `?ref=` version pin, verify module download, execution, and output mapping to read-only properties -- [ ] T034 [P] Create functional test for Bicep AVM module deployment in `test/functional-portable/recipes/` — use a public AVM Bicep module from MCR (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`), configure `recipeParameters`, verify ARM deployment succeeds and outputs are mapped to resource read-only properties -- [ ] T035 Create functional test for backward compatibility in `test/functional-portable/recipes/` — deploy a wrapped recipe (with `context` variable and `result` output) and verify zero behavioral changes - -**Checkpoint**: End-to-end tests validate the complete direct module flow for Terraform and Bicep, including property resolution and ternary expressions - ---- - -## Phase 9: Polish & Cross-Cutting Concerns - -**Purpose**: Improvements that affect multiple user stories - -- [ ] T036 [P] Run full linting pass (`make lint && make format-check`) and fix any issues across all modified packages -- [ ] T037 [P] Update inline code documentation (GoDoc comments) for all new exported functions and types in `pkg/recipes/paramresolver/`, `pkg/recipes/util/`, and modified driver files -- [ ] T038 Verify `make build` succeeds and run full unit test suite (`make test`) — confirm no regressions across the codebase -- [ ] T039 Run `specs/001-direct-module-support/quickstart.md` scenarios manually against a local Radius environment to validate end-to-end UX - ---- - -## Dependencies & Execution Order - -### Phase Dependencies - -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories -- **US2 Terraform (Phase 3)**: Depends on Foundational — first MVP increment -- **US1 Bicep (Phase 4)**: Depends on Foundational — can run in parallel with Phase 3 -- **US4 Private Auth (Phase 5)**: Depends on Foundational — can run in parallel with Phases 3–4 -- **US5 Reachability (Phase 6)**: Depends on Foundational — can run in parallel with Phases 3–5 -- **TypeSpec (Phase 7)**: No story dependency — can run in parallel with any phase after Setup -- **Functional Tests (Phase 8)**: Depends on Phases 3 + 4 (Terraform + Bicep paths wired with expression resolver) -- **Polish (Phase 9)**: Depends on all other phases - -### User Story Dependencies - -- **US1 (P1 Bicep)**: Can start after Foundational — no dependencies on other stories -- **US2 (P1 Terraform)**: Can start after Foundational — no dependencies on other stories -- **US3 (P1 Property Resolution)**: Implemented in Foundational phase — expression resolver includes `context.resource.properties.*` and ternary; validated through US1/US2 driver wiring and functional tests -- **US4 (P2 Private Auth)**: Independent of all other stories -- **US5 (P2 Reachability)**: Independent of all other stories (extends existing validation) - -### Within Each User Story - -- Core implementation before integration wiring -- Wiring before unit tests -- All tasks within a story are sequential unless marked [P] - -### Parallel Opportunities - -``` -After Foundational completes (which includes US3 expression engine), -all of these can run simultaneously: - - ┌─ Phase 3: US2 Terraform ─────────────┐ - ├─ Phase 4: US1 Bicep ─────────────────┤ - ├─ Phase 5: US4 Private Auth ──────────┤──▶ Phase 8: Functional Tests - ├─ Phase 6: US5 Reachability ──────────┤ - └─ Phase 7: TypeSpec ──────────────────┘ -``` - -Within Foundational: T007+T008, T009+T010 can all run in parallel with each other (and with the T003→T004→T005→T006 sequential chain). - ---- - -## Parallel Example: Foundational Phase - -```bash -# Sequential (has dependency — same file, expression resolver): -Task T003: "Implement ResolveParameterExpressions() with context.resource.properties.* in pkg/recipes/paramresolver/resolver.go" -Task T004: "Implement single-level ternary evaluation in pkg/recipes/paramresolver/resolver.go" -Task T005: "Write tests for expression resolver (basic + properties) in pkg/recipes/paramresolver/resolver_test.go" -Task T006: "Write tests for ternary evaluation in pkg/recipes/paramresolver/resolver_test.go" - -# Parallel with T003–T006 (different files, no dependencies): -Task T007: "Implement ApplyOutputsMapping() in pkg/recipes/util/outputs.go" -Task T008: "Write tests for ApplyOutputsMapping() in pkg/recipes/util/outputs_test.go" -Task T009: "Implement ShallowMergeParameters() in pkg/recipes/util/merge.go" -Task T010: "Write tests for ShallowMergeParameters() in pkg/recipes/util/merge_test.go" -``` - ---- - -## Implementation Strategy - -### MVP First (User Story 2 — Terraform Only) - -1. Complete Phase 1: Setup (verify prototype baseline) -2. Complete Phase 2: Foundational (expression resolver with properties + ternary, output mapping, shallow merge) -3. Complete Phase 3: User Story 2 (Terraform direct module path) -4. **STOP and VALIDATE**: Test with a real Terraform registry module end-to-end (including `context.resource.properties.*` and ternary) -5. Deploy/demo if ready — this alone unlocks the Terraform module ecosystem with property resolution - -### Incremental Delivery - -1. Setup + Foundational → Foundation ready (US3 expression engine included) -2. Add US2 (Terraform) → Test independently → Demo (**MVP!** — includes property resolution + ternary) -3. Add US1 (Bicep) → Test independently → Demo (full P1 delivery — all 3 P1 stories complete) -4. Add US4 + US5 → Test independently → Demo (enterprise features) -5. TypeSpec + Functional Tests + Polish → Release ready - -### Parallel Team Strategy - -With multiple developers after Foundational completes: - -- **Developer A**: US2 Terraform (Phase 3) then Functional Tests (Phase 8) -- **Developer B**: US1 Bicep (Phase 4) then US5 Reachability (Phase 6) -- **Developer C**: US4 Private Auth (Phase 5) then Polish (Phase 9) -- **Developer D**: TypeSpec (Phase 7) then Polish (Phase 9) - ---- - -## Summary - -| Metric | Value | -|--------|-------| -| **Total tasks** | 39 | -| **Setup** | 2 tasks (Phase 1) | -| **Foundational (incl. US3 engine)** | 9 tasks (Phase 2) | -| **US2 Terraform (P1)** | 6 tasks (Phase 3) | -| **US1 Bicep (P1)** | 6 tasks (Phase 4) | -| **US4 Private Auth (P2)** | 3 tasks (Phase 5) | -| **US5 Reachability (P2)** | 2 tasks (Phase 6) | -| **TypeSpec Alignment (P1)** | 3 tasks (Phase 7) | -| **Functional Tests (P1)** | 4 tasks (Phase 8) | -| **Polish** | 4 tasks (Phase 9) | -| **Parallel opportunities** | 5 phases can run simultaneously after Foundational | -| **Suggested MVP** | Phases 1–3 (US2 Terraform: 17 tasks, includes US3 property resolution) | -| **P1 stories** | US1, US2, US3 — all delivered by end of Phase 4 | - ---- - -## Notes - -- [P] tasks = different files, no dependencies on incomplete tasks -- [Story] label maps task to specific user story for traceability -- **US3 (Property Resolution) is P1** — its expression engine (ternary + `context.resource.properties.*`) is built in Foundational and ships with US1/US2 -- Prototype code (source classifier, TF output mapping, config generation) is already implemented — not re-tasked -- RecipePack is a collection of recipe configurations keyed by resource type -- Module version is part of `recipeLocation` (OCI tag, Git ref, registry syntax) — no separate `templateVersion` field (A-009) -- Output mapping maps module outputs to the resource type's read-only properties (not rename/filter) -- Reachability validation is best-effort: definitive failures reject, transient failures warn (SC-009) -- Each user story is independently completable and testable -- Commit after each task or logical group -- Stop at any checkpoint to validate story independently From eb004c71a6acd0b65eecf2b57e0deffca9dade02 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 9 Jun 2026 16:40:50 -0700 Subject: [PATCH 04/16] Address feedback Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index da4384014f0..e67e3f513c9 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -4,78 +4,77 @@ ## Topic Summary -Direct Recipe Module Support enables platform engineers to use any standard Bicep or Terraform module as a Radius Recipe without writing a Radius-specific wrapper. Today, using a module as a Radius Recipe requires a wrapper that conforms to Radius conventions (a `context` input variable, a structured `result` output). This feature eliminates the wrapper: point `location` directly at a standard module, and the system handles input resolution (injecting Radius context) and output resolution (mapping module outputs to resource properties) externally. +Radius Recipes enable platform engineers to define reusable infrastructure templates that application developers can consume without understanding the underlying cloud resources. Today, to use an existing community module e.g., an AWS RDS Terraform module from the Terraform Registry, or an Azure Verified Bicep Module as a Recipe, a platform engineer must write a thin recipe wrapper that adds Radius-specific conventions — namely a `context` input variable and a structured `result` output — publish it to a distribution source like a container registry or Git, and then it's ready for use. This extra layer adds maintenance burden, version drift risk, and a barrier to adoption. + +**Direct Recipe Module Support removes this requirement.** Platform engineers can now point a Recipe's `location` directly at any standard Bicep or Terraform module — no wrapper needed. Radius automatically resolves input values into the module's parameters at deploy time and maps the module's outputs back to the Resource Type's properties. The result is a simpler workflow: find a module, reference it, configure the parameter and output mappings, and deploy. ### Top level goals - Enable platform engineers to leverage community owned Terraform registry modules and Azure Verified Modules directly as Radius Recipes - Eliminate the need to write a Recipe wrapper, publish, and maintain these Recipes separately from the underlying module -- Eliminate the need for Radius to maintain a catalog of wrapped Recipes in the `resource-types-contrib` repository thus reducing maintenance overhead and surface area for supply chain attacks +- Scale the `resource-types-contrib` repository with tested Recipes by referencing community modules directly, eliminating wrapper maintenance overhead ### Non-goals (out of scope) -- Local filesystem paths as `location` sources -- Chained/nested ternary expressions (V1 supports single-level only) -- Automatic module version bumping -- Custom retry logic for module fetch failures (delegated to underlying IaC engine) -- New observability infrastructure (uses existing logging, tracing, metrics) +1. Local filesystem paths as `location` sources. Modules must be fetched from remote registries or Git; local paths are not supported. +2. Automatic module version bumping. Platform engineers explicitly pin module versions in their Recipe Packs. Auto-upgrading could introduce breaking changes silently. +3. Custom retry logic for module fetch failures. Fetch retries are delegated to the underlying IaC engine (Terraform CLI / Bicep). Radius does not add its own retry layer. +4. New observability infrastructure. This feature uses existing Radius logging, tracing, and metrics. No new telemetry systems are introduced. ## User profile and challenges ### User persona(s) -**Platform Engineer**: Responsible for defining infrastructure recipes that application developers consume. Manages recipe packs, environments, and provider configurations. Typically works across multiple teams and maintains a catalog of infrastructure patterns. +**Platform Engineer**: Responsible for defining infrastructure Recipes that application developers consume. Manages Recipe Packs, environments, and provider configurations. Typically works across multiple teams and maintains a catalog of infrastructure patterns. -**Application Developer**: Consumes recipes by declaring resources with properties (e.g., `database: 'mydb'`, `size: 's'`). Does not need to understand the underlying IaC module or infrastructure details. +**Application Developer**: Consumes Recipes by declaring resources with properties (e.g., `database: 'mydb'`, `size: 's'`). Does not need to understand the underlying IaC module or infrastructure details. ### Challenge(s) faced by the user -1. **Adoption friction** — Every module requires a custom recipe wrapper that must be published and versioned separately before it can be used. Using a community Terraform module requires writing a Recipe wrapper, publishing the wrapper to a distribution source and then adding it to Recipe packs for consumption. +1. **Adoption friction** — Every module requires a custom Recipe wrapper that must be published and versioned separately before it can be used. Using a community Terraform module requires writing a Recipe wrapper, publishing the wrapper to a distribution source and then adding it to Recipe Packs for consumption. 2. **Maintenance burden** — Upstream module updates require wrapper changes, validation, and republishing, creating version drift over time. Platform engineers must track upstream releases and update wrappers accordingly. -3. **Ecosystem lockout** — Thousands of production-ready community modules (Terraform registry, Azure Verified Modules) cannot be used directly, limiting the value proposition of Radius recipes. +3. **Ecosystem lockout** — Thousands of production-ready community modules (Terraform registry, Azure Verified Modules) cannot be used directly, limiting the value proposition of Radius Recipes. ### Positive user outcome -Platform engineers can easily configure a recipe using any existing module setting `location` directly — zero wrapping, zero republishing. The entire Terraform and Bicep module ecosystem becomes immediately usable as Radius recipes. +Platform engineers can easily configure a Recipe using any existing module setting `location` directly — zero wrapping, zero republishing. The entire Terraform and Bicep module ecosystem becomes immediately usable as Radius Recipes. ## Key scenarios ### Scenario 1: Terraform Registry Module -A platform engineer sets `location` to a Terraform registry path (e.g., `terraform-aws-modules/rds/aws`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to resource properties via the `outputs` field. +A platform engineer sets `location` to a Terraform registry URI (e.g., `registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to Resource Type properties via the `outputs` field. ### Scenario 2: Azure Verified Modules (OCI) -A platform engineer sets `location` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to resource properties via the `outputs` field. +A platform engineer sets `location` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to Resource Type properties via the `outputs` field. -### Scenario 3: Git-hosted Terraform Module +### Scenario 3: Private Git-hosted Terraform Module -A platform engineer references a private or public Git-hosted module (`git::https://github.com/org/module.git//subdir?ref=v2.0`) and the system clones and executes it. +A platform engineer references an internal module hosted in a private Git repository (e.g., `git::https://github.com/contoso/infra-modules.git//modules/vpc?ref=v2.0`). This supports organizations that maintain proprietary modules outside the public Terraform Registry. The system clones the module at the specified ref and executes it using the same parameter resolution and output mapping as registry modules. ## Key dependencies and risks -- **Community module coverage** — Not every Radius resource type will have a suitable community module available. Platform engineers may still need to author custom modules for resource types without community coverage or where community modules don't align with organizational requirements. +- **Community module coverage** — Not every Radius Resource Type will have a suitable community module available. Platform engineers may still need to author custom modules for Resource Types without community coverage or where community modules don't align with organizational requirements. - **Terraform version compatibility** — Direct modules may use features from newer Terraform versions. Mitigation: Terraform settings feature enables specifying the Terraform version. - **Module API stability** — Direct modules expose platform engineers to upstream breaking changes. Mitigation: version pinning via `?ref=` for Git or registry version constraints. - **Expression resolution correctness** — Unresolved `{{context.*}}` expressions are left as literal strings, which may cause cryptic downstream errors from the IaC engine. Mitigation: clear documentation of available expression paths. ## Current state -Radius currently supports recipes through wrapped modules that conform to specific conventions: -- **Terraform**: Module must declare a `context` input variable and produce a structured `result` output -- **Bicep**: Module must accept a `context` parameter and produce a `result` output +Today, Radius only supports Recipes that are purpose-built for Radius. Both Terraform and Bicep modules must include a `context` input variable (carrying Radius runtime metadata) and a structured `result` output (returning resource values back to Radius). Any community or third-party module that lacks these conventions cannot be used as a Recipe without first wrapping it. -Recipe Packs (`Radius.Core/recipePacks`) were introduced to group recipe definitions by resource type. This feature builds on Recipe Packs by extending `RecipeDefinition` with `outputs` mapping and broadening `location` to accept standard module sources. +Recipe Packs (`Radius.Core/recipePacks`) already enables platform engineers to group Recipe definitions by Resource Type. This direct module support extends that foundation by adding a `outputs` mapping to `RecipeDefinition` and broadening `location` to accept standard module sources that do not follow Radius wrapper conventions. ## Details of user problem -When I want to use a community Terraform module (like `terraform-aws-modules/rds/aws`) as a Radius recipe, I have to write a wrapper module that adds the `context` variable and `result` output, publish my wrapper to a Git repository or OCI registry based on the IaC engine, and then reference my wrapper in the recipe definition. If the upstream module releases a new version, I have to update my wrapper, test it, and republish. This creates a maintenance burden that scales with the number of modules in my catalog. +When I want to use a community Terraform module (like `terraform-aws-modules/rds/aws`) as a Radius Recipe, I have to write a wrapper module that adds the `context` variable and `result` output, publish my wrapper to a Git repository or OCI registry based on the IaC engine, and then reference my wrapper in the Recipe definition. If the upstream module releases a new version, I have to update my wrapper to pin the new version, validate that the wrapper still passes through the correct inputs/outputs, and republish it — even if the module's interface hasn't changed. This creates a maintenance burden that scales with the number of modules in my catalog. ## Desired user experience outcome -After this feature, I can set `location` directly to `terraform-aws-modules/rds/aws` on my recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my resource type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. +After this feature, I can set `location` directly to `registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0` on my Recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my Resource Type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. ### Detailed user experience @@ -88,7 +87,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'terraform-aws-modules/rds/aws' + location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' parameters: { identifier: '{{context.resource.name}}' db_name: '{{context.resource.properties.database}}' @@ -104,6 +103,17 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ } } } + + resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-dev' + properties: { + recipePacks: [recipepack.id] + recipeParameters: { + backup_retention_days: '2' + geo_redundant_backup_enabled: 'false' + } + } + } ``` 2. Application developer deploys a resource with properties: @@ -121,17 +131,17 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ 3. System resolves expressions (`{{context.resource.name}}` → `'orders-db'`, `{{context.resource.properties.database}}` → `'ordersdb'`), merges parameters, and executes Terraform against the upstream module 4. Module outputs are mapped to resource properties via the `outputs` definition -5. Application developer reads `resource.properties.host`, `resource.properties.port` etc. +5. Application reads `resource.properties.host`, `resource.properties.port` etc as the resolved values from the module outputs. ## Key investments ### Feature 1: Direct Module Execution -Use any standard Bicep or Terraform module directly as a recipe by pointing `location` at the module source. The system automatically detects that the module is not a Radius wrapper (no `context` variable), downloads it, and executes it through the existing driver — no wrapper needed. +Use any standard Bicep or Terraform module directly as a Recipe by pointing `location` at the module source. The system automatically detects that the module is not a Radius wrapper (no `context` variable), downloads it, and executes it through the existing driver — no wrapper needed. ### Feature 2: Template Expression Resolution -A `{{context.*}}` expression system that resolves Radius application runtime context values into recipe parameters at deploy time. Supports resource metadata, application/environment info, Kubernetes runtime, Azure, and AWS provider context. Includes single-level ternary expressions for conditional value mapping. +A `{{context.*}}` expression system that resolves Radius application runtime context values into Recipe parameters at deploy time. Supports resource metadata, application/environment info, Kubernetes runtime, Azure, and AWS provider context. Includes single-level ternary expressions for conditional value mapping. ### Feature 3: Output Mapping @@ -221,4 +231,4 @@ resource devenv 'Radius.Core/environments@2025-08-01-preview' = { } ``` -Environment parameters merge with recipe-level parameters. Environment takes precedence for overlapping keys. +Environment parameters merge with Recipe-level parameters. Environment takes precedence for overlapping keys. From fb0e5c3723be62df4fa2f9bd2edb2dbbda670108 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 9 Jun 2026 16:57:05 -0700 Subject: [PATCH 05/16] Address feedback 2 Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index e67e3f513c9..b73a98f1efe 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -88,12 +88,14 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr 'Radius.Data/mySqlDatabases': { kind: 'terraform' location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' + // Specify the module's input parameters parameters: { identifier: '{{context.resource.name}}' db_name: '{{context.resource.properties.database}}' engine: 'mysql' instance_class: 'db.t3.micro' } + // Map the module's outputs to the resource's properties outputs: { host: 'db_instance_address' port: 'db_instance_port' @@ -147,8 +149,6 @@ A `{{context.*}}` expression system that resolves Radius application runtime con An `outputs` field on `RecipeDefinition` that maps module output names to resource property names. Provides a stable property interface for resource consumers regardless of the underlying module's output naming. Sensitive outputs are automatically routed to the Secrets map. ---- - ## Usage Examples ### Terraform Registry Module (AWS RDS) @@ -232,3 +232,37 @@ resource devenv 'Radius.Core/environments@2025-08-01-preview' = { ``` Environment parameters merge with Recipe-level parameters. Environment takes precedence for overlapping keys. + +### Secrets Output Mapping + +```bicep +resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { + name: 'aws-data-pack' + properties: { + recipes: { + 'Radius.Data/mySqlDatabases': { + kind: 'terraform' + location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' + parameters: { + identifier: '{{context.resource.name}}' + db_name: '{{context.resource.properties.database}}' + engine: 'mysql' + instance_class: 'db.t3.micro' + manage_master_user_password: true + } + outputs: { + values: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + } + secrets: { + secret: 'db_master_user_secret_arn' + connectionString: 'db_instance_endpoint' + } + } + } + } + } +} +``` From d51a324233f1943f8994d365a313854841458f42 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 9 Jun 2026 17:09:26 -0700 Subject: [PATCH 06/16] Add secrets o/p Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index b73a98f1efe..9628aa35ddf 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -235,6 +235,8 @@ Environment parameters merge with Recipe-level parameters. Environment takes pre ### Secrets Output Mapping +Properties marked as `x-radius-sensitive` in the Resource Type definition are automatically handled by Radius — their values are encrypted at rest and not exposed in plain text through the API. When a module output is mapped to a property that has `x-radius-sensitive: true`, Radius encrypts the value before storing it: + ```bicep resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { name: 'aws-data-pack' @@ -251,18 +253,15 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { manage_master_user_password: true } outputs: { - values: { - host: 'db_instance_address' - port: 'db_instance_port' - database: 'db_instance_name' - } - secrets: { - secret: 'db_master_user_secret_arn' - connectionString: 'db_instance_endpoint' - } + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + password: 'db_master_user_secret_arn' // mapped to x-radius-sensitive property → encrypted by Radius } } } } } ``` + +The platform engineer does not need to separate values and secrets in the `outputs` mapping. Radius determines sensitivity from the Resource Type schema — any output mapped to a property with `x-radius-sensitive: true` is automatically encrypted. From 245abb5e9587b4cc17aafc269acd7c9bea3c1952 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:17:15 -0700 Subject: [PATCH 07/16] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index 9628aa35ddf..e97a210dc30 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -66,7 +66,7 @@ A platform engineer references an internal module hosted in a private Git reposi Today, Radius only supports Recipes that are purpose-built for Radius. Both Terraform and Bicep modules must include a `context` input variable (carrying Radius runtime metadata) and a structured `result` output (returning resource values back to Radius). Any community or third-party module that lacks these conventions cannot be used as a Recipe without first wrapping it. -Recipe Packs (`Radius.Core/recipePacks`) already enables platform engineers to group Recipe definitions by Resource Type. This direct module support extends that foundation by adding a `outputs` mapping to `RecipeDefinition` and broadening `location` to accept standard module sources that do not follow Radius wrapper conventions. +Recipe Packs (`Radius.Core/recipePacks`) already enable platform engineers to group Recipe definitions by Resource Type. This direct module support extends that foundation by adding an `outputs` mapping to `RecipeDefinition` and broadening `location` to accept standard module sources that do not follow Radius wrapper conventions. ## Details of user problem @@ -96,11 +96,12 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr instance_class: 'db.t3.micro' } // Map the module's outputs to the resource's properties - outputs: { - host: 'db_instance_address' - port: 'db_instance_port' - database: 'db_instance_name' - } + outputs: { + host: 'db_instance_address' + port: 'db_instance_port' + database: 'db_instance_name' + secretName: 'db_instance_master_user_secret_arn' + } } } } @@ -111,8 +112,10 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr properties: { recipePacks: [recipepack.id] recipeParameters: { - backup_retention_days: '2' - geo_redundant_backup_enabled: 'false' + 'Radius.Data/mySqlDatabases': { + backup_retention_days: 2 + geo_redundant_backup_enabled: false + } } } } @@ -224,8 +227,10 @@ resource devenv 'Radius.Core/environments@2025-08-01-preview' = { properties: { recipePacks: [recipepack.id] recipeParameters: { - backup_retention_days: '2' - geo_redundant_backup_enabled: 'false' + 'Radius.Data/mySqlDatabases': { + backup_retention_days: 2 + geo_redundant_backup_enabled: false + } } } } From fdfa1745ffbd87cf3369b302d5ed1fb17e32e0c9 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 9 Jun 2026 17:17:57 -0700 Subject: [PATCH 08/16] Addres copilot feedback Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index e97a210dc30..a31a86771c4 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -150,7 +150,7 @@ A `{{context.*}}` expression system that resolves Radius application runtime con ### Feature 3: Output Mapping -An `outputs` field on `RecipeDefinition` that maps module output names to resource property names. Provides a stable property interface for resource consumers regardless of the underlying module's output naming. Sensitive outputs are automatically routed to the Secrets map. +An `outputs` field on `RecipeDefinition` that maps module output names to resource property names. Provides a stable property interface for resource consumers regardless of the underlying module's output naming. ## Usage Examples @@ -163,7 +163,7 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'terraform-aws-modules/rds/aws' + location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' parameters: { identifier: '{{context.resource.name}}' db_name: '{{context.resource.properties.database}}' @@ -196,7 +196,7 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/postgreSqlDatabases': { kind: 'terraform' - location: 'Azure/avm-res-dbforpostgresql-flexibleserver/azurerm' + location: 'br:mcr.microsoft.com/bicep/avm/res/db-for-postgre-sql/flexible-server:0.4.0' parameters: { name: 'pg-{{context.resource.name}}' location: 'eastus2' From ca3cc5ca1e16fdc9cb1a511684cdd0fbf16bef17 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Tue, 9 Jun 2026 17:29:53 -0700 Subject: [PATCH 09/16] Addres copilot feedback Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index a31a86771c4..b1ecf870e7e 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -269,4 +269,4 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { } ``` -The platform engineer does not need to separate values and secrets in the `outputs` mapping. Radius determines sensitivity from the Resource Type schema — any output mapped to a property with `x-radius-sensitive: true` is automatically encrypted. +Radius determines sensitivity from the Resource Type schema — any output mapped to a property with `x-radius-sensitive: true` is automatically encrypted. From ec2d4e2695998888948c1ae5683b6f2316c29d4c Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Wed, 10 Jun 2026 09:24:15 -0700 Subject: [PATCH 10/16] Refinements Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index b1ecf870e7e..2071c420d06 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -4,13 +4,15 @@ ## Topic Summary -Radius Recipes enable platform engineers to define reusable infrastructure templates that application developers can consume without understanding the underlying cloud resources. Today, to use an existing community module e.g., an AWS RDS Terraform module from the Terraform Registry, or an Azure Verified Bicep Module as a Recipe, a platform engineer must write a thin recipe wrapper that adds Radius-specific conventions — namely a `context` input variable and a structured `result` output — publish it to a distribution source like a container registry or Git, and then it's ready for use. This extra layer adds maintenance burden, version drift risk, and a barrier to adoption. +Radius Recipes enable platform engineers to define reusable infrastructure templates that application developers can consume without understanding the underlying cloud resources. Today, to use an existing community module — e.g., an [AWS RDS Terraform module](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) from the Terraform Registry, or an [Azure Verified Bicep Module](https://azure.github.io/Azure-Verified-Modules/) from the Microsoft Container Registry — as a Recipe, a platform engineer must write a thin recipe wrapper that adds Radius-specific conventions — namely a `context` input variable and a structured `result` output, publish it to a distribution source like a container registry or Git, and then it's ready for use. This extra layer adds maintenance burden, version drift risk, and a barrier to adoption. **Direct Recipe Module Support removes this requirement.** Platform engineers can now point a Recipe's `location` directly at any standard Bicep or Terraform module — no wrapper needed. Radius automatically resolves input values into the module's parameters at deploy time and maps the module's outputs back to the Resource Type's properties. The result is a simpler workflow: find a module, reference it, configure the parameter and output mappings, and deploy. +This also enables us to scale the `resource-types-contrib` repository by leveraging existing, well-maintained community modules without having to write everything from scratch. Instead of authoring and maintaining custom wrapper Recipes, we maintain Recipe Packs with tested modules per platform validated against Radius Resource Types. + ### Top level goals -- Enable platform engineers to leverage community owned Terraform registry modules and Azure Verified Modules directly as Radius Recipes +- Enable platform engineers to leverage community maintained Terraform registry modules and Azure Verified Modules directly as Radius Recipes - Eliminate the need to write a Recipe wrapper, publish, and maintain these Recipes separately from the underlying module - Scale the `resource-types-contrib` repository with tested Recipes by referencing community modules directly, eliminating wrapper maintenance overhead @@ -51,13 +53,13 @@ A platform engineer sets `location` to a Terraform registry URI (e.g., `registry A platform engineer sets `location` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to Resource Type properties via the `outputs` field. -### Scenario 3: Private Git-hosted Terraform Module +### Scenario 3: Private hosted Module -A platform engineer references an internal module hosted in a private Git repository (e.g., `git::https://github.com/contoso/infra-modules.git//modules/vpc?ref=v2.0`). This supports organizations that maintain proprietary modules outside the public Terraform Registry. The system clones the module at the specified ref and executes it using the same parameter resolution and output mapping as registry modules. +A platform engineer references an internal module hosted in a private OCI registry (e.g., `oci://my-registry.com/my-namespace/my-module:1.0.0`). This supports organizations that maintain proprietary modules outside the public Terraform Registry. The system clones the module at the specified ref and executes it using the same parameter resolution and output mapping as registry modules. ## Key dependencies and risks -- **Community module coverage** — Not every Radius Resource Type will have a suitable community module available. Platform engineers may still need to author custom modules for Resource Types without community coverage or where community modules don't align with organizational requirements. +- **Community module coverage** — Not every Radius Resource Type has a suitable community module. Where gaps exist, platform engineers will still author custom modules. For the `resource-types-contrib` repository, we will continue to write and maintain Recipes for Resource Types that lack community module coverage. - **Terraform version compatibility** — Direct modules may use features from newer Terraform versions. Mitigation: Terraform settings feature enables specifying the Terraform version. - **Module API stability** — Direct modules expose platform engineers to upstream breaking changes. Mitigation: version pinning via `?ref=` for Git or registry version constraints. - **Expression resolution correctness** — Unresolved `{{context.*}}` expressions are left as literal strings, which may cause cryptic downstream errors from the IaC engine. Mitigation: clear documentation of available expression paths. @@ -78,7 +80,10 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr ### Detailed user experience -1. Platform engineer creates a RecipePack pointing directly at an upstream module: +>**Note**: This user experience incorporates the [Rename recipeKind and recipeLocation to kind and location in Recipe Packs](https://github.com/radius-project/radius/issues/11879) changes. + + +1. Platform engineer creates a Recipe Pack pointing directly at an upstream module: ```bicep resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { @@ -270,3 +275,12 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { ``` Radius determines sensitivity from the Resource Type schema — any output mapped to a property with `x-radius-sensitive: true` is automatically encrypted. + +## Edge cases + +1. **Module with no outputs** — A module that produces no outputs results in an empty Values map on the resource. The resource is still created successfully but has no populated properties from the Recipe. Radius logs a warning indicating that the module produced no outputs to help platform engineers catch misconfiguration early. +2. **Output name mismatch** — If the `outputs` mapping references a module output that does not exist, the mapped property is left empty (nil) on the resource. Radius logs a warning indicating the unresolved output name, so platform engineers can identify and fix the mapping without blocking deployment. +3. **Required parameters not supplied** — If the module requires an input variable that is not provided through `parameters` or environment-level `recipeParameters`, Radius surfaces the error from IaC engine indicating which variable is missing. +4. **Invalid `{{context.*}}` expression path** — If a template expression references a context path that does not exist (e.g., `{{context.resource.properties.nonexistent}}`), the expression resolves to an empty string. The IaC engine may then fail if the parameter requires a non-empty value. +5. **Large module with many unused variables** — Some community modules expose dozens of input variables with defaults. Only variables explicitly mapped in `parameters` are passed; all others use the module's defaults. This is expected behavior. +6. **Module version yanked or deleted** — If the pinned module version is removed from the registry, the Recipe deployment fails at the module download step with a fetch error. Mitigation: platform engineers should monitor upstream module availability. From 8bb70750199c892e83fcda763c5b6e04315fa583 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Wed, 10 Jun 2026 16:16:50 -0700 Subject: [PATCH 11/16] Address feedback comments Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index 2071c420d06..e279a5b3d3a 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -47,7 +47,7 @@ Platform engineers can easily configure a Recipe using any existing module setti ### Scenario 1: Terraform Registry Module -A platform engineer sets `location` to a Terraform registry URI (e.g., `registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to Resource Type properties via the `outputs` field. +A platform engineer sets `location` to a Terraform registry module source (e.g., `terraform-aws-modules/rds/aws`) with a `version` field (e.g., `5.9.0`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to Resource Type properties via the `outputs` field. ### Scenario 2: Azure Verified Modules (OCI) @@ -76,7 +76,7 @@ When I want to use a community Terraform module (like `terraform-aws-modules/rds ## Desired user experience outcome -After this feature, I can set `location` directly to `registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0` on my Recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my Resource Type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. +After this feature, I can set `location` directly to `terraform-aws-modules/rds/aws` and `version` to `5.9.0` on my Recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my Resource Type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. ### Detailed user experience @@ -92,7 +92,8 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' + location: 'terraform-aws-modules/rds/aws' + version: '5.9.0' // Specify the module's input parameters parameters: { identifier: '{{context.resource.name}}' @@ -143,6 +144,78 @@ After this feature, I can set `location` directly to `registry.terraform.io/terr 4. Module outputs are mapped to resource properties via the `outputs` definition 5. Application reads `resource.properties.host`, `resource.properties.port` etc as the resolved values from the module outputs. +6. (Optional) For private modules, the platform engineer configures registry or Git authentication using the settings resources (`Radius.Core/terraformConfigs` or `Radius.Core/bicepConfigs`) referenced from the environment. The Radius driver resolves credentials by parsing the hostname from the module `location` and looking up the matching key in the credentials map. + + **Private Terraform registry (e.g., Terraform Cloud):** + + ```bicep + resource terraformConfig 'Radius.Core/terraformConfigs@2025-08-01-preview' = { + name: 'my-terraform-config' + properties: { + terraformrc: { + credentials: { + 'app.terraform.io': { + secret: tfcSecrets.id + } + } + } + } + } + + resource tfcSecrets 'Radius.Core/secrets@2025-08-01-preview' = { + name: 'tfc-creds' + properties: { + type: 'generic' + data: { + token: { value: tfcToken } + } + } + } + + resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-dev' + properties: { + recipePacks: [recipepack.id] + terraformConfig: terraformConfig.id + } + } + ``` + + **Private Bicep/OCI registry:** + + ```bicep + resource bicepConfig 'Radius.Core/bicepConfigs@2025-08-01-preview' = { + name: 'my-bicep-config' + properties: { + registryAuthentications: { + 'corp.acr.io': { + authenticationMethod: 'BasicAuth' + basicAuthSecretId: acrSecrets.id + } + } + } + } + + resource acrSecrets 'Radius.Core/secrets@2025-08-01-preview' = { + name: 'acr-creds' + properties: { + type: 'generic' + data: { + username: { value: acrUsername } + password: { value: acrPassword } + } + } + } + + resource env 'Radius.Core/environments@2025-08-01-preview' = { + name: 'aws-dev' + properties: { + recipePacks: [recipepack.id] + bicepConfig: bicepConfig.id + } + } + ``` + ## Key investments ### Feature 1: Direct Module Execution @@ -168,7 +241,8 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' + location: 'terraform-aws-modules/rds/aws' + version: '5.9.0' parameters: { identifier: '{{context.resource.name}}' db_name: '{{context.resource.properties.database}}' @@ -254,7 +328,8 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'registry.terraform.io/terraform-aws-modules/rds/aws:5.9.0' + location: 'terraform-aws-modules/rds/aws' + version: '5.9.0' parameters: { identifier: '{{context.resource.name}}' db_name: '{{context.resource.properties.database}}' @@ -284,3 +359,9 @@ Radius determines sensitivity from the Resource Type schema — any output mappe 4. **Invalid `{{context.*}}` expression path** — If a template expression references a context path that does not exist (e.g., `{{context.resource.properties.nonexistent}}`), the expression resolves to an empty string. The IaC engine may then fail if the parameter requires a non-empty value. 5. **Large module with many unused variables** — Some community modules expose dozens of input variables with defaults. Only variables explicitly mapped in `parameters` are passed; all others use the module's defaults. This is expected behavior. 6. **Module version yanked or deleted** — If the pinned module version is removed from the registry, the Recipe deployment fails at the module download step with a fetch error. Mitigation: platform engineers should monitor upstream module availability. + +## Open Questions/ Designs (Reviewed on 6/10) + +1. Follow up with a separate design/gist to determine the approach for handling secrets in the context of Recipe modules. +2. Describe the user experience for platform engineers using pre-canned recipe packs from resource type contrib in the feature spec +3. Depending on external modules carries a supply-chain security risk even with version pinning, since bad actors could potentially overwrite pinned versions. SHA/digest-based pinning is the more secure approach. Investigate which source types support this (Git via `?ref=`, OCI via `@sha256:...`) and whether Radius should recommend or enforce it. From b68165bec2c37c5579e9ee032ad9331a6c599a26 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Wed, 10 Jun 2026 16:40:27 -0700 Subject: [PATCH 12/16] Add private registry validation feature Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index e279a5b3d3a..127e19ee7f8 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -230,6 +230,10 @@ A `{{context.*}}` expression system that resolves Radius application runtime con An `outputs` field on `RecipeDefinition` that maps module output names to resource property names. Provides a stable property interface for resource consumers regardless of the underlying module's output naming. +### Feature 4: Private Registry/Repository Support + +Validate that direct recipe modules work with private registries and repositories using the existing `Radius.Core/terraformConfigs` and `Radius.Core/bicepConfigs` authentication mechanisms. Since direct modules use the same `location` field and driver download path as wrapper-based recipes, this is expected to be a no-op, the existing credential resolution logic should work. The investment here is primarily testing and documentation. + ## Usage Examples ### Terraform Registry Module (AWS RDS) From b008839dbd08b0cdc94502aa190c3009b87eedeb Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Wed, 10 Jun 2026 17:09:54 -0700 Subject: [PATCH 13/16] Spell check Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index 127e19ee7f8..ce0dbca602a 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -163,7 +163,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ } resource tfcSecrets 'Radius.Core/secrets@2025-08-01-preview' = { - name: 'tfc-creds' + name: 'tfc-credentials' properties: { type: 'generic' data: { @@ -197,7 +197,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ } resource acrSecrets 'Radius.Core/secrets@2025-08-01-preview' = { - name: 'acr-creds' + name: 'acr-credentials' properties: { type: 'generic' data: { From d2d6a6de725b75db3cd382ed5b9a9acc2e752f9b Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:11:14 -0700 Subject: [PATCH 14/16] Apply suggestion from @Reshrahim Signed-off-by: Reshma Abdul Rahim <61033581+Reshrahim@users.noreply.github.com> Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index ce0dbca602a..d04bb5c75e5 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -220,7 +220,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ ### Feature 1: Direct Module Execution -Use any standard Bicep or Terraform module directly as a Recipe by pointing `location` at the module source. The system automatically detects that the module is not a Radius wrapper (no `context` variable), downloads it, and executes it through the existing driver — no wrapper needed. +Use any standard Bicep or Terraform module directly as a Recipe by pointing `location` at the module source. The system downloads the module, and executes it through the existing driver. ### Feature 2: Template Expression Resolution From 08d87d07af7e009689a6c77b8abfc715baa27d0e Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Thu, 11 Jun 2026 09:56:10 -0700 Subject: [PATCH 15/16] Update to Signed-off-by: Reshma Abdul Rahim --- .../recipes/2026-05-direct-recipe-modules.md | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index d04bb5c75e5..2b404002a33 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -6,7 +6,7 @@ Radius Recipes enable platform engineers to define reusable infrastructure templates that application developers can consume without understanding the underlying cloud resources. Today, to use an existing community module — e.g., an [AWS RDS Terraform module](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) from the Terraform Registry, or an [Azure Verified Bicep Module](https://azure.github.io/Azure-Verified-Modules/) from the Microsoft Container Registry — as a Recipe, a platform engineer must write a thin recipe wrapper that adds Radius-specific conventions — namely a `context` input variable and a structured `result` output, publish it to a distribution source like a container registry or Git, and then it's ready for use. This extra layer adds maintenance burden, version drift risk, and a barrier to adoption. -**Direct Recipe Module Support removes this requirement.** Platform engineers can now point a Recipe's `location` directly at any standard Bicep or Terraform module — no wrapper needed. Radius automatically resolves input values into the module's parameters at deploy time and maps the module's outputs back to the Resource Type's properties. The result is a simpler workflow: find a module, reference it, configure the parameter and output mappings, and deploy. +**Direct Recipe Module Support removes this requirement.** Platform engineers can now point a Recipe's `source` directly at any standard Bicep or Terraform module — no wrapper needed. Radius automatically resolves input values into the module's parameters at deploy time and maps the module's outputs back to the Resource Type's properties. The result is a simpler workflow: find a module, reference it, configure the parameter and output mappings, and deploy. This also enables us to scale the `resource-types-contrib` repository by leveraging existing, well-maintained community modules without having to write everything from scratch. Instead of authoring and maintaining custom wrapper Recipes, we maintain Recipe Packs with tested modules per platform validated against Radius Resource Types. @@ -18,7 +18,7 @@ This also enables us to scale the `resource-types-contrib` repository by leverag ### Non-goals (out of scope) -1. Local filesystem paths as `location` sources. Modules must be fetched from remote registries or Git; local paths are not supported. +1. Local filesystem paths as `source` values. Modules must be fetched from remote registries or Git; local paths are not supported. 2. Automatic module version bumping. Platform engineers explicitly pin module versions in their Recipe Packs. Auto-upgrading could introduce breaking changes silently. 3. Custom retry logic for module fetch failures. Fetch retries are delegated to the underlying IaC engine (Terraform CLI / Bicep). Radius does not add its own retry layer. 4. New observability infrastructure. This feature uses existing Radius logging, tracing, and metrics. No new telemetry systems are introduced. @@ -41,17 +41,17 @@ This also enables us to scale the `resource-types-contrib` repository by leverag ### Positive user outcome -Platform engineers can easily configure a Recipe using any existing module setting `location` directly — zero wrapping, zero republishing. The entire Terraform and Bicep module ecosystem becomes immediately usable as Radius Recipes. +Platform engineers can easily configure a Recipe using any existing module setting `source` directly — zero wrapping, zero republishing. The entire Terraform and Bicep module ecosystem becomes immediately usable as Radius Recipes. ## Key scenarios ### Scenario 1: Terraform Registry Module -A platform engineer sets `location` to a Terraform registry module source (e.g., `terraform-aws-modules/rds/aws`) with a `version` field (e.g., `5.9.0`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to Resource Type properties via the `outputs` field. +A platform engineer sets `source` to a Terraform registry module source (e.g., `terraform-aws-modules/rds/aws`) with a `version` field (e.g., `5.9.0`) and the system deploys it by automatically resolving developer set properties via `context` as Terraform input variables, and mapping module outputs to Resource Type properties via the `outputs` field. ### Scenario 2: Azure Verified Modules (OCI) -A platform engineer sets `location` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to Resource Type properties via the `outputs` field. +A platform engineer sets `source` to an Azure Verified Module OCI reference (e.g., `br:mcr.microsoft.com/bicep/avm/res/storage/storage-account:0.14.3`) and the system deploys it via ARM, passing resolved parameters and mapping outputs to Resource Type properties via the `outputs` field. ### Scenario 3: Private hosted Module @@ -68,7 +68,7 @@ A platform engineer references an internal module hosted in a private OCI regist Today, Radius only supports Recipes that are purpose-built for Radius. Both Terraform and Bicep modules must include a `context` input variable (carrying Radius runtime metadata) and a structured `result` output (returning resource values back to Radius). Any community or third-party module that lacks these conventions cannot be used as a Recipe without first wrapping it. -Recipe Packs (`Radius.Core/recipePacks`) already enable platform engineers to group Recipe definitions by Resource Type. This direct module support extends that foundation by adding an `outputs` mapping to `RecipeDefinition` and broadening `location` to accept standard module sources that do not follow Radius wrapper conventions. +Recipe Packs (`Radius.Core/recipePacks`) already enable platform engineers to group Recipe definitions by Resource Type. This direct module support extends that foundation by adding an `outputs` mapping to `RecipeDefinition` and broadening `source` to accept standard module sources that do not follow Radius wrapper conventions. ## Details of user problem @@ -76,7 +76,7 @@ When I want to use a community Terraform module (like `terraform-aws-modules/rds ## Desired user experience outcome -After this feature, I can set `location` directly to `terraform-aws-modules/rds/aws` and `version` to `5.9.0` on my Recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my Resource Type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. +After this feature, I can set `source` directly to `terraform-aws-modules/rds/aws` and `version` to `5.9.0` on my Recipe definition, configure `parameters` with `{{context.*}}` expressions for dynamic values, and set `outputs` to map the module's outputs to my Resource Type's properties. The module doesn't need to know about Radius. When my application developer deploys a `Radius.Data/mySqlDatabases` resource, the system resolves expressions, passes parameters to the module, executes it, and maps outputs — all without any wrapper. ### Detailed user experience @@ -92,7 +92,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'terraform-aws-modules/rds/aws' + source: 'terraform-aws-modules/rds/aws' version: '5.9.0' // Specify the module's input parameters parameters: { @@ -144,7 +144,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ 4. Module outputs are mapped to resource properties via the `outputs` definition 5. Application reads `resource.properties.host`, `resource.properties.port` etc as the resolved values from the module outputs. -6. (Optional) For private modules, the platform engineer configures registry or Git authentication using the settings resources (`Radius.Core/terraformConfigs` or `Radius.Core/bicepConfigs`) referenced from the environment. The Radius driver resolves credentials by parsing the hostname from the module `location` and looking up the matching key in the credentials map. +6. (Optional) For private modules, the platform engineer configures registry or Git authentication using the settings resources (`Radius.Core/terraformConfigs` or `Radius.Core/bicepConfigs`) referenced from the environment. The Radius driver resolves credentials by parsing the hostname from the module `source` and looking up the matching key in the credentials map. **Private Terraform registry (e.g., Terraform Cloud):** @@ -220,7 +220,7 @@ After this feature, I can set `location` directly to `terraform-aws-modules/rds/ ### Feature 1: Direct Module Execution -Use any standard Bicep or Terraform module directly as a Recipe by pointing `location` at the module source. The system downloads the module, and executes it through the existing driver. +Use any standard Bicep or Terraform module directly as a Recipe by pointing `source` at the module source. The system downloads the module, and executes it through the existing driver. ### Feature 2: Template Expression Resolution @@ -232,7 +232,7 @@ An `outputs` field on `RecipeDefinition` that maps module output names to resour ### Feature 4: Private Registry/Repository Support -Validate that direct recipe modules work with private registries and repositories using the existing `Radius.Core/terraformConfigs` and `Radius.Core/bicepConfigs` authentication mechanisms. Since direct modules use the same `location` field and driver download path as wrapper-based recipes, this is expected to be a no-op, the existing credential resolution logic should work. The investment here is primarily testing and documentation. +Validate that direct recipe modules work with private registries and repositories using the existing `Radius.Core/terraformConfigs` and `Radius.Core/bicepConfigs` authentication mechanisms. Since direct modules use the same `source` field and driver download path as wrapper-based recipes, this is expected to be a no-op, the existing credential resolution logic should work. The investment here is primarily testing and documentation. ## Usage Examples @@ -245,7 +245,7 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'terraform-aws-modules/rds/aws' + source: 'terraform-aws-modules/rds/aws' version: '5.9.0' parameters: { identifier: '{{context.resource.name}}' @@ -279,7 +279,7 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/postgreSqlDatabases': { kind: 'terraform' - location: 'br:mcr.microsoft.com/bicep/avm/res/db-for-postgre-sql/flexible-server:0.4.0' + source: 'br:mcr.microsoft.com/bicep/avm/res/db-for-postgre-sql/flexible-server:0.4.0' parameters: { name: 'pg-{{context.resource.name}}' location: 'eastus2' @@ -332,7 +332,7 @@ resource recipepack 'Radius.Core/recipePacks@2025-08-01-preview' = { recipes: { 'Radius.Data/mySqlDatabases': { kind: 'terraform' - location: 'terraform-aws-modules/rds/aws' + source: 'terraform-aws-modules/rds/aws' version: '5.9.0' parameters: { identifier: '{{context.resource.name}}' From 71bc0d393ab557203a949fbcd2fbe9385498fd34 Mon Sep 17 00:00:00 2001 From: Reshma Abdul Rahim Date: Thu, 11 Jun 2026 10:03:49 -0700 Subject: [PATCH 16/16] Update to Signed-off-by: Reshma Abdul Rahim --- eng/design-notes/recipes/2026-05-direct-recipe-modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md index 2b404002a33..41aa47a3643 100644 --- a/eng/design-notes/recipes/2026-05-direct-recipe-modules.md +++ b/eng/design-notes/recipes/2026-05-direct-recipe-modules.md @@ -80,7 +80,7 @@ After this feature, I can set `source` directly to `terraform-aws-modules/rds/aw ### Detailed user experience ->**Note**: This user experience incorporates the [Rename recipeKind and recipeLocation to kind and location in Recipe Packs](https://github.com/radius-project/radius/issues/11879) changes. +>**Note**: This user experience incorporates the [Rename recipeKind and recipeLocation to kind and source in Recipe Packs](https://github.com/radius-project/radius/issues/11879) changes. 1. Platform engineer creates a Recipe Pack pointing directly at an upstream module: