Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
93456f7
Validate command initial changes
saanikaguptamicrosoft Mar 31, 2026
53c598d
Validate existence of local paths
saanikaguptamicrosoft Mar 31, 2026
13825ce
Add validation for placeholder mapping
saanikaguptamicrosoft Mar 31, 2026
e2318a4
Add unit tests
saanikaguptamicrosoft Mar 31, 2026
09f3d12
Show warning if output definition is empty
saanikaguptamicrosoft Mar 31, 2026
41d0a5f
Nit
saanikaguptamicrosoft Mar 31, 2026
85d1522
Nit
saanikaguptamicrosoft Mar 31, 2026
9baa33b
Nit
saanikaguptamicrosoft Mar 31, 2026
302e3f3
Remove duplicate test
saanikaguptamicrosoft Mar 31, 2026
b6c0f35
Remove warning if outputs key itself is missing
saanikaguptamicrosoft Mar 31, 2026
bd5fc28
Add more tests
saanikaguptamicrosoft Mar 31, 2026
6f62b33
Merge branch 'foundry-training-dev' into saanika/validate
saanikaguptamicrosoft Apr 7, 2026
7a64359
Override PersistentPreRunE for validate command as it's offline command
saanikaguptamicrosoft Apr 7, 2026
af3ae7e
Add git paths to remote URI list
saanikaguptamicrosoft Apr 7, 2026
5a2d25d
Add shorthand for file flag in validate command
saanikaguptamicrosoft Apr 7, 2026
115279f
Emit warning when local path cannot be verified due to missing file p…
saanikaguptamicrosoft Apr 7, 2026
9fb6f5e
Fix validateSingleBracePlaceholders
saanikaguptamicrosoft Apr 7, 2026
0ba174c
Nit
saanikaguptamicrosoft Apr 7, 2026
9972a48
Nit: update error message
saanikaguptamicrosoft Apr 7, 2026
5b7ae17
Nit: Print success message in green
saanikaguptamicrosoft Apr 7, 2026
fb575ef
Nit: Update validation message
saanikaguptamicrosoft Apr 7, 2026
d6f9cae
Handle edge cases for optional inputs
saanikaguptamicrosoft Apr 7, 2026
893cdb6
Fix error message for incorrect placeholder format
saanikaguptamicrosoft Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
}
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"),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] fmt.Sprintf with no format verbs - just use a plain string literal.

})
}
}

// 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] validatePlaceholders (and validateInputOutputDefinitions at L255) iterates ALL regex matches. If the same key appears twice in the command, you get duplicate identical findings. Track seen keys with a map to dedup.

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),
})
}
}
}
}
}
Loading