Skip to content
Merged
Show file tree
Hide file tree
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 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
beb2e81
Address comments and fix UTs
saanikaguptamicrosoft Apr 14, 2026
e1b2324
Nit: Refactor
saanikaguptamicrosoft Apr 14, 2026
2043719
Add required tag in job definition schema
saanikaguptamicrosoft Apr 14, 2026
5bb4eb2
Address comments
saanikaguptamicrosoft Apr 16, 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())
Comment thread
saanikaguptamicrosoft marked this conversation as resolved.

return cmd
}
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",
Comment thread
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
Comment thread
saanikaguptamicrosoft marked this conversation as resolved.
}
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",
})
}
Comment thread
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)
Comment thread
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)
}

Comment thread
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) {
Comment thread
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) {
Comment thread
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) {
Comment thread
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
Comment thread
saanikaguptamicrosoft marked this conversation as resolved.
}
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.

Non-blocking: ${{inputs.data} (missing one closing brace) bypasses both checks - placeholderRegex needs }}, and singleBraceRegex sees the ${ prefix and skips. Worth adding a malformed-placeholder check in a follow-up.

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