-
Notifications
You must be signed in to change notification settings - Fork 288
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
base: foundry-training-dev
Are you sure you want to change the base?
Changes from all commits
93456f7
53c598d
13825ce
e2318a4
09f3d12
41d0a5f
85d1522
9baa33b
302e3f3
b6c0f35
bd5fc28
6f62b33
7a64359
af3ae7e
5a2d25d
115279f
9fb6f5e
0ba174c
9972a48
5b7ae17
fb575ef
d6f9cae
893cdb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
saanikaguptamicrosoft marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"), | ||
|
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. [LOW] |
||
| }) | ||
| } | ||
| } | ||
|
|
||
| // 5. Local path existence checks | ||
| validateLocalPath(result, "code", job.Code, yamlDir) | ||
saanikaguptamicrosoft marked this conversation as resolved.
Show resolved
Hide 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 | ||
| 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) { | ||
saanikaguptamicrosoft marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) { | ||
|
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. [MEDIUM] |
||
| 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 | ||
saanikaguptamicrosoft marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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), | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.