From 93456f76bef55449f22c3648fbce4632b1ef11ff Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 13:24:50 +0530 Subject: [PATCH 01/22] Validate command initial changes --- .../internal/cmd/job.go | 1 + .../internal/cmd/job_validate.go | 75 ++++++++++++ .../internal/utils/job_validator.go | 108 ++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go create mode 100644 cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job.go index a70514237e7..45baf7101b7 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job.go @@ -34,6 +34,7 @@ func newJobCommand() *cobra.Command { cmd.AddCommand(newJobShowCommand()) cmd.AddCommand(newJobDeleteCommand()) cmd.AddCommand(newJobCancelCommand()) + cmd.AddCommand(newJobValidateCommand()) return cmd } diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go new file mode 100644 index 00000000000..c837c21a98d --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + + "azure.ai.customtraining/internal/utils" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func newJobValidateCommand() *cobra.Command { + var filePath string + + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate a job YAML definition file without submitting", + Long: "Validate a job YAML definition file for schema correctness and surface issues early.\n\nExample:\n azd ai training job validate --file job.yaml", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if filePath == "" { + return fmt.Errorf("--file (-f) is required: provide a path to a YAML job definition file") + } + + // Read and parse the YAML file (without running ValidateJobDefinition which stops at first error) + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read job file '%s': %w", filePath, err) + } + + var jobDef utils.JobDefinition + if err := yaml.Unmarshal(data, &jobDef); err != nil { + return fmt.Errorf("failed to parse job YAML: %w", err) + } + + // Run offline validation — collects all findings + result := utils.ValidateJobOffline(&jobDef) + + // Print findings + if len(result.Findings) == 0 { + fmt.Printf("✓ Validation passed: %s\n", filePath) + return nil + } + + fmt.Printf("Validation results for: %s\n\n", filePath) + + for _, f := range result.Findings { + prefix := "⚠" + if f.Severity == utils.SeverityError { + prefix = "✗" + } + fmt.Printf(" %s [%s] %s: %s\n", prefix, f.Severity, f.Field, f.Message) + } + + fmt.Println() + fmt.Printf(" Errors: %d, Warnings: %d\n", result.ErrorCount(), result.WarningCount()) + + if result.HasErrors() { + fmt.Printf("\n✗ Validation failed.\n") + return fmt.Errorf("validation failed with %d error(s)", result.ErrorCount()) + } + + fmt.Printf("\n✓ Validation passed with warnings.\n") + return nil + }, + } + + cmd.Flags().StringVar(&filePath, "file", "", "Path to YAML job definition file (required)") + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go new file mode 100644 index 00000000000..d0a18bf647e --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "fmt" + "strings" +) + +// FindingSeverity indicates whether a finding is an error or a warning. +type FindingSeverity string + +const ( + SeverityError FindingSeverity = "Error" + SeverityWarning FindingSeverity = "Warning" +) + +// ValidationFinding represents a single validation issue found in a job definition. +type ValidationFinding struct { + Field string + Severity FindingSeverity + Message string +} + +// ValidationResult holds the overall result of job validation. +type ValidationResult struct { + Findings []ValidationFinding +} + +// HasErrors returns true if any finding is an error. +func (r *ValidationResult) HasErrors() bool { + for _, f := range r.Findings { + if f.Severity == SeverityError { + return true + } + } + return false +} + +// ErrorCount returns the number of error findings. +func (r *ValidationResult) ErrorCount() int { + count := 0 + for _, f := range r.Findings { + if f.Severity == SeverityError { + count++ + } + } + return count +} + +// WarningCount returns the number of warning findings. +func (r *ValidationResult) WarningCount() int { + count := 0 + for _, f := range r.Findings { + if f.Severity == SeverityWarning { + count++ + } + } + return count +} + +// ValidateJobOffline performs offline validation of a job definition. +// It returns all findings (errors and warnings) rather than stopping at the first error. +func ValidateJobOffline(job *JobDefinition) *ValidationResult { + result := &ValidationResult{} + + // 1. command field is required + if job.Command == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityError, + Message: "'command' is required", + }) + } + + // 2. environment field is required + if job.Environment == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "environment", + Severity: SeverityError, + Message: "'environment' is required", + }) + } + + // 3. compute field is required + if job.Compute == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "compute", + Severity: SeverityError, + Message: "'compute' is required", + }) + } + + // 4. code must not be a git path + if job.Code != "" { + lower := strings.ToLower(job.Code) + if strings.HasPrefix(lower, "git://") || strings.HasPrefix(lower, "git+") { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "code", + Severity: SeverityError, + Message: fmt.Sprintf("git paths are not supported for 'code': '%s'. Use a local path instead", job.Code), + }) + } + } + + return result +} From 53c598d08c094d66d215a98ed72f5ebabcab9321 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 13:35:52 +0530 Subject: [PATCH 02/22] Validate existence of local paths --- .../internal/cmd/job_validate.go | 4 ++- .../internal/utils/job_validator.go | 35 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index c837c21a98d..f1c93a7474b 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "azure.ai.customtraining/internal/utils" @@ -38,7 +39,8 @@ func newJobValidateCommand() *cobra.Command { } // Run offline validation — collects all findings - result := utils.ValidateJobOffline(&jobDef) + yamlDir := filepath.Dir(filePath) + result := utils.ValidateJobOffline(&jobDef, yamlDir) // Print findings if len(result.Findings) == 0 { diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index d0a18bf647e..461e633479e 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -5,6 +5,8 @@ package utils import ( "fmt" + "os" + "path/filepath" "strings" ) @@ -61,8 +63,9 @@ func (r *ValidationResult) WarningCount() int { } // ValidateJobOffline performs offline validation of a job definition. +// yamlDir is the directory containing the YAML file, used to resolve relative paths. // It returns all findings (errors and warnings) rather than stopping at the first error. -func ValidateJobOffline(job *JobDefinition) *ValidationResult { +func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { result := &ValidationResult{} // 1. command field is required @@ -104,5 +107,35 @@ func ValidateJobOffline(job *JobDefinition) *ValidationResult { } } + // 5. Local path existence checks + validateLocalPath(result, "code", job.Code, yamlDir) + for name, input := range job.Inputs { + if input.Value == "" { + validateLocalPath(result, fmt.Sprintf("inputs.%s.path", name), input.Path, yamlDir) + } + } + return result } + +// validateLocalPath checks that a local path exists on disk. +// Remote URIs (azureml://, https://, http://) and empty paths are skipped. +func validateLocalPath(result *ValidationResult, field string, path string, yamlDir string) { + if path == "" || IsRemoteURI(path) { + return + } + + // Resolve relative paths against the YAML file directory + resolved := path + if !filepath.IsAbs(path) { + resolved = filepath.Join(yamlDir, path) + } + + if _, err := os.Stat(resolved); os.IsNotExist(err) { + result.Findings = append(result.Findings, ValidationFinding{ + Field: field, + Severity: SeverityError, + Message: fmt.Sprintf("local path does not exist: '%s'", path), + }) + } +} From 13825ce0a034d3648fceeedb4242c823a5f963f5 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 15:44:08 +0530 Subject: [PATCH 03/22] Add validation for placeholder mapping --- .../internal/utils/job_validator.go | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 461e633479e..1296dc66b0c 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" ) @@ -115,6 +116,21 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { } } + // 6. Validate ${{inputs.xxx}} and ${{outputs.xxx}} placeholders in command + if job.Command != "" { + validatePlaceholders(result, job) + } + + // 7. Warn on single-brace {inputs.xxx} or {outputs.xxx} usage in command + if job.Command != "" { + validateSingleBracePlaceholders(result, job.Command) + } + + // 8. Inputs/outputs with nil/empty definitions referenced in command + if job.Command != "" { + validateInputOutputDefinitions(result, job) + } + return result } @@ -139,3 +155,120 @@ func validateLocalPath(result *ValidationResult, field string, path string, yaml }) } } + +// Regex patterns for placeholder validation. +var ( + // Matches ${{inputs.key}} or ${{outputs.key}} — captures "inputs" or "outputs" and the key name. + placeholderRegex = regexp.MustCompile(`\$\{\{(inputs|outputs)\.(\w[\w.-]*)}}`) + + // Matches optional blocks: [...] (content between square brackets). + optionalBlockRegex = regexp.MustCompile(`\[[^\]]*]`) + + // Matches ${{inputs.key}} — used to extract input keys from optional blocks. + inputPlaceholderRegex = regexp.MustCompile(`\$\{\{inputs\.(\w[\w.-]*)}}`) + + // Matches single-brace {inputs.key} or {outputs.key} that are NOT preceded by $ or another {. + // Uses a negative lookbehind approximation: we check matches and filter in code. + singleBraceRegex = regexp.MustCompile(`\{(inputs|outputs)\.(\w[\w.-]*)}}?`) +) + +// validatePlaceholders checks that ${{inputs.xxx}} references in command exist in job.Inputs +// and ${{outputs.xxx}} references exist in job.Outputs. +// References inside [...] optional blocks are skipped for inputs. +func validatePlaceholders(result *ValidationResult, job *JobDefinition) { + command := job.Command + + // Build set of optional input keys (those inside [...] blocks) + optionalInputs := make(map[string]bool) + for _, block := range optionalBlockRegex.FindAllString(command, -1) { + for _, match := range inputPlaceholderRegex.FindAllStringSubmatch(block, -1) { + optionalInputs[match[1]] = true + } + } + + // Find all ${{inputs.xxx}} and ${{outputs.xxx}} references + for _, match := range placeholderRegex.FindAllStringSubmatch(command, -1) { + kind := match[1] // "inputs" or "outputs" + key := match[2] + + if kind == "inputs" { + if optionalInputs[key] { + continue // skip optional inputs + } + if job.Inputs == nil { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityError, + Message: fmt.Sprintf("command references '${{inputs.%s}}' but no inputs are defined", key), + }) + } else if _, exists := job.Inputs[key]; !exists { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityError, + Message: fmt.Sprintf("command references '${{inputs.%s}}' but '%s' is not defined in inputs", key, key), + }) + } + } else if kind == "outputs" { + if job.Outputs == nil { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityWarning, + Message: fmt.Sprintf("command references '${{outputs.%s}}' but no outputs are defined", key), + }) + } else if _, exists := job.Outputs[key]; !exists { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityWarning, + Message: fmt.Sprintf("command references '${{outputs.%s}}' but '%s' is not defined in outputs", key, key), + }) + } + } + } +} + +// validateSingleBracePlaceholders flags when the command uses {inputs.xxx} or {outputs.xxx} +// instead of the correct ${{inputs.xxx}} syntax. This is an error because the backend +// will not resolve single-brace placeholders. +func validateSingleBracePlaceholders(result *ValidationResult, command string) { + for _, match := range singleBraceRegex.FindAllStringSubmatchIndex(command, -1) { + start := match[0] + // Skip if this is already part of a ${{...}} (preceded by "${") + if start >= 2 && command[start-2:start] == "${" { + continue + } + if start >= 1 && command[start-1:start] == "$" { + continue + } + + kind := command[match[2]:match[3]] + key := command[match[4]:match[5]] + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityError, + Message: fmt.Sprintf("command uses single-brace '{%s.%s}' — use '${{%s.%s}}' instead", kind, key, kind, key), + }) + } +} + +// validateInputOutputDefinitions checks that inputs referenced in command +// are not empty/nil definitions (all fields zero-valued). +func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition) { + command := job.Command + + for _, match := range placeholderRegex.FindAllStringSubmatch(command, -1) { + kind := match[1] + key := match[2] + + if kind == "inputs" && job.Inputs != nil { + if input, exists := job.Inputs[key]; exists { + if (input == InputDefinition{}) { + result.Findings = append(result.Findings, ValidationFinding{ + Field: fmt.Sprintf("inputs.%s", key), + Severity: SeverityError, + Message: fmt.Sprintf("input '%s' is referenced in command but has an empty definition", key), + }) + } + } + } + } +} From e2318a4b514e8870b338126ce0e4df0e162d04b2 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 15:44:33 +0530 Subject: [PATCH 04/22] Add unit tests --- .../internal/utils/job_validator_test.go | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go new file mode 100644 index 00000000000..515556d77f5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -0,0 +1,339 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func validJob() *JobDefinition { + return &JobDefinition{ + Command: "python train.py", + Environment: "azureml:my-env:1", + Compute: "gpu-cluster", + } +} + +func findFindingByMessage(result *ValidationResult, substr string) *ValidationFinding { + for _, f := range result.Findings { + if strings.Contains(f.Message, substr) { + return &f + } + } + return nil +} + +// Tests required fields and a fully valid job with all common YAML patterns. +func TestValidate_RequiredFieldsAndValidJob(t *testing.T) { + // YAML with nothing — all required fields missing: + // (empty file) + empty := &JobDefinition{} + result := ValidateJobOffline(empty, ".") + if result.ErrorCount() < 3 { + t.Errorf("expected at least 3 errors (command, environment, compute), got %d", result.ErrorCount()) + } + + // Realistic valid YAML: + // command: >- + // python train.py + // --data ${{inputs.training_data}} + // --out ${{outputs.model}} + // environment: azureml://registries/azureml/environments/sklearn-1.5/labels/latest + // compute: azureml:gpu-cluster + // code: azureml://registries/mycode + // inputs: + // training_data: + // type: uri_folder + // path: azureml://datastores/workspaceblobstore/paths/data/train + // outputs: + // model: + // type: uri_folder + job := validJob() + job.Command = "python train.py --data ${{inputs.training_data}} --out ${{outputs.model}}" + job.Code = "azureml://registries/mycode" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/workspaceblobstore/paths/data/train"}, + } + job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} + result = ValidateJobOffline(job, ".") + if result.HasErrors() { + for _, f := range result.Findings { + t.Errorf("unexpected finding: [%s] %s: %s", f.Severity, f.Field, f.Message) + } + } +} + +// Tests git code paths are rejected, normal code paths accepted. +func TestValidate_GitPaths(t *testing.T) { + // YAML: code: git://github.com/org/repo — rejected + // YAML: code: git+https://github.com/org/repo — rejected + for _, code := range []string{"git://github.com/repo", "git+https://github.com/repo", "GIT://github.com/repo"} { + job := validJob() + job.Code = code + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "git paths are not supported"); f == nil { + t.Errorf("expected git path error for code=%q", code) + } + } + + // YAML: code: ./src — accepted (local) + // YAML: code: azureml://datastores/blob/paths/code — accepted (remote) + for _, code := range []string{"./src", "azureml://datastores/blob/paths/code"} { + job := validJob() + job.Code = code + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "git paths are not supported"); f != nil { + t.Errorf("did not expect git path error for code=%q", code) + } + } +} + +// Tests local path existence for code and input paths. +func TestValidate_LocalPaths(t *testing.T) { + dir := t.TempDir() + os.Mkdir(filepath.Join(dir, "src"), 0o755) + + // YAML: code: src — src/ exists on disk → no error + job := validJob() + job.Code = "src" + result := ValidateJobOffline(job, dir) + if f := findFindingByMessage(result, "local path does not exist"); f != nil { + t.Error("did not expect error when src dir exists") + } + + // YAML: code: nonexistent — does not exist → error + job = validJob() + job.Code = "nonexistent" + result = ValidateJobOffline(job, dir) + if f := findFindingByMessage(result, "local path does not exist: 'nonexistent'"); f == nil { + t.Error("expected error for missing local code path") + } + + // YAML: code: azureml://datastores/blob/paths/src — remote URI, skip check + job = validJob() + job.Code = "azureml://datastores/blob/paths/src" + result = ValidateJobOffline(job, dir) + if f := findFindingByMessage(result, "local path does not exist"); f != nil { + t.Error("did not expect error for remote code path") + } + + // YAML: + // inputs: + // training_data: + // type: uri_folder + // path: nonexistent_data ← local path missing → error + // pretrained_model: + // type: uri_folder + // path: azureml://datastores/blob/data ← remote → skipped + // epochs: + // value: "10" ← literal value, no path → skipped + job = validJob() + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "nonexistent_data"}, + "pretrained_model": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + "epochs": {Value: "10"}, + } + result = ValidateJobOffline(job, dir) + if f := findFindingByMessage(result, "'nonexistent_data'"); f == nil { + t.Error("expected error for missing input local path") + } + if f := findFindingByMessage(result, "pretrained_model"); f != nil { + t.Error("did not expect error for remote input path") + } +} + +// Tests ${{inputs.xxx}} and ${{outputs.xxx}} placeholder validation in command. +func TestValidate_PlaceholderMapping(t *testing.T) { + // YAML — all placeholders map correctly: + // command: python train.py --data ${{inputs.training_data}} --out ${{outputs.model}} + // inputs: + // training_data: + // type: uri_folder + // path: azureml://datastores/blob/data + // outputs: + // model: + // type: uri_folder + job := validJob() + job.Command = "python train.py --data ${{inputs.training_data}} --out ${{outputs.model}}" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + } + job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "not defined"); f != nil { + t.Errorf("did not expect error for mapped placeholders: %s", f.Message) + } + + // YAML — typos in placeholder keys: + // command: >- + // python train.py + // --data ${{inputs.training_data}} + // --val ${{inputs.validation_data}} ← "validation_data" NOT in inputs → error + // --out ${{outputs.model_output}} ← "model_output" NOT in outputs → warning + // inputs: + // training_data: + // type: uri_folder + // path: azureml://datastores/blob/data + // outputs: + // model: ← key is "model", not "model_output" + // type: uri_folder + job = validJob() + job.Command = "python train.py --data ${{inputs.training_data}} --val ${{inputs.validation_data}} --out ${{outputs.model_output}}" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + } + job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "'validation_data' is not defined in inputs"); f == nil || f.Severity != SeverityError { + t.Error("expected error for unmapped input 'validation_data'") + } + if f := findFindingByMessage(result, "'model_output' is not defined in outputs"); f == nil || f.Severity != SeverityWarning { + t.Error("expected warning for unmapped output 'model_output'") + } + + // YAML — placeholders but no inputs/outputs section at all: + // command: python train.py --data ${{inputs.data}} --out ${{outputs.model}} + // (no inputs: or outputs: defined) + job = validJob() + job.Command = "python train.py --data ${{inputs.data}} --out ${{outputs.model}}" + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "no inputs are defined"); f == nil { + t.Error("expected error when inputs section missing entirely") + } + if f := findFindingByMessage(result, "no outputs are defined"); f == nil { + t.Error("expected warning when outputs section missing entirely") + } + + // YAML — optional inputs inside [...] brackets: + // command: >- + // python train.py + // --data ${{inputs.training_data}} + // [--val ${{inputs.validation_data}} --lr ${{inputs.learning_rate}}] + // inputs: + // training_data: + // type: uri_folder + // path: azureml://datastores/blob/data + // (validation_data and learning_rate NOT defined — but inside [] so OK) + job = validJob() + job.Command = "python train.py --data ${{inputs.training_data}} [--val ${{inputs.validation_data}} --lr ${{inputs.learning_rate}}]" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + } + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "validation_data"); f != nil { + t.Error("did not expect error for optional validation_data inside brackets") + } + if f := findFindingByMessage(result, "learning_rate"); f != nil { + t.Error("did not expect error for optional learning_rate inside brackets") + } +} + +// Tests single-brace {inputs.xxx} is flagged as error (backend won't resolve it). +func TestValidate_SingleBracePlaceholders(t *testing.T) { + // YAML (incorrect): + // command: python train.py --data {inputs.training_data} --out {outputs.model} + // Should be: ${{inputs.training_data}} and ${{outputs.model}} + job := validJob() + job.Command = "python train.py --data {inputs.training_data} --out {outputs.model}" + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "single-brace '{inputs.training_data}'"); f == nil || f.Severity != SeverityError { + t.Error("expected error for single-brace input placeholder") + } + if f := findFindingByMessage(result, "single-brace '{outputs.model}'"); f == nil || f.Severity != SeverityError { + t.Error("expected error for single-brace output placeholder") + } + + // YAML (correct): + // command: python train.py --data ${{inputs.training_data}} + // Correct ${{...}} should NOT trigger single-brace error + job = validJob() + job.Command = "python train.py --data ${{inputs.training_data}}" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + } + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "single-brace"); f != nil { + t.Error("did not expect single-brace error for correct ${{...}} syntax") + } +} + +// Tests input with empty definition (equivalent to Python None) is flagged. +func TestValidate_EmptyInputDefinition(t *testing.T) { + // YAML — input key exists but has no properties (None): + // command: python train.py --data ${{inputs.training_data}} + // inputs: + // training_data: ← key present but empty definition + job := validJob() + job.Command = "python train.py --data ${{inputs.training_data}}" + job.Inputs = map[string]InputDefinition{"training_data": {}} + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "has an empty definition"); f == nil || f.Severity != SeverityError { + t.Error("expected error for empty input definition") + } + + // YAML — inputs with at least one field set are NOT empty: + // inputs: + // data1: { type: uri_folder } + // epochs: { value: "10" } + // data2: { path: azureml://datastores/blob/data } + for _, input := range []InputDefinition{{Type: "uri_folder"}, {Value: "10"}, {Path: "azureml://x"}} { + job = validJob() + job.Command = "python train.py --x ${{inputs.x}}" + job.Inputs = map[string]InputDefinition{"x": input} + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "has an empty definition"); f != nil { + t.Errorf("did not expect empty error for input %+v", input) + } + } + + // YAML — empty output definition is VALID (backend auto-provisions uri_folder + rw_mount): + // command: python train.py --out ${{outputs.model}} + // outputs: + // model: ← empty is fine for outputs + job = validJob() + job.Command = "python train.py --out ${{outputs.model}}" + job.Outputs = map[string]OutputDefinition{"model": {}} + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "model"); f != nil { + t.Errorf("did not expect error/warning for empty output definition: %s", f.Message) + } +} + +// Tests multiline commands (YAML | and >- both resolve correctly after unmarshal). +func TestValidate_MultilineCommand(t *testing.T) { + // YAML with pipe (|) — newlines preserved: + // command: | + // python train.py + // --data ${{inputs.training_data}} + // --out ${{outputs.model}} + // After unmarshal: "python train.py\n--data ${{inputs.training_data}}\n--out ${{outputs.model}}\n" + job := validJob() + job.Command = "python train.py\n--data ${{inputs.training_data}}\n--out ${{outputs.model}}\n" + job.Inputs = map[string]InputDefinition{ + "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, + } + job.Outputs = map[string]OutputDefinition{"model": {}} + result := ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "not defined"); f != nil { + t.Errorf("did not expect error for multiline command: %s", f.Message) + } +} + +func TestValidationResult_HasErrorsAndCounts(t *testing.T) { + r := &ValidationResult{} + if r.HasErrors() { + t.Error("expected no errors on empty result") + } + r.Findings = append(r.Findings, ValidationFinding{Severity: SeverityWarning}) + if r.HasErrors() { + t.Error("warnings should not count as errors") + } + r.Findings = append(r.Findings, ValidationFinding{Severity: SeverityError}, ValidationFinding{Severity: SeverityError}) + if r.ErrorCount() != 2 || r.WarningCount() != 1 { + t.Errorf("expected 2 errors, 1 warning, got %d errors, %d warnings", r.ErrorCount(), r.WarningCount()) + } +} From 09f3d12441916bcd39ceae0f15298cf317ac7220 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 17:41:31 +0530 Subject: [PATCH 05/22] Show warning if output definition is empty --- .../internal/utils/job_validator.go | 13 ++++++++++++- .../internal/utils/job_validator_test.go | 8 ++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 1296dc66b0c..32526e3b106 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -250,8 +250,9 @@ func validateSingleBracePlaceholders(result *ValidationResult, command string) { } } -// validateInputOutputDefinitions checks that inputs referenced in command +// validateInputOutputDefinitions checks that inputs/outputs referenced in command // are not empty/nil definitions (all fields zero-valued). +// Empty inputs are errors; empty outputs are warnings (backend uses defaults). func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition) { command := job.Command @@ -269,6 +270,16 @@ func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition }) } } + } else if kind == "outputs" && job.Outputs != nil { + if output, exists := job.Outputs[key]; exists { + if (output == OutputDefinition{}) { + result.Findings = append(result.Findings, ValidationFinding{ + Field: fmt.Sprintf("outputs.%s", key), + Severity: SeverityWarning, + Message: fmt.Sprintf("output '%s' has an empty definition — default values will be used", key), + }) + } + } } } } diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go index 515556d77f5..e6d957dd101 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -290,16 +290,16 @@ func TestValidate_EmptyInputDefinition(t *testing.T) { } } - // YAML — empty output definition is VALID (backend auto-provisions uri_folder + rw_mount): + // YAML — empty output definition shows warning (backend defaults to uri_folder + rw_mount): // command: python train.py --out ${{outputs.model}} // outputs: - // model: ← empty is fine for outputs + // model: ← empty definition → warning job = validJob() job.Command = "python train.py --out ${{outputs.model}}" job.Outputs = map[string]OutputDefinition{"model": {}} result = ValidateJobOffline(job, ".") - if f := findFindingByMessage(result, "model"); f != nil { - t.Errorf("did not expect error/warning for empty output definition: %s", f.Message) + if f := findFindingByMessage(result, "default values will be used"); f == nil || f.Severity != SeverityWarning { + t.Error("expected warning for empty output definition") } } From 41d0a5f34dbed09d1da6b43c5e0a3586fc788db0 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 18:06:45 +0530 Subject: [PATCH 06/22] Nit --- .../internal/utils/job_validator_test.go | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go index e6d957dd101..b0b72ddabad 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -261,12 +261,12 @@ func TestValidate_SingleBracePlaceholders(t *testing.T) { } } -// Tests input with empty definition (equivalent to Python None) is flagged. -func TestValidate_EmptyInputDefinition(t *testing.T) { +// Tests empty input/output definitions (equivalent to Python None) are flagged. +func TestValidate_EmptyDefinitions(t *testing.T) { // YAML — input key exists but has no properties (None): // command: python train.py --data ${{inputs.training_data}} // inputs: - // training_data: ← key present but empty definition + // training_data: ← key present but empty definition → error job := validJob() job.Command = "python train.py --data ${{inputs.training_data}}" job.Inputs = map[string]InputDefinition{"training_data": {}} @@ -275,25 +275,10 @@ func TestValidate_EmptyInputDefinition(t *testing.T) { t.Error("expected error for empty input definition") } - // YAML — inputs with at least one field set are NOT empty: - // inputs: - // data1: { type: uri_folder } - // epochs: { value: "10" } - // data2: { path: azureml://datastores/blob/data } - for _, input := range []InputDefinition{{Type: "uri_folder"}, {Value: "10"}, {Path: "azureml://x"}} { - job = validJob() - job.Command = "python train.py --x ${{inputs.x}}" - job.Inputs = map[string]InputDefinition{"x": input} - result = ValidateJobOffline(job, ".") - if f := findFindingByMessage(result, "has an empty definition"); f != nil { - t.Errorf("did not expect empty error for input %+v", input) - } - } - - // YAML — empty output definition shows warning (backend defaults to uri_folder + rw_mount): + // YAML — output key exists but has no properties (None): // command: python train.py --out ${{outputs.model}} // outputs: - // model: ← empty definition → warning + // model: ← empty definition → warning (backend uses defaults) job = validJob() job.Command = "python train.py --out ${{outputs.model}}" job.Outputs = map[string]OutputDefinition{"model": {}} From 85d15220063c4de717f728bcc0164e05cc717797 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 18:27:48 +0530 Subject: [PATCH 07/22] Nit --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index f1c93a7474b..f44bfa158d8 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -19,12 +19,11 @@ func newJobValidateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "validate", - Short: "Validate a job YAML definition file without submitting", - Long: "Validate a job YAML definition file for schema correctness and surface issues early.\n\nExample:\n azd ai training job validate --file job.yaml", + Short: "Validate a job YAML definition file offline without submitting", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if filePath == "" { - return fmt.Errorf("--file (-f) is required: provide a path to a YAML job definition file") + return fmt.Errorf("--file is required: provide a path to a YAML job definition file") } // Read and parse the YAML file (without running ValidateJobDefinition which stops at first error) From 9baa33b1c4f63257d7d1b855466ff2604be0efe5 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 18:32:21 +0530 Subject: [PATCH 08/22] Nit --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index f44bfa158d8..90e40664540 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -26,7 +26,7 @@ func newJobValidateCommand() *cobra.Command { return fmt.Errorf("--file is required: provide a path to a YAML job definition file") } - // Read and parse the YAML file (without running ValidateJobDefinition which stops at first error) + // Read and parse the YAML file data, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read job file '%s': %w", filePath, err) From 302e3f30c1848e018b18f5cc7060c52bf242c2e5 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 18:43:59 +0530 Subject: [PATCH 09/22] Remove duplicate test --- .../internal/utils/job_validator_test.go | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go index b0b72ddabad..d6fe1353b62 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -148,26 +148,6 @@ func TestValidate_LocalPaths(t *testing.T) { // Tests ${{inputs.xxx}} and ${{outputs.xxx}} placeholder validation in command. func TestValidate_PlaceholderMapping(t *testing.T) { - // YAML — all placeholders map correctly: - // command: python train.py --data ${{inputs.training_data}} --out ${{outputs.model}} - // inputs: - // training_data: - // type: uri_folder - // path: azureml://datastores/blob/data - // outputs: - // model: - // type: uri_folder - job := validJob() - job.Command = "python train.py --data ${{inputs.training_data}} --out ${{outputs.model}}" - job.Inputs = map[string]InputDefinition{ - "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, - } - job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} - result := ValidateJobOffline(job, ".") - if f := findFindingByMessage(result, "not defined"); f != nil { - t.Errorf("did not expect error for mapped placeholders: %s", f.Message) - } - // YAML — typos in placeholder keys: // command: >- // python train.py @@ -181,13 +161,13 @@ func TestValidate_PlaceholderMapping(t *testing.T) { // outputs: // model: ← key is "model", not "model_output" // type: uri_folder - job = validJob() + job := validJob() job.Command = "python train.py --data ${{inputs.training_data}} --val ${{inputs.validation_data}} --out ${{outputs.model_output}}" job.Inputs = map[string]InputDefinition{ "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, } job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} - result = ValidateJobOffline(job, ".") + result := ValidateJobOffline(job, ".") if f := findFindingByMessage(result, "'validation_data' is not defined in inputs"); f == nil || f.Severity != SeverityError { t.Error("expected error for unmapped input 'validation_data'") } From b6c0f35367bcebb3c06a18ba1b13c4ece4bda80d Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 18:54:09 +0530 Subject: [PATCH 10/22] Remove warning if outputs key itself is missing --- .../internal/utils/job_validator.go | 15 +---------- .../internal/utils/job_validator_test.go | 26 ++++++------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 32526e3b106..e9fed089fcc 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -191,6 +191,7 @@ func validatePlaceholders(result *ValidationResult, job *JobDefinition) { kind := match[1] // "inputs" or "outputs" key := match[2] + // Only validate input placeholders — outputs are auto-provisioned by the backend if kind == "inputs" { if optionalInputs[key] { continue // skip optional inputs @@ -208,20 +209,6 @@ func validatePlaceholders(result *ValidationResult, job *JobDefinition) { Message: fmt.Sprintf("command references '${{inputs.%s}}' but '%s' is not defined in inputs", key, key), }) } - } else if kind == "outputs" { - if job.Outputs == nil { - result.Findings = append(result.Findings, ValidationFinding{ - Field: "command", - Severity: SeverityWarning, - Message: fmt.Sprintf("command references '${{outputs.%s}}' but no outputs are defined", key), - }) - } else if _, exists := job.Outputs[key]; !exists { - result.Findings = append(result.Findings, ValidationFinding{ - Field: "command", - Severity: SeverityWarning, - Message: fmt.Sprintf("command references '${{outputs.%s}}' but '%s' is not defined in outputs", key, key), - }) - } } } } diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go index d6fe1353b62..db6a0bad3d6 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -146,47 +146,37 @@ func TestValidate_LocalPaths(t *testing.T) { } } -// Tests ${{inputs.xxx}} and ${{outputs.xxx}} placeholder validation in command. +// Tests ${{inputs.xxx}} placeholder validation in command. +// Output placeholders are NOT validated here — outputs are auto-provisioned by the backend. func TestValidate_PlaceholderMapping(t *testing.T) { - // YAML — typos in placeholder keys: + // YAML — typo in input placeholder key: // command: >- // python train.py // --data ${{inputs.training_data}} // --val ${{inputs.validation_data}} ← "validation_data" NOT in inputs → error - // --out ${{outputs.model_output}} ← "model_output" NOT in outputs → warning // inputs: // training_data: // type: uri_folder // path: azureml://datastores/blob/data - // outputs: - // model: ← key is "model", not "model_output" - // type: uri_folder job := validJob() - job.Command = "python train.py --data ${{inputs.training_data}} --val ${{inputs.validation_data}} --out ${{outputs.model_output}}" + job.Command = "python train.py --data ${{inputs.training_data}} --val ${{inputs.validation_data}}" job.Inputs = map[string]InputDefinition{ "training_data": {Type: "uri_folder", Path: "azureml://datastores/blob/data"}, } - job.Outputs = map[string]OutputDefinition{"model": {Type: "uri_folder"}} result := ValidateJobOffline(job, ".") if f := findFindingByMessage(result, "'validation_data' is not defined in inputs"); f == nil || f.Severity != SeverityError { t.Error("expected error for unmapped input 'validation_data'") } - if f := findFindingByMessage(result, "'model_output' is not defined in outputs"); f == nil || f.Severity != SeverityWarning { - t.Error("expected warning for unmapped output 'model_output'") - } - // YAML — placeholders but no inputs/outputs section at all: - // command: python train.py --data ${{inputs.data}} --out ${{outputs.model}} - // (no inputs: or outputs: defined) + // YAML — input placeholders but no inputs section at all: + // command: python train.py --data ${{inputs.data}} + // (no inputs: defined) job = validJob() - job.Command = "python train.py --data ${{inputs.data}} --out ${{outputs.model}}" + job.Command = "python train.py --data ${{inputs.data}}" result = ValidateJobOffline(job, ".") if f := findFindingByMessage(result, "no inputs are defined"); f == nil { t.Error("expected error when inputs section missing entirely") } - if f := findFindingByMessage(result, "no outputs are defined"); f == nil { - t.Error("expected warning when outputs section missing entirely") - } // YAML — optional inputs inside [...] brackets: // command: >- From bd5fc287bda3296ff94c51dbcec87f1275583ad1 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 31 Mar 2026 19:50:26 +0530 Subject: [PATCH 11/22] Add more tests --- .../internal/utils/job_validator_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go index db6a0bad3d6..2afb7bb368b 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -178,6 +178,16 @@ func TestValidate_PlaceholderMapping(t *testing.T) { t.Error("expected error when inputs section missing entirely") } + // YAML — output placeholders but no outputs section at all: + // command: python train.py --out ${{outputs.model}} + // (no outputs: defined — backend auto-provisions, so no error or warning) + job = validJob() + job.Command = "python train.py --out ${{outputs.model}}" + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "outputs"); f != nil { + t.Errorf("did not expect any output finding when outputs section missing: %s", f.Message) + } + // YAML — optional inputs inside [...] brackets: // command: >- // python train.py @@ -217,6 +227,16 @@ func TestValidate_SingleBracePlaceholders(t *testing.T) { t.Error("expected error for single-brace output placeholder") } + // YAML (incorrect) — single-brace inside [...] brackets: + // command: python train.py [--data {inputs.optional_data}] + // Single-brace is wrong syntax even inside optional blocks → still error + job = validJob() + job.Command = "python train.py [--data {inputs.optional_data}]" + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "single-brace '{inputs.optional_data}'"); f == nil || f.Severity != SeverityError { + t.Error("expected error for single-brace inside optional brackets") + } + // YAML (correct): // command: python train.py --data ${{inputs.training_data}} // Correct ${{...}} should NOT trigger single-brace error From 7a64359e743b6902e77a5cb39ad668898a422aac Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 10:37:57 +0530 Subject: [PATCH 12/22] Override PersistentPreRunE for validate command as it's offline command --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index 90e40664540..83c3de716e8 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -20,7 +20,9 @@ func newJobValidateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "validate", Short: "Validate a job YAML definition file offline without submitting", - Args: cobra.NoArgs, + // Override parent's PersistentPreRunE — validate is offline and needs no Azure setup. + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { return nil }, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if filePath == "" { return fmt.Errorf("--file is required: provide a path to a YAML job definition file") From af3ae7e5e71603a64244ccdcd2cd307d9992190f Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 11:12:28 +0530 Subject: [PATCH 13/22] Add git paths to remote URI list --- .../azure.ai.customtraining/internal/utils/yaml_parser.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go index f76bc39a862..f40fbee42db 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go @@ -96,7 +96,9 @@ func IsRemoteURI(s string) bool { lower := strings.ToLower(s) return strings.HasPrefix(lower, "azureml://") || strings.HasPrefix(lower, "https://") || - strings.HasPrefix(lower, "http://") + strings.HasPrefix(lower, "http://") || + strings.HasPrefix(lower, "git://") || + strings.HasPrefix(lower, "git+") } // ValidateJobDefinition checks that required fields are present. From 5a2d25dde689fd0b82ba07175c58e3510997ccf5 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 11:16:16 +0530 Subject: [PATCH 14/22] Add shorthand for file flag in validate command --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index 83c3de716e8..3bf60d2d526 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -72,7 +72,7 @@ func newJobValidateCommand() *cobra.Command { }, } - cmd.Flags().StringVar(&filePath, "file", "", "Path to YAML job definition file (required)") + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Path to YAML job definition file (required)") return cmd } From 115279fea6d3ecc339ddb98f369ba6a30b316c74 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 11:43:04 +0530 Subject: [PATCH 15/22] Emit warning when local path cannot be verified due to missing file permission --- .../azure.ai.customtraining/internal/utils/job_validator.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index e9fed089fcc..c73a8b08ec0 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -153,6 +153,12 @@ func validateLocalPath(result *ValidationResult, field string, path string, yaml Severity: SeverityError, Message: fmt.Sprintf("local path does not exist: '%s'", path), }) + } else if err != nil { + result.Findings = append(result.Findings, ValidationFinding{ + Field: field, + Severity: SeverityWarning, + Message: fmt.Sprintf("could not verify path exists: '%s': %v", path, err), + }) } } From 9fb6f5e8889340e5c3adfc899e81c224c04d1420 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 12:08:59 +0530 Subject: [PATCH 16/22] Fix validateSingleBracePlaceholders --- .../azure.ai.customtraining/internal/utils/job_validator.go | 3 --- .../azure.ai.customtraining/internal/utils/yaml_parser.go | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index c73a8b08ec0..e08660fdaea 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -229,9 +229,6 @@ func validateSingleBracePlaceholders(result *ValidationResult, command string) { if start >= 2 && command[start-2:start] == "${" { continue } - if start >= 1 && command[start-1:start] == "$" { - continue - } kind := command[match[2]:match[3]] key := command[match[4]:match[5]] diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go index f40fbee42db..7c532b13ca1 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/yaml_parser.go @@ -98,7 +98,7 @@ func IsRemoteURI(s string) bool { strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "git://") || - strings.HasPrefix(lower, "git+") + strings.HasPrefix(lower, "git+") } // ValidateJobDefinition checks that required fields are present. From 0ba174cdae625f1bad5822d1cad6e553826100bf Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 12:39:26 +0530 Subject: [PATCH 17/22] Nit --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index 3bf60d2d526..4abf04bcc7d 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -63,7 +63,6 @@ func newJobValidateCommand() *cobra.Command { fmt.Printf(" Errors: %d, Warnings: %d\n", result.ErrorCount(), result.WarningCount()) if result.HasErrors() { - fmt.Printf("\n✗ Validation failed.\n") return fmt.Errorf("validation failed with %d error(s)", result.ErrorCount()) } From 9972a484bca4c895e67efe9df6c27691159372a0 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 12:42:41 +0530 Subject: [PATCH 18/22] Nit: update error message --- .../azure.ai.customtraining/internal/utils/job_validator.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index e08660fdaea..24da84eec87 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -74,7 +74,7 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { result.Findings = append(result.Findings, ValidationFinding{ Field: "command", Severity: SeverityError, - Message: "'command' is required", + Message: "required field is missing", }) } @@ -83,7 +83,7 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { result.Findings = append(result.Findings, ValidationFinding{ Field: "environment", Severity: SeverityError, - Message: "'environment' is required", + Message: "required field is missing", }) } @@ -92,7 +92,7 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { result.Findings = append(result.Findings, ValidationFinding{ Field: "compute", Severity: SeverityError, - Message: "'compute' is required", + Message: "required field is missing", }) } From 5b7ae17b6b21e432f806c7bbde796f550b3027b8 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 12:45:53 +0530 Subject: [PATCH 19/22] Nit: Print success message in green --- .../azure.ai.customtraining/internal/cmd/job_validate.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go index 4abf04bcc7d..3b771e42c87 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -10,6 +10,7 @@ import ( "azure.ai.customtraining/internal/utils" + "github.com/fatih/color" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -45,7 +46,7 @@ func newJobValidateCommand() *cobra.Command { // Print findings if len(result.Findings) == 0 { - fmt.Printf("✓ Validation passed: %s\n", filePath) + color.Green("✓ Validation passed: %s\n", filePath) return nil } @@ -66,7 +67,7 @@ func newJobValidateCommand() *cobra.Command { return fmt.Errorf("validation failed with %d error(s)", result.ErrorCount()) } - fmt.Printf("\n✓ Validation passed with warnings.\n") + color.Green("\n✓ Validation passed with warnings.\n") return nil }, } From fb575ef5648cb596b095f9f71aebcabe4ec7cf30 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 12:52:51 +0530 Subject: [PATCH 20/22] Nit: Update validation message --- .../azure.ai.customtraining/internal/utils/job_validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 24da84eec87..920d0b1c5f5 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -103,7 +103,7 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { result.Findings = append(result.Findings, ValidationFinding{ Field: "code", Severity: SeverityError, - Message: fmt.Sprintf("git paths are not supported for 'code': '%s'. Use a local path instead", job.Code), + Message: fmt.Sprintf("git paths are not supported"), }) } } From d6f9caeccc88ed5ad6635ca6c54627b505dc96c0 Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 15:08:50 +0530 Subject: [PATCH 21/22] Handle edge cases for optional inputs --- .../internal/utils/job_validator.go | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 920d0b1c5f5..74c13bb0b58 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -117,8 +117,10 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { } // 6. Validate ${{inputs.xxx}} and ${{outputs.xxx}} placeholders in command + var optionalInputs map[string]bool if job.Command != "" { - validatePlaceholders(result, job) + optionalInputs = optionalInputKeys(job.Command) + validatePlaceholders(result, job, optionalInputs) } // 7. Warn on single-brace {inputs.xxx} or {outputs.xxx} usage in command @@ -128,7 +130,7 @@ func ValidateJobOffline(job *JobDefinition, yamlDir string) *ValidationResult { // 8. Inputs/outputs with nil/empty definitions referenced in command if job.Command != "" { - validateInputOutputDefinitions(result, job) + validateInputOutputDefinitions(result, job, optionalInputs) } return result @@ -178,19 +180,22 @@ var ( singleBraceRegex = regexp.MustCompile(`\{(inputs|outputs)\.(\w[\w.-]*)}}?`) ) -// validatePlaceholders checks that ${{inputs.xxx}} references in command exist in job.Inputs -// and ${{outputs.xxx}} references exist in job.Outputs. -// References inside [...] optional blocks are skipped for inputs. -func validatePlaceholders(result *ValidationResult, job *JobDefinition) { - command := job.Command - - // Build set of optional input keys (those inside [...] blocks) - optionalInputs := make(map[string]bool) +// optionalInputKeys returns the set of input keys that appear inside [...] optional blocks. +func optionalInputKeys(command string) map[string]bool { + result := make(map[string]bool) for _, block := range optionalBlockRegex.FindAllString(command, -1) { for _, match := range inputPlaceholderRegex.FindAllStringSubmatch(block, -1) { - optionalInputs[match[1]] = true + result[match[1]] = true } } + return result +} + +// validatePlaceholders checks that ${{inputs.xxx}} references in command exist in job.Inputs +// and ${{outputs.xxx}} references exist in job.Outputs. +// References inside [...] optional blocks are skipped for inputs. +func validatePlaceholders(result *ValidationResult, job *JobDefinition, optionalInputs map[string]bool) { + command := job.Command // Find all ${{inputs.xxx}} and ${{outputs.xxx}} references for _, match := range placeholderRegex.FindAllStringSubmatch(command, -1) { @@ -243,7 +248,8 @@ func validateSingleBracePlaceholders(result *ValidationResult, command string) { // validateInputOutputDefinitions checks that inputs/outputs referenced in command // are not empty/nil definitions (all fields zero-valued). // Empty inputs are errors; empty outputs are warnings (backend uses defaults). -func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition) { +// Inputs inside [...] optional blocks are skipped — empty definitions are valid for optional inputs. +func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition, optionalInputs map[string]bool) { command := job.Command for _, match := range placeholderRegex.FindAllStringSubmatch(command, -1) { @@ -251,6 +257,9 @@ func validateInputOutputDefinitions(result *ValidationResult, job *JobDefinition key := match[2] if kind == "inputs" && job.Inputs != nil { + if optionalInputs[key] { + continue + } if input, exists := job.Inputs[key]; exists { if (input == InputDefinition{}) { result.Findings = append(result.Findings, ValidationFinding{ From 893cdb6e4f8a039c621d1916cc20709e0a641c7d Mon Sep 17 00:00:00 2001 From: Saanika Gupta Date: Tue, 7 Apr 2026 15:21:03 +0530 Subject: [PATCH 22/22] Fix error message for incorrect placeholder format --- .../azure.ai.customtraining/internal/utils/job_validator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go index 74c13bb0b58..90fafe80852 100644 --- a/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -240,7 +240,7 @@ func validateSingleBracePlaceholders(result *ValidationResult, command string) { result.Findings = append(result.Findings, ValidationFinding{ Field: "command", Severity: SeverityError, - Message: fmt.Sprintf("command uses single-brace '{%s.%s}' — use '${{%s.%s}}' instead", kind, key, kind, key), + Message: fmt.Sprintf("Incorrect placeholder format — use '${{%s.%s}}' instead", kind, key), }) } }