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 b2d8c4993ff..40b92849b14 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()) cmd.AddCommand(newJobDownloadCommand()) 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..3b771e42c87 --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "azure.ai.customtraining/internal/utils" + + "github.com/fatih/color" + "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 offline without submitting", + // 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") + } + + // 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) + } + + 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 + yamlDir := filepath.Dir(filePath) + result := utils.ValidateJobOffline(&jobDef, yamlDir) + + // Print findings + if len(result.Findings) == 0 { + color.Green("✓ 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() { + return fmt.Errorf("validation failed with %d error(s)", result.ErrorCount()) + } + + color.Green("\n✓ Validation passed with warnings.\n") + return nil + }, + } + + cmd.Flags().StringVarP(&filePath, "file", "f", "", "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..90fafe80852 --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package utils + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "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. +// 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, yamlDir string) *ValidationResult { + result := &ValidationResult{} + + // 1. command field is required + if job.Command == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "command", + Severity: SeverityError, + Message: "required field is missing", + }) + } + + // 2. environment field is required + if job.Environment == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "environment", + Severity: SeverityError, + Message: "required field is missing", + }) + } + + // 3. compute field is required + if job.Compute == "" { + result.Findings = append(result.Findings, ValidationFinding{ + Field: "compute", + Severity: SeverityError, + Message: "required field is missing", + }) + } + + // 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"), + }) + } + } + + // 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) + } + } + + // 6. Validate ${{inputs.xxx}} and ${{outputs.xxx}} placeholders in command + var optionalInputs map[string]bool + if job.Command != "" { + optionalInputs = optionalInputKeys(job.Command) + validatePlaceholders(result, job, optionalInputs) + } + + // 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, optionalInputs) + } + + 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), + }) + } 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), + }) + } +} + +// 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.-]*)}}?`) +) + +// 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) { + 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) { + 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 + } + 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), + }) + } + } + } +} + +// 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 + } + + 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("Incorrect placeholder format — use '${{%s.%s}}' instead", kind, key), + }) + } +} + +// 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). +// 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) { + kind := match[1] + 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{ + Field: fmt.Sprintf("inputs.%s", key), + Severity: SeverityError, + Message: fmt.Sprintf("input '%s' is referenced in command but has an empty definition", key), + }) + } + } + } 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 new file mode 100644 index 00000000000..2afb7bb368b --- /dev/null +++ b/cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator_test.go @@ -0,0 +1,314 @@ +// 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}} placeholder validation in command. +// Output placeholders are NOT validated here — outputs are auto-provisioned by the backend. +func TestValidate_PlaceholderMapping(t *testing.T) { + // YAML — typo in input placeholder key: + // command: >- + // python train.py + // --data ${{inputs.training_data}} + // --val ${{inputs.validation_data}} ← "validation_data" NOT in inputs → error + // inputs: + // training_data: + // type: uri_folder + // path: azureml://datastores/blob/data + job := validJob() + 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"}, + } + 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'") + } + + // 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}}" + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "no inputs are defined"); f == nil { + 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 + // --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 (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 + 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 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 → error + 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 — output key exists but has no properties (None): + // command: python train.py --out ${{outputs.model}} + // outputs: + // model: ← empty definition → warning (backend uses defaults) + job = validJob() + job.Command = "python train.py --out ${{outputs.model}}" + job.Outputs = map[string]OutputDefinition{"model": {}} + result = ValidateJobOffline(job, ".") + if f := findFindingByMessage(result, "default values will be used"); f == nil || f.Severity != SeverityWarning { + t.Error("expected warning for empty output definition") + } +} + +// 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()) + } +} 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..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 @@ -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.