From 271a3bc77839e72ff15884845c14f92be572790c Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 10 Jun 2026 21:30:05 +0000 Subject: [PATCH 1/3] feat: introduce azd validate command with pipeline and gate framework Implements the core validation pipeline framework for azd validate (#7817). The design introduces three concepts: - Pipeline: orchestrates sequential execution of validation gates - Gate: a named validation stage containing related checks - Check: an individual validation function within a gate Key components: - pkg/validate/types.go: Core types (CheckResult, GateResult, PipelineResult) - pkg/validate/gate.go: Gate interface and PipelineContext with typed values - pkg/validate/check.go: CheckBasedGate for composing checks into gates - pkg/validate/pipeline.go: Pipeline engine with abort/continue behavior - pkg/validate/report.go: ValidationReport UX rendering (reuses preflight pattern) - pkg/validate/gate_project_config.go: Example built-in gate - internal/cmd/validate.go: ValidateAction with --gate filter flag The pipeline flows a shared PipelineContext through gates, enabling inter-gate data sharing via a typed Values map. Gates run sequentially with configurable error behavior (abort on first error vs continue). The existing local-preflight in the bicep provider can be migrated to a Gate implementation in a follow-up, making it the first gate in the pipeline alongside the new project-config gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/container.go | 1 + cli/azd/cmd/root.go | 14 + cli/azd/cmd/testdata/TestFigSpec.ts | 19 ++ .../cmd/testdata/TestUsage-azd-validate.snap | 20 ++ cli/azd/cmd/testdata/TestUsage-azd.snap | 1 + cli/azd/internal/cmd/validate.go | 190 ++++++++++++++ cli/azd/pkg/validate/check.go | 65 +++++ cli/azd/pkg/validate/gate.go | 72 +++++ cli/azd/pkg/validate/gate_project_config.go | 84 ++++++ cli/azd/pkg/validate/pipeline.go | 105 ++++++++ cli/azd/pkg/validate/pipeline_test.go | 245 ++++++++++++++++++ cli/azd/pkg/validate/report.go | 175 +++++++++++++ cli/azd/pkg/validate/types.go | 144 ++++++++++ 13 files changed, 1135 insertions(+) create mode 100644 cli/azd/cmd/testdata/TestUsage-azd-validate.snap create mode 100644 cli/azd/internal/cmd/validate.go create mode 100644 cli/azd/pkg/validate/check.go create mode 100644 cli/azd/pkg/validate/gate.go create mode 100644 cli/azd/pkg/validate/gate_project_config.go create mode 100644 cli/azd/pkg/validate/pipeline.go create mode 100644 cli/azd/pkg/validate/pipeline_test.go create mode 100644 cli/azd/pkg/validate/report.go create mode 100644 cli/azd/pkg/validate/types.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index d59edf95c6c..c99257d2192 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -1020,6 +1020,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // Required for nested actions called from composite actions like 'up' registerAction[*cmd.ProvisionAction](container, "azd-provision-action") + registerAction[*cmd.ValidateAction](container, "azd-validate-action") registerAction[*downAction](container, "azd-down-action") registerAction[*configShowAction](container, "azd-config-show-action") } diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 5dbea27b345..44ce307899c 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -332,6 +332,20 @@ func newRootCmd( }). UseMiddleware("extensions", middleware.NewExtensionsMiddleware) + root. + Add("validate", &actions.ActionDescriptorOptions{ + Command: cmd.NewValidateCmd(), + FlagsResolver: cmd.NewValidateFlags, + ActionResolver: cmd.NewValidateAction, + OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, + DefaultFormat: output.NoneFormat, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupAzure, + }, + RequireLogin: true, + }). + UseMiddleware("extensions", middleware.NewExtensionsMiddleware) + root. Add("package", &actions.ActionDescriptorOptions{ Command: newPackageCmd(), diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index bcc6c55442e..59e981bdd12 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6726,6 +6726,21 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['validate'], + description: 'Validate your project configuration and Azure readiness.', + options: [ + { + name: ['--gate'], + description: 'Run only the specified validation gate (e.g. "local-preflight").', + args: [ + { + name: 'gate', + }, + ], + }, + ], + }, { name: ['version'], description: 'Print the version number of Azure Developer CLI.', @@ -8010,6 +8025,10 @@ const completionSpec: Fig.Spec = { name: ['update'], description: 'Updates azd to the latest version.', }, + { + name: ['validate'], + description: 'Validate your project configuration and Azure readiness.', + }, { name: ['version'], description: 'Print the version number of Azure Developer CLI.', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-validate.snap b/cli/azd/cmd/testdata/TestUsage-azd-validate.snap new file mode 100644 index 00000000000..fdc599ce8ce --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-validate.snap @@ -0,0 +1,20 @@ + +Validate your project configuration and Azure readiness. + +Usage + azd validate [flags] + +Flags + -e, --environment string : The name of the environment to use. + --gate string : Run only the specified validation gate (e.g. "local-preflight"). + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd validate in your web browser. + -h, --help : Gets help for validate. + --no-prompt : Runs without prompts. Uses existing values; fails if any required value or decision cannot be resolved automatically. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index 75b72817c42..476f1aae4ad 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -15,6 +15,7 @@ Commands down : Delete your project's Azure resources. provision : Provision Azure resources for your project. publish : Publish a service to a container registry. + validate : Validate your project configuration and Azure readiness. Manage and show settings completion : Generate shell completion scripts. diff --git a/cli/azd/internal/cmd/validate.go b/cli/azd/internal/cmd/validate.go new file mode 100644 index 00000000000..7ec7adad76b --- /dev/null +++ b/cli/azd/internal/cmd/validate.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/validate" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ValidateFlags holds the flags for the validate command. +type ValidateFlags struct { + global *internal.GlobalCommandOptions + // gate filters the pipeline to run only the specified gate. + gate string + *internal.EnvFlag +} + +// Bind registers the validate flags on the given flag set. +func (f *ValidateFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag = &internal.EnvFlag{} + f.EnvFlag.Bind(local, global) + local.StringVar( + &f.gate, + "gate", + "", + "Run only the specified validation gate (e.g. \"local-preflight\").", + ) + f.global = global +} + +// NewValidateFlags creates and binds flags for the validate command. +func NewValidateFlags( + cmd *cobra.Command, global *internal.GlobalCommandOptions, +) *ValidateFlags { + flags := &ValidateFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +// NewValidateCmd creates the cobra command for "azd validate". +func NewValidateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate", + Short: "Validate your project configuration and Azure readiness.", + Long: `Validate runs a pipeline of validation gates against your project +and environment to detect issues before provisioning. Each gate performs +a set of checks (e.g. role assignments, resource quotas, configuration) +and reports warnings or errors with actionable suggestions. + +Use --gate to run a specific gate in isolation.`, + } +} + +// ValidateAction implements the azd validate command. +type ValidateAction struct { + flags *ValidateFlags + projectConfig *project.ProjectConfig + projectManager project.ProjectManager + env *environment.Environment + console input.Console + formatter output.Formatter + writer io.Writer + gates []validate.Gate +} + +// NewValidateAction creates a new ValidateAction with all dependencies +// injected via IoC. +func NewValidateAction( + flags *ValidateFlags, + projectConfig *project.ProjectConfig, + projectManager project.ProjectManager, + env *environment.Environment, + console input.Console, + formatter output.Formatter, + writer io.Writer, +) actions.Action { + return &ValidateAction{ + flags: flags, + projectConfig: projectConfig, + projectManager: projectManager, + env: env, + console: console, + formatter: formatter, + writer: writer, + } +} + +// RegisterGate adds a validation gate to be executed by this action. +// Gates are executed in the order they are registered. +func (a *ValidateAction) RegisterGate(gate validate.Gate) { + a.gates = append(a.gates, gate) +} + +// Run executes the validation pipeline and displays results. +func (a *ValidateAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Validating project (azd validate)", + TitleNote: "Running validation checks against your project and environment", + }) + + if err := a.projectManager.Initialize(ctx, a.projectConfig); err != nil { + return nil, err + } + + pipeline := validate.NewPipeline(validate.PipelineOptions{ + OnError: validate.OnErrorContinue, + }) + + // Register gates, optionally filtering by --gate flag + gateFilter := a.flags.gate + registered := 0 + for _, gate := range a.gates { + if gateFilter != "" && gate.Name() != gateFilter { + continue + } + pipeline.AddGate(gate) + registered++ + } + + if gateFilter != "" && registered == 0 { + return nil, fmt.Errorf( + "unknown gate %q; available gates: %s", + gateFilter, a.availableGateNames(), + ) + } + + pCtx := &validate.PipelineContext{ + Console: a.console, + Environment: a.env, + Project: a.projectConfig, + } + + result, err := pipeline.Run(ctx, pCtx) + if err != nil { + return nil, err + } + + // Display the validation report + report := &validate.ValidationReport{Result: result} + a.console.MessageUxItem(ctx, report) + + // Format output for --output json + if a.formatter.Kind() == output.JsonFormat { + if err := a.formatter.Format(result, a.writer, nil); err != nil { + return nil, fmt.Errorf("formatting output: %w", err) + } + } + + if result.HasErrors() { + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: fmt.Sprintf( + "Validation found %d error(s) and %d warning(s).", + result.TotalErrors(), result.TotalWarnings(), + ), + }, + }, nil + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Validation completed successfully.", + }, + }, nil +} + +// availableGateNames returns a comma-separated list of registered gate names. +func (a *ValidateAction) availableGateNames() string { + if len(a.gates) == 0 { + return "(none registered)" + } + names := make([]string, len(a.gates)) + for i, g := range a.gates { + names[i] = g.Name() + } + return fmt.Sprintf("%v", names) +} diff --git a/cli/azd/pkg/validate/check.go b/cli/azd/pkg/validate/check.go new file mode 100644 index 00000000000..d31f3f4f36f --- /dev/null +++ b/cli/azd/pkg/validate/check.go @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" +) + +// CheckFn is a function that performs a single validation check within a gate. +// It receives the pipeline context for access to environment, project, and +// inter-gate state. Returns zero or more results (or nil if nothing to report) +// and an error if the check itself failed to execute. +type CheckFn func(ctx context.Context, pCtx *PipelineContext) ([]CheckResult, error) + +// Check pairs a unique rule identifier with its check function. +type Check struct { + // RuleID is a unique, stable identifier for the rule + // (e.g. "role_assignment_permissions"). + RuleID string + // Fn is the check function that performs the validation. + Fn CheckFn +} + +// CheckBasedGate is a convenience [Gate] implementation that runs a list +// of [Check] functions sequentially and aggregates their results. +// +// Use this when implementing a gate that is composed of independent checks +// rather than a single monolithic validation. +type CheckBasedGate struct { + // GateName is the unique identifier for this gate. + GateName string + // Checks is the ordered list of checks to execute. + Checks []Check +} + +// Name returns the gate's unique identifier. +func (g *CheckBasedGate) Name() string { return g.GateName } + +// AddCheck appends a check to the gate's check list. +func (g *CheckBasedGate) AddCheck(check Check) { + g.Checks = append(g.Checks, check) +} + +// Run executes all registered checks sequentially and returns the +// aggregated results. If a check returns an error, execution stops +// and the error is returned. +func (g *CheckBasedGate) Run( + ctx context.Context, pCtx *PipelineContext, +) (*GateResult, error) { + result := &GateResult{ + GateName: g.GateName, + Results: []CheckResult{}, + } + + for _, check := range g.Checks { + checkResults, err := check.Fn(ctx, pCtx) + if err != nil { + return result, err + } + result.Results = append(result.Results, checkResults...) + } + + return result, nil +} diff --git a/cli/azd/pkg/validate/gate.go b/cli/azd/pkg/validate/gate.go new file mode 100644 index 00000000000..2d5732f4c19 --- /dev/null +++ b/cli/azd/pkg/validate/gate.go @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +// Gate is a named validation stage in the pipeline. Each gate groups +// related checks and runs them against the current project and environment. +// +// Gate implementations should return a non-nil [GateResult] even when no +// findings are produced (use an empty Results slice). A nil result with a +// nil error signals that the gate was skipped entirely. +type Gate interface { + // Name returns the unique identifier for this gate (e.g. "local-preflight"). + Name() string + + // Run executes all checks in this gate and returns the aggregated results. + // The pipeline context provides access to shared state, project configuration, + // and the console for user interaction. + Run(ctx context.Context, pCtx *PipelineContext) (*GateResult, error) +} + +// PipelineContext is the shared state that flows through all gates in the +// validation pipeline. It provides access to the project, environment, and +// console, and allows gates to share computed data via the Values map. +type PipelineContext struct { + // Console provides user interaction capabilities (prompts, messages). + Console input.Console + + // Environment is the current azd environment with subscription, location, etc. + Environment *environment.Environment + + // Project is the loaded project configuration from azure.yaml. + Project *project.ProjectConfig + + // Values is a key-value store for inter-gate communication. + // Gates can store computed data (e.g. resolved resource lists) for + // downstream gates to consume. Keys should be namespaced by gate name + // to avoid collisions (e.g. "local-preflight.snapshot"). + Values map[string]any +} + +// SetValue stores a value in the pipeline context for inter-gate communication. +func (c *PipelineContext) SetValue(key string, value any) { + if c.Values == nil { + c.Values = make(map[string]any) + } + c.Values[key] = value +} + +// GetValue retrieves a value from the pipeline context. +// Returns the value and true if found, or the zero value and false if not. +func GetValue[T any](c *PipelineContext, key string) (T, bool) { + if c.Values == nil { + var zero T + return zero, false + } + v, ok := c.Values[key] + if !ok { + var zero T + return zero, false + } + typed, ok := v.(T) + return typed, ok +} diff --git a/cli/azd/pkg/validate/gate_project_config.go b/cli/azd/pkg/validate/gate_project_config.go new file mode 100644 index 00000000000..f9e9156aa83 --- /dev/null +++ b/cli/azd/pkg/validate/gate_project_config.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" + "fmt" +) + +// NewProjectConfigGate creates a gate that validates the project configuration +// from azure.yaml. This is a built-in gate that checks for common configuration +// issues before provisioning or deployment. +func NewProjectConfigGate() Gate { + gate := &CheckBasedGate{GateName: "project-config"} + + gate.AddCheck(Check{ + RuleID: "project_has_services", + Fn: checkProjectHasServices, + }) + + gate.AddCheck(Check{ + RuleID: "services_have_hosts", + Fn: checkServicesHaveHosts, + }) + + return gate +} + +// checkProjectHasServices verifies that the project defines at least one service. +func checkProjectHasServices( + ctx context.Context, pCtx *PipelineContext, +) ([]CheckResult, error) { + if pCtx.Project == nil { + return []CheckResult{{ + Severity: CheckError, + DiagnosticID: "project_not_loaded", + Message: "Project configuration (azure.yaml) could not be loaded.", + Suggestion: "Ensure you are running from a directory with an azure.yaml file.", + }}, nil + } + + if len(pCtx.Project.Services) == 0 { + return []CheckResult{{ + Severity: CheckWarning, + DiagnosticID: "no_services_defined", + Message: "No services are defined in azure.yaml.", + Suggestion: "Add a service definition to azure.yaml. " + + "See https://aka.ms/azure-dev/azure.yaml.schema", + }}, nil + } + + return nil, nil +} + +// checkServicesHaveHosts verifies that each service has a host configured. +func checkServicesHaveHosts( + ctx context.Context, pCtx *PipelineContext, +) ([]CheckResult, error) { + if pCtx.Project == nil { + return nil, nil + } + + var results []CheckResult + for name, svc := range pCtx.Project.Services { + if svc.Host == "" { + results = append(results, CheckResult{ + Severity: CheckWarning, + DiagnosticID: "service_missing_host", + Message: fmt.Sprintf( + "Service %q does not have a host configured.", + name, + ), + Suggestion: fmt.Sprintf( + "Add a 'host' field to the %q service in azure.yaml "+ + "(e.g. host: containerapp).", + name, + ), + }) + } + } + + return results, nil +} diff --git a/cli/azd/pkg/validate/pipeline.go b/cli/azd/pkg/validate/pipeline.go new file mode 100644 index 00000000000..377c4aa005a --- /dev/null +++ b/cli/azd/pkg/validate/pipeline.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" + "fmt" + "log" +) + +// OnErrorBehavior controls how the pipeline reacts when a gate produces errors. +type OnErrorBehavior string + +const ( + // OnErrorAbort stops the pipeline when a gate produces error-level findings. + OnErrorAbort OnErrorBehavior = "abort" + // OnErrorContinue logs error-level findings but continues to the next gate. + OnErrorContinue OnErrorBehavior = "continue" +) + +// PipelineOptions configures how the validation pipeline executes. +type PipelineOptions struct { + // OnError controls pipeline behavior when a gate produces errors. + // Defaults to [OnErrorAbort] if empty. + OnError OnErrorBehavior +} + +// Pipeline orchestrates the sequential execution of validation gates. +// Gates are executed in the order they are added, and results are aggregated +// into a single [PipelineResult]. +type Pipeline struct { + gates []Gate + options PipelineOptions +} + +// NewPipeline creates a new validation pipeline with the given options. +func NewPipeline(options PipelineOptions) *Pipeline { + if options.OnError == "" { + options.OnError = OnErrorAbort + } + return &Pipeline{ + options: options, + } +} + +// AddGate registers a gate to be executed during pipeline runs. +// Gates are executed in the order they are added. +func (p *Pipeline) AddGate(gate Gate) { + p.gates = append(p.gates, gate) +} + +// Gates returns the list of registered gates. +func (p *Pipeline) Gates() []Gate { + return p.gates +} + +// Run executes all registered gates sequentially and returns the aggregated +// results. Each gate receives the shared [PipelineContext] for access to +// project state and inter-gate communication. +// +// When [PipelineOptions.OnError] is [OnErrorAbort] (the default), the pipeline +// stops after the first gate that produces error-level findings. When set to +// [OnErrorContinue], all gates run regardless of errors. +// +// If a gate returns an error (as opposed to error-level findings), the pipeline +// stops immediately and returns the error. +func (p *Pipeline) Run(ctx context.Context, pCtx *PipelineContext) (*PipelineResult, error) { + result := &PipelineResult{} + + for _, gate := range p.gates { + select { + case <-ctx.Done(): + return result, ctx.Err() + default: + } + + log.Printf("validate: running gate %q", gate.Name()) + gateResult, err := gate.Run(ctx, pCtx) + if err != nil { + return result, fmt.Errorf("validation gate %q failed: %w", gate.Name(), err) + } + + if gateResult == nil { + // nil result means gate was skipped + gateResult = &GateResult{ + GateName: gate.Name(), + Skipped: true, + SkipReason: "gate returned no result", + } + } + + result.GateResults = append(result.GateResults, gateResult) + + if gateResult.HasErrors() && p.options.OnError == OnErrorAbort { + log.Printf( + "validate: gate %q produced %d error(s), aborting pipeline", + gate.Name(), gateResult.ErrorCount(), + ) + return result, nil + } + } + + return result, nil +} diff --git a/cli/azd/pkg/validate/pipeline_test.go b/cli/azd/pkg/validate/pipeline_test.go new file mode 100644 index 00000000000..57a92284611 --- /dev/null +++ b/cli/azd/pkg/validate/pipeline_test.go @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// testGate is a simple Gate implementation for testing. +type testGate struct { + name string + result *GateResult + err error +} + +func (g *testGate) Name() string { return g.name } + +func (g *testGate) Run(_ context.Context, _ *PipelineContext) (*GateResult, error) { + return g.result, g.err +} + +func TestPipeline_Run_NoGates(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + require.NotNil(t, result) + require.Empty(t, result.GateResults) + require.False(t, result.HasErrors()) + require.False(t, result.HasWarnings()) +} + +func TestPipeline_Run_SingleGateNoFindings(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + p.AddGate(&testGate{ + name: "empty-gate", + result: &GateResult{ + GateName: "empty-gate", + Results: []CheckResult{}, + }, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + require.Len(t, result.GateResults, 1) + require.Equal(t, "empty-gate", result.GateResults[0].GateName) + require.Empty(t, result.GateResults[0].Results) + require.False(t, result.HasErrors()) +} + +func TestPipeline_Run_WarningsDoNotAbort(t *testing.T) { + p := NewPipeline(PipelineOptions{OnError: OnErrorAbort}) + p.AddGate(&testGate{ + name: "warning-gate", + result: &GateResult{ + GateName: "warning-gate", + Results: []CheckResult{ + {Severity: CheckWarning, DiagnosticID: "warn1", Message: "a warning"}, + }, + }, + }) + p.AddGate(&testGate{ + name: "second-gate", + result: &GateResult{ + GateName: "second-gate", + Results: []CheckResult{}, + }, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + // Both gates should have run — warnings don't abort. + require.Len(t, result.GateResults, 2) + require.True(t, result.HasWarnings()) + require.False(t, result.HasErrors()) +} + +func TestPipeline_Run_ErrorsAbortByDefault(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + p.AddGate(&testGate{ + name: "error-gate", + result: &GateResult{ + GateName: "error-gate", + Results: []CheckResult{ + {Severity: CheckError, DiagnosticID: "err1", Message: "a blocking error"}, + }, + }, + }) + p.AddGate(&testGate{ + name: "should-not-run", + result: &GateResult{ + GateName: "should-not-run", + Results: []CheckResult{}, + }, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + // Only the first gate should have run. + require.Len(t, result.GateResults, 1) + require.True(t, result.HasErrors()) + require.Equal(t, 1, result.TotalErrors()) +} + +func TestPipeline_Run_ErrorsContinueWhenConfigured(t *testing.T) { + p := NewPipeline(PipelineOptions{OnError: OnErrorContinue}) + p.AddGate(&testGate{ + name: "error-gate", + result: &GateResult{ + GateName: "error-gate", + Results: []CheckResult{ + {Severity: CheckError, DiagnosticID: "err1", Message: "an error"}, + }, + }, + }) + p.AddGate(&testGate{ + name: "next-gate", + result: &GateResult{ + GateName: "next-gate", + Results: []CheckResult{ + {Severity: CheckWarning, DiagnosticID: "warn1", Message: "a warning"}, + }, + }, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + // Both gates should have run. + require.Len(t, result.GateResults, 2) + require.True(t, result.HasErrors()) + require.True(t, result.HasWarnings()) + require.Equal(t, 1, result.TotalErrors()) + require.Equal(t, 1, result.TotalWarnings()) +} + +func TestPipeline_Run_GateErrorStopsPipeline(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + p.AddGate(&testGate{ + name: "failing-gate", + err: errors.New("internal failure"), + }) + p.AddGate(&testGate{ + name: "should-not-run", + result: &GateResult{GateName: "should-not-run"}, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.Error(t, err) + require.Contains(t, err.Error(), "failing-gate") + require.Empty(t, result.GateResults) +} + +func TestPipeline_Run_NilResultTreatedAsSkipped(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + p.AddGate(&testGate{ + name: "nil-gate", + result: nil, + }) + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + require.Len(t, result.GateResults, 1) + require.True(t, result.GateResults[0].Skipped) +} + +func TestPipeline_Run_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() // cancel immediately + + p := NewPipeline(PipelineOptions{}) + p.AddGate(&testGate{ + name: "should-not-run", + result: &GateResult{GateName: "should-not-run"}, + }) + + result, err := p.Run(ctx, &PipelineContext{}) + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, result.GateResults) +} + +func TestPipeline_Run_GateOrder(t *testing.T) { + p := NewPipeline(PipelineOptions{}) + names := []string{"alpha", "beta", "gamma"} + for _, name := range names { + p.AddGate(&testGate{ + name: name, + result: &GateResult{GateName: name, Results: []CheckResult{}}, + }) + } + + result, err := p.Run(t.Context(), &PipelineContext{}) + require.NoError(t, err) + require.Len(t, result.GateResults, 3) + for i, name := range names { + require.Equal(t, name, result.GateResults[i].GateName) + } +} + +func TestPipelineContext_Values(t *testing.T) { + pCtx := &PipelineContext{} + + // GetValue on empty context + _, ok := GetValue[string](pCtx, "missing") + require.False(t, ok) + + // SetValue and GetValue + pCtx.SetValue("gate-a.data", "hello") + val, ok := GetValue[string](pCtx, "gate-a.data") + require.True(t, ok) + require.Equal(t, "hello", val) + + // Type mismatch + _, ok = GetValue[int](pCtx, "gate-a.data") + require.False(t, ok) +} + +func TestPipelineResult_Totals(t *testing.T) { + r := &PipelineResult{ + GateResults: []*GateResult{ + { + GateName: "g1", + Results: []CheckResult{ + {Severity: CheckWarning}, + {Severity: CheckError}, + {Severity: CheckWarning}, + }, + }, + { + GateName: "g2", + Results: []CheckResult{ + {Severity: CheckError}, + }, + }, + }, + } + + require.Equal(t, 2, r.TotalErrors()) + require.Equal(t, 2, r.TotalWarnings()) + require.True(t, r.HasErrors()) + require.True(t, r.HasWarnings()) +} diff --git a/cli/azd/pkg/validate/report.go b/cli/azd/pkg/validate/report.go new file mode 100644 index 00000000000..96d5856ce90 --- /dev/null +++ b/cli/azd/pkg/validate/report.go @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" +) + +var ( + warningPrefix = output.WithWarningFormat("(!) Warning:") + failedPrefix = output.WithErrorFormat("(x) Error:") + skippedPrefix = output.WithGrayFormat("(-) Skipped:") + donePrefix = output.WithSuccessFormat("(✓) Done") +) + +// ValidationReport renders the results of a validation pipeline run. +// It implements the ux.UxItem interface for display in the azd console. +type ValidationReport struct { + Result *PipelineResult +} + +// ToString renders the validation report as a formatted string for terminal display. +func (r *ValidationReport) ToString(currentIndentation string) string { + if r.Result == nil || len(r.Result.GateResults) == 0 { + return "" + } + + var sb strings.Builder + + for i, gr := range r.Result.GateResults { + if i > 0 { + sb.WriteString("\n") + } + + r.writeGateSection(&sb, currentIndentation, gr) + } + + // Summary line + sb.WriteString("\n") + totalErrors := r.Result.TotalErrors() + totalWarnings := r.Result.TotalWarnings() + if totalErrors == 0 && totalWarnings == 0 { + sb.WriteString(fmt.Sprintf( + "%s%s Validation completed with no issues.\n", + currentIndentation, donePrefix, + )) + } else { + sb.WriteString(fmt.Sprintf( + "%sValidation completed: %d error(s), %d warning(s).\n", + currentIndentation, totalErrors, totalWarnings, + )) + } + + return sb.String() +} + +// writeGateSection renders a single gate's results. +func (r *ValidationReport) writeGateSection( + sb *strings.Builder, indent string, gr *GateResult, +) { + gateHeader := output.WithBold("%s", gr.GateName) + + if gr.Skipped { + sb.WriteString(fmt.Sprintf("%s%s %s", indent, skippedPrefix, gateHeader)) + if gr.SkipReason != "" { + sb.WriteString(fmt.Sprintf(" (%s)", gr.SkipReason)) + } + sb.WriteString("\n") + return + } + + if len(gr.Results) == 0 { + sb.WriteString(fmt.Sprintf( + "%s%s %s - no issues found\n", + indent, donePrefix, gateHeader, + )) + return + } + + sb.WriteString(fmt.Sprintf("%s%s\n", indent, gateHeader)) + + // Partition into warnings and errors for consistent ordering + var warnings, errors []CheckResult + for _, cr := range gr.Results { + if cr.Severity == CheckError { + errors = append(errors, cr) + } else { + warnings = append(warnings, cr) + } + } + + for _, w := range warnings { + writeCheckItem(sb, indent+" ", warningPrefix, w) + } + for _, e := range errors { + writeCheckItem(sb, indent+" ", failedPrefix, e) + } +} + +// writeCheckItem renders a single check result with message, suggestion, and links. +func writeCheckItem( + sb *strings.Builder, indent string, prefix string, item CheckResult, +) { + if item.Message == "" { + return + } + + lines := strings.Split(item.Message, "\n") + sb.WriteString(fmt.Sprintf("%s%s %s\n", indent, prefix, lines[0])) + for _, line := range lines[1:] { + sb.WriteString(fmt.Sprintf("%s%s\n", indent, line)) + } + + if item.Suggestion != "" { + sb.WriteString(fmt.Sprintf("%s%s %s\n", + indent, + output.WithHighLightFormat("Suggestion:"), + item.Suggestion)) + } + for _, link := range item.Links { + if link.Title != "" { + sb.WriteString(fmt.Sprintf("%s• %s\n", + indent, + output.WithHyperlink(link.URL, link.Title))) + } else { + sb.WriteString(fmt.Sprintf("%s• %s\n", + indent, + output.WithLinkFormat(link.URL))) + } + } +} + +// MarshalJSON serializes the validation report for JSON output and telemetry. +func (r *ValidationReport) MarshalJSON() ([]byte, error) { + if r.Result == nil { + return json.Marshal(output.EventForMessage("validate: no results")) + } + + type jsonGateResult struct { + Gate string `json:"gate"` + Skipped bool `json:"skipped,omitempty"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + } + + gates := make([]jsonGateResult, len(r.Result.GateResults)) + for i, gr := range r.Result.GateResults { + gates[i] = jsonGateResult{ + Gate: gr.GateName, + Skipped: gr.Skipped, + Errors: gr.ErrorCount(), + Warnings: gr.WarningCount(), + } + } + + return json.Marshal(struct { + Type string `json:"type"` + Gates []jsonGateResult `json:"gates"` + Summary string `json:"summary"` + }{ + Type: "validate.report", + Gates: gates, + Summary: fmt.Sprintf("%d error(s), %d warning(s)", + r.Result.TotalErrors(), r.Result.TotalWarnings()), + }) +} + +// Ensure ValidationReport satisfies ux.UxItem at compile time. +var _ ux.UxItem = (*ValidationReport)(nil) diff --git a/cli/azd/pkg/validate/types.go b/cli/azd/pkg/validate/types.go new file mode 100644 index 00000000000..97312f73b24 --- /dev/null +++ b/cli/azd/pkg/validate/types.go @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package validate provides the validation pipeline framework for azd. +// +// The framework is organized around three concepts: +// - A [Pipeline] that orchestrates sequential execution of validation stages. +// - [Gate] implementations that group related checks into a named stage. +// - [Check] functions that perform individual validations within a gate. +// +// The pipeline flows a shared [PipelineContext] through each gate, allowing +// gates to read project/environment state and store computed data for +// downstream gates via the Values map. +package validate + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" +) + +// CheckSeverity indicates whether a check result is a warning or a blocking error. +type CheckSeverity int + +const ( + // CheckWarning indicates a non-blocking issue that the user should be aware of. + CheckWarning CheckSeverity = iota + // CheckError indicates a blocking issue that should prevent deployment. + CheckError +) + +// CheckResult holds the outcome of a single validation check. +type CheckResult struct { + // Severity indicates whether this result is a warning or a blocking error. + Severity CheckSeverity + // DiagnosticID is a unique, stable identifier for this finding type + // (e.g. "role_assignment_missing"). Used in telemetry to correlate + // findings with deployment outcomes. + DiagnosticID string + // Message is a human-readable description of the finding. + Message string + // Suggestion is an optional actionable recommendation for resolving the issue. + Suggestion string + // Links is an optional list of reference links related to the finding. + Links []ux.PreflightReportLink +} + +// GateResult aggregates results from all checks executed within a single gate. +type GateResult struct { + // GateName is the unique identifier of the gate that produced these results. + GateName string + // Results contains the individual check outcomes. An empty non-nil slice + // means checks ran but found nothing; a nil slice means the gate was skipped. + Results []CheckResult + // Skipped is true when the gate did not execute its checks + // (e.g. required tooling was unavailable). + Skipped bool + // SkipReason explains why the gate was skipped. Only set when Skipped is true. + SkipReason string +} + +// HasErrors returns true if the gate result contains at least one error-level finding. +func (r *GateResult) HasErrors() bool { + for _, result := range r.Results { + if result.Severity == CheckError { + return true + } + } + return false +} + +// HasWarnings returns true if the gate result contains at least one warning-level finding. +func (r *GateResult) HasWarnings() bool { + for _, result := range r.Results { + if result.Severity == CheckWarning { + return true + } + } + return false +} + +// ErrorCount returns the number of error-level findings. +func (r *GateResult) ErrorCount() int { + count := 0 + for _, result := range r.Results { + if result.Severity == CheckError { + count++ + } + } + return count +} + +// WarningCount returns the number of warning-level findings. +func (r *GateResult) WarningCount() int { + count := 0 + for _, result := range r.Results { + if result.Severity == CheckWarning { + count++ + } + } + return count +} + +// PipelineResult aggregates results from all gates executed in the pipeline. +type PipelineResult struct { + // GateResults holds the result for each gate that was executed. + GateResults []*GateResult +} + +// HasErrors returns true if any gate result contains at least one error-level finding. +func (r *PipelineResult) HasErrors() bool { + for _, gr := range r.GateResults { + if gr.HasErrors() { + return true + } + } + return false +} + +// HasWarnings returns true if any gate result contains at least one warning-level finding. +func (r *PipelineResult) HasWarnings() bool { + for _, gr := range r.GateResults { + if gr.HasWarnings() { + return true + } + } + return false +} + +// TotalErrors returns the total count of error-level findings across all gates. +func (r *PipelineResult) TotalErrors() int { + total := 0 + for _, gr := range r.GateResults { + total += gr.ErrorCount() + } + return total +} + +// TotalWarnings returns the total count of warning-level findings across all gates. +func (r *PipelineResult) TotalWarnings() int { + total := 0 + for _, gr := range r.GateResults { + total += gr.WarningCount() + } + return total +} From 20dee7e79898de79ef1996722a5e6d1ca4931d6d Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 10 Jun 2026 21:34:43 +0000 Subject: [PATCH 2/3] fix: register built-in project-config gate in ValidateAction The constructor was creating an empty gates slice without registering any built-in gates, causing azd validate to succeed immediately with no checks. Now registers NewProjectConfigGate() by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/validate.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/azd/internal/cmd/validate.go b/cli/azd/internal/cmd/validate.go index 7ec7adad76b..396aeb12293 100644 --- a/cli/azd/internal/cmd/validate.go +++ b/cli/azd/internal/cmd/validate.go @@ -87,7 +87,7 @@ func NewValidateAction( formatter output.Formatter, writer io.Writer, ) actions.Action { - return &ValidateAction{ + action := &ValidateAction{ flags: flags, projectConfig: projectConfig, projectManager: projectManager, @@ -96,6 +96,11 @@ func NewValidateAction( formatter: formatter, writer: writer, } + + // Register built-in gates + action.RegisterGate(validate.NewProjectConfigGate()) + + return action } // RegisterGate adds a validation gate to be executed by this action. From 84a9b37f253f9d5ce14ec112b175bb15f7e9b224 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 10 Jun 2026 21:51:18 +0000 Subject: [PATCH 3/3] feat: wire local-preflight gate into azd validate pipeline Add Validate() to the provisioning.Provider interface and implement it in BicepProvider (reuses plan() + local preflight checks), with stubs for Terraform, TestProvider, DevCenter, and external gRPC providers. Create LocalPreflightGate in pkg/validate that delegates to the provisioning Manager and converts results into the validate framework's CheckResult types. Register the local-preflight gate in ValidateAction so 'azd validate' runs both project-config and local-preflight gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/provision_test.go | 4 + cli/azd/internal/cmd/validate.go | 36 ++++--- .../external_provisioning_provider.go | 11 ++ cli/azd/pkg/devcenter/provision_provider.go | 10 ++ .../provisioning/bicep/bicep_provider.go | 84 +++++++++++++++ cli/azd/pkg/infra/provisioning/manager.go | 11 ++ cli/azd/pkg/infra/provisioning/provider.go | 37 +++++++ .../terraform/terraform_provider.go | 11 ++ .../infra/provisioning/test/test_provider.go | 9 ++ cli/azd/pkg/validate/gate_local_preflight.go | 102 ++++++++++++++++++ 10 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 cli/azd/pkg/validate/gate_local_preflight.go diff --git a/cli/azd/internal/cmd/provision_test.go b/cli/azd/internal/cmd/provision_test.go index 4b36dd719a7..f8b7ae3d704 100644 --- a/cli/azd/internal/cmd/provision_test.go +++ b/cli/azd/internal/cmd/provision_test.go @@ -96,6 +96,10 @@ func (p *mockProvider) Destroy(_ context.Context, _ provisioning.DestroyOptions) func (p *mockProvider) EnsureEnv(_ context.Context) error { return nil } +func (p *mockProvider) Validate(_ context.Context) (*provisioning.ValidateResult, error) { + return &provisioning.ValidateResult{Skipped: true, SkipReason: "mock"}, nil +} + func (p *mockProvider) Parameters(_ context.Context) ([]provisioning.Parameter, error) { return nil, nil } diff --git a/cli/azd/internal/cmd/validate.go b/cli/azd/internal/cmd/validate.go index 396aeb12293..5eca8cb0d9e 100644 --- a/cli/azd/internal/cmd/validate.go +++ b/cli/azd/internal/cmd/validate.go @@ -11,6 +11,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" @@ -66,14 +67,15 @@ Use --gate to run a specific gate in isolation.`, // ValidateAction implements the azd validate command. type ValidateAction struct { - flags *ValidateFlags - projectConfig *project.ProjectConfig - projectManager project.ProjectManager - env *environment.Environment - console input.Console - formatter output.Formatter - writer io.Writer - gates []validate.Gate + flags *ValidateFlags + projectConfig *project.ProjectConfig + projectManager project.ProjectManager + provisionManager *provisioning.Manager + env *environment.Environment + console input.Console + formatter output.Formatter + writer io.Writer + gates []validate.Gate } // NewValidateAction creates a new ValidateAction with all dependencies @@ -82,23 +84,27 @@ func NewValidateAction( flags *ValidateFlags, projectConfig *project.ProjectConfig, projectManager project.ProjectManager, + provisionManager *provisioning.Manager, env *environment.Environment, console input.Console, formatter output.Formatter, writer io.Writer, ) actions.Action { action := &ValidateAction{ - flags: flags, - projectConfig: projectConfig, - projectManager: projectManager, - env: env, - console: console, - formatter: formatter, - writer: writer, + flags: flags, + projectConfig: projectConfig, + projectManager: projectManager, + provisionManager: provisionManager, + env: env, + console: console, + formatter: formatter, + writer: writer, } // Register built-in gates action.RegisterGate(validate.NewProjectConfigGate()) + action.RegisterGate( + validate.NewLocalPreflightGate(provisionManager)) return action } diff --git a/cli/azd/internal/grpcserver/external_provisioning_provider.go b/cli/azd/internal/grpcserver/external_provisioning_provider.go index 1d3b306f6a3..890977cb292 100644 --- a/cli/azd/internal/grpcserver/external_provisioning_provider.go +++ b/cli/azd/internal/grpcserver/external_provisioning_provider.go @@ -218,6 +218,17 @@ func (p *ExternalProvisioningProvider) Destroy( }, nil } +// Validate is not yet supported for extension provisioning providers. +// Returns a skipped result. +func (p *ExternalProvisioningProvider) Validate( + ctx context.Context, +) (*provisioning.ValidateResult, error) { + return &provisioning.ValidateResult{ + Skipped: true, + SkipReason: "validation not yet supported for extension providers", + }, nil +} + // EnsureEnv ensures the environment is configured properly. func (p *ExternalProvisioningProvider) EnsureEnv(ctx context.Context) error { req := &azdext.ProvisioningMessage{ diff --git a/cli/azd/pkg/devcenter/provision_provider.go b/cli/azd/pkg/devcenter/provision_provider.go index 02ba6b658be..381ecb91e85 100644 --- a/cli/azd/pkg/devcenter/provision_provider.go +++ b/cli/azd/pkg/devcenter/provision_provider.go @@ -249,6 +249,16 @@ func (p *ProvisionProvider) Preview(ctx context.Context) (*provisioning.DeployPr return nil, fmt.Errorf("preview is not supported for devcenter") } +// Validate is not yet supported for Dev Center. +func (p *ProvisionProvider) Validate( + ctx context.Context, +) (*provisioning.ValidateResult, error) { + return &provisioning.ValidateResult{ + Skipped: true, + SkipReason: "validation not yet supported for Dev Center provider", + }, nil +} + // Destroy destroys the environment by deleting the ADE environment func (p *ProvisionProvider) Destroy( ctx context.Context, diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 86def60b860..21754033ac4 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -1147,6 +1147,90 @@ func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPrevie }, nil } +// Validate runs local preflight validation without deploying. +// It compiles the Bicep template, builds a deployment object, and +// executes the same local preflight checks that run during Deploy(). +// The results are returned as [provisioning.ValidateResult] so callers +// can inspect findings without side effects. +func (p *BicepProvider) Validate( + ctx context.Context, +) (*provisioning.ValidateResult, error) { + planned, err := p.plan(ctx) + if err != nil { + return nil, err + } + + deployment, err := p.generateDeploymentObject(planned) + if err != nil { + return nil, err + } + + envLocation := p.resolveResourceGroupLocation( + ctx, p.env.GetSubscriptionId()) + if envLocation == "" { + envLocation = strings.ToLower(p.env.GetLocation()) + } + + localPreflight := newLocalArmPreflight( + p.path, p.bicepCli, deployment, envLocation) + + localPreflight.AddCheck(PreflightCheck{ + RuleID: "role_assignment_permissions", + Fn: p.checkRoleAssignmentPermissions, + }) + localPreflight.AddCheck(PreflightCheck{ + RuleID: "ai_model_quota", + Fn: p.checkAiModelQuota, + }) + localPreflight.AddCheck(PreflightCheck{ + RuleID: "reserved_resource_names", + Fn: p.checkReservedResourceNames, + }) + + results, err := localPreflight.validate( + ctx, p.console, planned.RawArmTemplate, planned.Parameters) + if err != nil { + return nil, fmt.Errorf( + "local preflight validation failed: %w", err) + } + + if results == nil { + return &provisioning.ValidateResult{ + Skipped: true, + SkipReason: "bicep snapshot unavailable", + }, nil + } + + validateResults := make( + []provisioning.ValidateCheckResult, len(results)) + for i, r := range results { + severity := provisioning.ValidateWarning + if r.Severity == PreflightCheckError { + severity = provisioning.ValidateError + } + + links := make( + []provisioning.ValidateLink, len(r.Links)) + for j, l := range r.Links { + links[j] = provisioning.ValidateLink{ + URL: l.URL, Title: l.Title, + } + } + + validateResults[i] = provisioning.ValidateCheckResult{ + Severity: severity, + DiagnosticID: r.DiagnosticID, + Message: r.Message, + Suggestion: r.Suggestion, + Links: links, + } + } + + return &provisioning.ValidateResult{ + Results: validateResults, + }, nil +} + // convertPropertyChanges converts Azure SDK's WhatIfPropertyChange to our DeploymentPreviewPropertyChange func convertPropertyChanges(changes []*armresources.WhatIfPropertyChange) []provisioning.DeploymentPreviewPropertyChange { if changes == nil { diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 5d9b81caf0c..68d5cd42ebb 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -143,6 +143,17 @@ func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) { return deployResult, nil } +// Validate runs provider-level validation without deploying. +// It delegates to the provider's Validate method, which compiles +// the template and runs local preflight checks. +func (m *Manager) Validate(ctx context.Context) (*ValidateResult, error) { + if m.provider == nil { + return nil, fmt.Errorf( + "provider not initialized; call Initialize() first") + } + return m.provider.Validate(ctx) +} + const ( fileShareUploadOperation string = "FileShareUpload" azdOperationsFileName string = "azd.operations.yaml" diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index a27d5d52923..33fecb5a45e 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -222,6 +222,42 @@ const ( PreflightAbortedSkipped SkippedReasonType = "preflight aborted" ) +// ValidateCheckSeverity indicates whether a validation finding is a warning or blocking error. +type ValidateCheckSeverity int + +const ( + // ValidateWarning indicates a non-blocking issue. + ValidateWarning ValidateCheckSeverity = iota + // ValidateError indicates a blocking issue. + ValidateError +) + +// ValidateCheckResult holds the outcome of a single validation check. +type ValidateCheckResult struct { + Severity ValidateCheckSeverity + DiagnosticID string + Message string + Suggestion string + Links []ValidateLink +} + +// ValidateLink is a reference link attached to a validation finding. +type ValidateLink struct { + URL string + Title string +} + +// ValidateResult holds the outcome of a provider-level validation. +type ValidateResult struct { + // Results contains all findings. An empty non-nil slice means validation + // ran but found nothing. A nil slice means validation was skipped. + Results []ValidateCheckResult + // Skipped is true when validation did not execute. + Skipped bool + // SkipReason explains why validation was skipped. + SkipReason string +} + type DeployResult struct { Deployment *Deployment SkippedReason SkippedReasonType @@ -266,6 +302,7 @@ type Provider interface { Deploy(ctx context.Context) (*DeployResult, error) Preview(ctx context.Context) (*DeployPreviewResult, error) Destroy(ctx context.Context, options DestroyOptions) (*DestroyResult, error) + Validate(ctx context.Context) (*ValidateResult, error) EnsureEnv(ctx context.Context) error Parameters(ctx context.Context) ([]Parameter, error) PlannedOutputs(ctx context.Context) ([]PlannedOutput, error) diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 3c86efc0055..45636ed4a5c 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -235,6 +235,17 @@ func (t *TerraformProvider) Preview(ctx context.Context) (*provisioning.DeployPr }, nil } +// Validate is not yet implemented for Terraform. +// Returns a skipped result indicating Terraform validation is pending. +func (t *TerraformProvider) Validate( + ctx context.Context, +) (*provisioning.ValidateResult, error) { + return &provisioning.ValidateResult{ + Skipped: true, + SkipReason: "validation not yet supported for Terraform provider", + }, nil +} + // Destroys the specified deployment through terraform destroy func (t *TerraformProvider) Destroy( ctx context.Context, diff --git a/cli/azd/pkg/infra/provisioning/test/test_provider.go b/cli/azd/pkg/infra/provisioning/test/test_provider.go index e833853a770..7e30b43e288 100644 --- a/cli/azd/pkg/infra/provisioning/test/test_provider.go +++ b/cli/azd/pkg/infra/provisioning/test/test_provider.go @@ -107,6 +107,15 @@ func (p *TestProvider) Preview(ctx context.Context) (*provisioning.DeployPreview }, nil } +// Validate returns an empty result for the test provider. +func (p *TestProvider) Validate( + ctx context.Context, +) (*provisioning.ValidateResult, error) { + return &provisioning.ValidateResult{ + Results: []provisioning.ValidateCheckResult{}, + }, nil +} + func (p *TestProvider) Destroy( ctx context.Context, options provisioning.DestroyOptions, diff --git a/cli/azd/pkg/validate/gate_local_preflight.go b/cli/azd/pkg/validate/gate_local_preflight.go new file mode 100644 index 00000000000..7f4217420f5 --- /dev/null +++ b/cli/azd/pkg/validate/gate_local_preflight.go @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package validate + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" +) + +// LocalPreflightGate is a validation gate that runs the provisioning +// provider's local preflight checks. For Bicep projects this compiles +// the template, runs bicep snapshot, and executes checks for role +// assignments, AI model quota, and reserved resource names. +type LocalPreflightGate struct { + provisionManager *provisioning.Manager +} + +// NewLocalPreflightGate creates a local-preflight gate backed by the +// given provisioning manager. The manager must be initialized before +// the gate is run. +func NewLocalPreflightGate( + provisionManager *provisioning.Manager, +) *LocalPreflightGate { + return &LocalPreflightGate{ + provisionManager: provisionManager, + } +} + +// Name returns "local-preflight". +func (g *LocalPreflightGate) Name() string { + return "local-preflight" +} + +// Run initializes the provisioning manager and delegates to the +// provider's Validate method. The provider compiles the template, +// runs bicep snapshot, and executes local preflight checks. +func (g *LocalPreflightGate) Run( + ctx context.Context, pCtx *PipelineContext, +) (*GateResult, error) { + if pCtx.Project == nil { + return &GateResult{ + GateName: g.Name(), + Skipped: true, + SkipReason: "no project configuration loaded", + }, nil + } + + infraOptions := pCtx.Project.Infra + if err := g.provisionManager.Initialize( + ctx, pCtx.Project.Path, infraOptions, + ); err != nil { + return nil, fmt.Errorf( + "initializing provisioning manager: %w", err) + } + + validateResult, err := g.provisionManager.Validate(ctx) + if err != nil { + return nil, fmt.Errorf( + "running provider validation: %w", err) + } + + if validateResult.Skipped { + return &GateResult{ + GateName: g.Name(), + Skipped: true, + SkipReason: validateResult.SkipReason, + }, nil + } + + results := make([]CheckResult, len(validateResult.Results)) + for i, r := range validateResult.Results { + severity := CheckWarning + if r.Severity == provisioning.ValidateError { + severity = CheckError + } + + links := make([]ux.PreflightReportLink, len(r.Links)) + for j, l := range r.Links { + links[j] = ux.PreflightReportLink{ + URL: l.URL, + Title: l.Title, + } + } + + results[i] = CheckResult{ + Severity: severity, + DiagnosticID: r.DiagnosticID, + Message: r.Message, + Suggestion: r.Suggestion, + Links: links, + } + } + + return &GateResult{ + GateName: g.Name(), + Results: results, + }, nil +}