-
Notifications
You must be signed in to change notification settings - Fork 291
Add validate command for custom training #7407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
saanikaguptamicrosoft
merged 27 commits into
Azure:foundry-training-dev
from
saanikaguptamicrosoft:saanika/validate
Apr 21, 2026
+732
−4
Merged
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
93456f7
Validate command initial changes
saanikaguptamicrosoft 53c598d
Validate existence of local paths
saanikaguptamicrosoft 13825ce
Add validation for placeholder mapping
saanikaguptamicrosoft e2318a4
Add unit tests
saanikaguptamicrosoft 09f3d12
Show warning if output definition is empty
saanikaguptamicrosoft 41d0a5f
Nit
saanikaguptamicrosoft 85d1522
Nit
saanikaguptamicrosoft 9baa33b
Nit
saanikaguptamicrosoft 302e3f3
Remove duplicate test
saanikaguptamicrosoft b6c0f35
Remove warning if outputs key itself is missing
saanikaguptamicrosoft bd5fc28
Add more tests
saanikaguptamicrosoft 6f62b33
Merge branch 'foundry-training-dev' into saanika/validate
saanikaguptamicrosoft 7a64359
Override PersistentPreRunE for validate command as it's offline command
saanikaguptamicrosoft af3ae7e
Add git paths to remote URI list
saanikaguptamicrosoft 5a2d25d
Add shorthand for file flag in validate command
saanikaguptamicrosoft 115279f
Emit warning when local path cannot be verified due to missing file p…
saanikaguptamicrosoft 9fb6f5e
Fix validateSingleBracePlaceholders
saanikaguptamicrosoft 0ba174c
Nit
saanikaguptamicrosoft 9972a48
Nit: update error message
saanikaguptamicrosoft 5b7ae17
Nit: Print success message in green
saanikaguptamicrosoft fb575ef
Nit: Update validation message
saanikaguptamicrosoft d6f9cae
Handle edge cases for optional inputs
saanikaguptamicrosoft 893cdb6
Fix error message for incorrect placeholder format
saanikaguptamicrosoft beb2e81
Address comments and fix UTs
saanikaguptamicrosoft e1b2324
Nit: Refactor
saanikaguptamicrosoft 2043719
Add required tag in job definition schema
saanikaguptamicrosoft 5bb4eb2
Address comments
saanikaguptamicrosoft File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
cli/azd/extensions/azure.ai.customtraining/internal/cmd/job_validate.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // 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/spf13/cobra" | ||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| func newJobValidateCommand() *cobra.Command { | ||
| var filePath string | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "validate", | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| 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 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 { | ||
| 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 | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| } | ||
272 changes: 272 additions & 0 deletions
272
cli/azd/extensions/azure.ai.customtraining/internal/utils/job_validator.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| // 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: "'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", | ||
| }) | ||
| } | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
|
|
||
| // 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), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // 5. Local path existence checks | ||
| validateLocalPath(result, "code", job.Code, yamlDir) | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| 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 | ||
| 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) | ||
| } | ||
|
|
||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| 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) { | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| result.Findings = append(result.Findings, ValidationFinding{ | ||
| Field: field, | ||
| Severity: SeverityError, | ||
| Message: fmt.Sprintf("local path does not exist: '%s'", path), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // 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) { | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| 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) { | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| 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 | ||
|
saanikaguptamicrosoft marked this conversation as resolved.
|
||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: |
||
| 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/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 | ||
|
|
||
| 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), | ||
| }) | ||
| } | ||
| } | ||
| } 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), | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.