diff --git a/CLAUDE.md b/CLAUDE.md index 7021b37a95..eae3a12028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,7 +417,7 @@ Follow template (what/why/references). **Blog Posts (CI Enforced):** - PRs labeled `minor`/`major` MUST include blog post: `website/blog/YYYY-MM-DD-feature-name.mdx` - Use `.mdx` with YAML front matter, `` after intro -- Tags: User-facing (`feature`/`enhancement`/`bugfix`) or Contributors (`contributors`) +- **ONLY use existing tags** - check `website/blog/*.mdx` for valid tags before writing - Author: committer's GitHub username, add to `website/blog/authors.yml` **Blog Template:** @@ -433,11 +433,14 @@ Brief intro. ## What Changed / Why This Matters / How to Use It / Get Involved ``` -**Tag Reference:** -- Primary: `feature`, `enhancement`, `bugfix`, `contributors` -- Technical (contributor): `atmos-core`, `refactoring`, `testing`, `ci-cd`, `developer-experience` -- Technical (user): `terraform`, `helmfile`, `workflows`, `validation`, `performance`, `cloud-architecture` -- General: `announcements`, `breaking-changes` +**Existing Tags (use only these):** +- Primary: `feature`, `enhancement`, `bugfix` +- Secondary: `dx`, `security`, `documentation`, `core`, `breaking-change` + +**Finding valid tags:** +```bash +grep -h "^ - " website/blog/*.mdx | sort | uniq -c | sort -rn +``` Use `no-release` label for docs-only changes. diff --git a/docs/prd/version-constraint.md b/docs/prd/version-constraint.md new file mode 100644 index 0000000000..b8ec2a4ca5 --- /dev/null +++ b/docs/prd/version-constraint.md @@ -0,0 +1,896 @@ +# Version Constraint Validation + +## Overview + +This document describes the version constraint validation feature for Atmos, which allows `atmos.yaml` configurations to specify required Atmos version ranges using semantic versioning (semver) constraints. When a configuration requires a specific version range, Atmos will validate the current version against the constraint and respond according to the configured enforcement level. + +## Problem Statement + +### Current State + +Currently, Atmos provides version checking functionality under the `version` key in `atmos.yaml`: + +```yaml +version: + check: + enabled: true + timeout: 5 + frequency: "24h" +``` + +This configuration enables checking for newer versions of Atmos available on GitHub, but it does **not** enforce any minimum or maximum version requirements for the configuration itself. + +### Challenges + +1. **No minimum version enforcement** - Configurations using new features cannot require a minimum Atmos version +2. **Breaking changes** - Users may run incompatible Atmos versions with configurations expecting newer features +3. **Team consistency** - No way to ensure all team members use compatible Atmos versions +4. **Feature gating** - Cannot specify version ranges for configurations using version-specific features +5. **Migration clarity** - No clear signal when upgrading is required vs. recommended +6. **Multi-environment version drift** - Different environments (CI, local, containers) often run mismatched Atmos versions, leading to inconsistent behavior +7. **Silent feature unavailability** - Newer features may not exist in older versions, causing confusing errors without clear version context +8. **Experimentation friction** - No way to warn users about unsupported versions while still allowing experimentation with newer releases + +## Proposed Solution + +### YAML Configuration Structure + +```yaml +# atmos.yaml +version: + # EXISTING: Check for new releases on GitHub (unchanged) + check: + enabled: true + timeout: 5 + frequency: "24h" + + # NEW: Validate Atmos version against constraints + constraint: + require: ">=2.5.0, <3.0.0" # Semver constraint expression + enforcement: "fatal" # fatal | warn | silent (default: fatal) + message: "Custom message" # Optional custom error message +``` + +### Data Structure + +Extend the existing `Version` struct in `pkg/schema/version.go`: + +```go +type VersionConstraint struct { + // Require specifies the semver constraint(s) for Atmos version as a single string. + // Multiple constraints are comma-separated and treated as logical AND. + // Uses hashicorp/go-version library (already in go.mod): https://github.com/hashicorp/go-version + // + // Why string instead of []string: + // - Consistent with Terraform constraint syntax + // - Simpler YAML (no list nesting) + // - Native to hashicorp/go-version (parses comma-separated directly) + // - Single atomic expression + // + // Examples: + // ">=1.2.3" - Minimum version + // "<2.0.0" - Maximum version (exclude) + // ">=1.2.3, <2.0.0" - Range (AND logic) + // ">=2.5.0, !=2.7.0, <3.0.0" - Complex (multiple AND constraints) + // "~>1.2" - Pessimistic constraint (>=1.2.0, <1.3.0) + // "~>1.2.3" - Pessimistic constraint (>=1.2.3, <1.3.0) + // "1.2.3" - Exact version + Require string `yaml:"require,omitempty" mapstructure:"require" json:"require,omitempty"` + + // Enforcement specifies the behavior when version constraint is not satisfied. + // Values: + // "fatal" - Exit immediately with error code 1 (default) + // "warn" - Log warning but continue execution + // "silent" - Skip validation entirely (for debugging) + Enforcement string `yaml:"enforcement,omitempty" mapstructure:"enforcement" json:"enforcement,omitempty"` + + // Message provides a custom message to display when constraint fails. + // If empty, a default message is shown. + Message string `yaml:"message,omitempty" mapstructure:"message" json:"message,omitempty"` +} + +type Version struct { + Check VersionCheck `yaml:"check,omitempty" mapstructure:"check" json:"check,omitempty"` + Constraint VersionConstraint `yaml:"constraint,omitempty" mapstructure:"constraint" json:"constraint,omitempty"` +} +``` + +## Configuration Examples + +### Example 1: Minimum Version (Fatal) +```yaml +version: + constraint: + require: ">=2.5.0" + enforcement: "fatal" +``` + +**Behavior:** +- Current version `2.6.0` → Pass, execute normally +- Current version `2.4.0` → Exit with error: + ``` + ✗ Atmos version constraint not satisfied + Required: >=2.5.0 + Current: 2.4.0 + + This configuration requires Atmos version >=2.5.0. + Please upgrade: https://atmos.tools/install + ``` + +### Example 2: Version Range with Custom Message +```yaml +version: + constraint: + require: ">=2.5.0, <3.0.0" + enforcement: "warn" + message: "This stack configuration is tested with Atmos 2.x. Atmos 3.x may introduce breaking changes." +``` + +**Behavior:** +- Current version `2.6.0` → Pass, execute normally +- Current version `3.0.0` → Show warning and continue: + ``` + ⚠ Atmos version constraint not satisfied + Required: >=2.5.0, <3.0.0 + Current: 3.0.0 + + This stack configuration is tested with Atmos 2.x. Atmos 3.x may introduce breaking changes. + ``` + +### Example 3: Pessimistic Constraint (Terraform-style) +```yaml +version: + constraint: + require: "~>2.5" # Equivalent to ">=2.5.0, <2.6.0" + enforcement: "fatal" +``` + +### Example 4: Multiple Constraints +```yaml +version: + constraint: + require: ">=2.5.0, !=2.7.0, <3.0.0" # Allow 2.5.x and 2.6.x, skip broken 2.7.0 + enforcement: "fatal" +``` + +### Example 5: Silent Mode (Debugging) +```yaml +version: + constraint: + require: ">=2.5.0" + enforcement: "silent" # Skip validation, useful for testing +``` + +### Example 6: Team Consistency +```yaml +version: + constraint: + require: ">=2.5.0, <3.0.0" + enforcement: "fatal" + message: | + Our team uses Atmos 2.x for this project. + + To install/upgrade Atmos: + - macOS: brew install atmos + - Linux: Download from https://github.com/cloudposse/atmos/releases + + Questions? Contact #infrastructure-support +``` + +### Example 7: Multi-Environment Consistency +```yaml +# Ensure all environments (CI, local, containers) use compatible versions +version: + constraint: + require: ">=2.5.0, <3.0.0" + enforcement: "fatal" + message: | + Version mismatch detected across environments. + + This configuration requires Atmos 2.x to ensure consistent behavior + across CI pipelines, local development, and container deployments. + + Check your environment: + - CI: Update .github/workflows or .gitlab-ci.yml + - Local: brew upgrade atmos + - Docker: Update Dockerfile base image +``` + +### Example 8: Experimentation Mode (Warn on Unsupported) +```yaml +# Allow experimentation with newer versions but warn if not officially supported +version: + constraint: + require: ">=2.5.0, <2.8.0" + enforcement: "warn" + message: | + You are using Atmos 2.8.0+ which is newer than our tested version range. + + This configuration is validated against Atmos 2.5.0-2.7.x. + Newer versions may work but are not officially supported. + + Proceed at your own risk. Report issues to #infrastructure. +``` + +### Example 9: Combined with Version Checking +```yaml +version: + # Check for new Atmos releases periodically + check: + enabled: true + timeout: 5 + frequency: "24h" + + # Require minimum version for this configuration + constraint: + require: ">=2.5.0" + enforcement: "fatal" +``` + +## Constraint Syntax Reference + +Using `hashicorp/go-version` constraint syntax (same as Terraform): + +| Constraint | Meaning | Example | +|------------|---------|---------| +| `>=1.2.3` | Greater than or equal | `>=2.5.0` | +| `<=1.2.3` | Less than or equal | `<=3.0.0` | +| `>1.2.3` | Greater than (exclusive) | `>2.4.0` | +| `<1.2.3` | Less than (exclusive) | `<3.0.0` | +| `1.2.3` | Exact version | `2.5.0` | +| `!=1.2.3` | Not equal | `!=2.7.0` | +| `~>1.2` | Pessimistic (~> 1.2 = >=1.2.0, <1.3.0) | `~>2.5` | +| `~>1.2.3` | Pessimistic (~> 1.2.3 = >=1.2.3, <1.3.0) | `~>2.5.0` | +| Multiple | Comma-separated AND | `>=2.5.0, <3.0.0, !=2.7.0` | + +Full syntax: https://github.com/hashicorp/go-version + +## Enforcement Levels + +| Level | Behavior | Use Case | +|-------|----------|----------| +| `fatal` (default) | Exit with error code 1 | Production configs requiring specific versions | +| `warn` | Show warning, continue execution | Migration periods, soft requirements | +| `silent` | Skip validation entirely | Debugging, testing with different versions | + +## Environment Variable Override + +Allow runtime override for debugging and CI/CD: + +```bash +# Override enforcement level +ATMOS_VERSION_ENFORCEMENT=warn atmos terraform plan + +# Disable constraint checking entirely +ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan +``` + +**Precedence:** +1. `ATMOS_VERSION_ENFORCEMENT` environment variable (highest) +2. `version.constraint.enforcement` in `atmos.yaml` +3. Default value `"fatal"` (lowest) + +## Implementation Plan + +### Phase 1: Core Validation + +**File Changes:** + +1. **`errors/errors.go`** - Add sentinel errors for version constraint validation + ```go + // Version constraint errors. + var ( + // ErrVersionConstraint indicates the current Atmos version does not satisfy + // the version constraint specified in atmos.yaml. + ErrVersionConstraint = errors.New("version constraint not satisfied") + + // ErrInvalidVersionConstraint indicates the version constraint syntax is invalid. + ErrInvalidVersionConstraint = errors.New("invalid version constraint") + ) + ``` + +2. **`pkg/schema/version.go`** - Add `VersionConstraint` struct + ```go + type VersionConstraint struct { + Require string `yaml:"require,omitempty" mapstructure:"require" json:"require,omitempty"` + Enforcement string `yaml:"enforcement,omitempty" mapstructure:"enforcement" json:"enforcement,omitempty"` + Message string `yaml:"message,omitempty" mapstructure:"message" json:"message,omitempty"` + } + + type Version struct { + Check VersionCheck `yaml:"check,omitempty" mapstructure:"check" json:"check,omitempty"` + Constraint VersionConstraint `yaml:"constraint,omitempty" mapstructure:"constraint" json:"constraint,omitempty"` + } + ``` + +3. **`pkg/version/constraint.go`** (new file) - Validation logic (returns errors, no deep exits) + ```go + package version + + import ( + "fmt" + + goversion "github.com/hashicorp/go-version" + ) + + // ValidateConstraint checks if the current Atmos version satisfies the constraint. + // Returns (satisfied bool, error). + func ValidateConstraint(constraintStr string) (bool, error) { + if constraintStr == "" { + return true, nil // No constraint = always pass + } + + current, err := goversion.NewVersion(Version) + if err != nil { + return false, fmt.Errorf("invalid current version %q: %w", Version, err) + } + + constraints, err := goversion.NewConstraint(constraintStr) + if err != nil { + return false, fmt.Errorf("invalid version constraint %q: %w", constraintStr, err) + } + + return constraints.Check(current), nil + } + ``` + +4. **`pkg/version/constraint_test.go`** (new file) - Comprehensive tests + ```go + package version + + import ( + "testing" + ) + + func TestValidateConstraint(t *testing.T) { + // Save original version + originalVersion := Version + defer func() { Version = originalVersion }() + + tests := []struct { + name string + currentVersion string + constraint string + expectPass bool + expectError bool + }{ + { + name: "empty constraint always passes", + currentVersion: "1.0.0", + constraint: "", + expectPass: true, + expectError: false, + }, + { + name: "minimum version satisfied", + currentVersion: "2.5.0", + constraint: ">=2.0.0", + expectPass: true, + expectError: false, + }, + { + name: "minimum version not satisfied", + currentVersion: "1.9.0", + constraint: ">=2.0.0", + expectPass: false, + expectError: false, + }, + { + name: "range satisfied", + currentVersion: "2.5.0", + constraint: ">=2.0.0, <3.0.0", + expectPass: true, + expectError: false, + }, + { + name: "range not satisfied (too new)", + currentVersion: "3.0.0", + constraint: ">=2.0.0, <3.0.0", + expectPass: false, + expectError: false, + }, + { + name: "pessimistic constraint satisfied", + currentVersion: "2.5.3", + constraint: "~>2.5", + expectPass: true, + expectError: false, + }, + { + name: "pessimistic constraint not satisfied", + currentVersion: "2.6.0", + constraint: "~>2.5", + expectPass: false, + expectError: false, + }, + { + name: "exact version match", + currentVersion: "2.5.0", + constraint: "2.5.0", + expectPass: true, + expectError: false, + }, + { + name: "invalid constraint syntax", + currentVersion: "2.5.0", + constraint: "invalid>>2.0", + expectPass: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + Version = tt.currentVersion + + pass, err := ValidateConstraint(tt.constraint) + + if tt.expectError && err == nil { + t.Errorf("expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + if pass != tt.expectPass { + t.Errorf("expected pass=%v, got pass=%v", tt.expectPass, pass) + } + }) + } + } + ``` + +5. **`cmd/cmd_utils.go`** - Add validation call in `initConfig()` + ```go + // Add after config is loaded, before any command execution + func initConfig() error { + // ... existing config loading code ... + + // Validate version constraint + if err := validateVersionConstraint(atmosConfig); err != nil { + return err + } + + return nil + } + + // validateVersionConstraint uses the Atmos error builder pattern. + // No deep exits - all errors are returned for proper propagation. + func validateVersionConstraint(cfg *schema.AtmosConfiguration) error { + constraint := cfg.Version.Constraint + + // Skip if no constraint specified + if constraint.Require == "" { + return nil + } + + // Check environment variable override + enforcement := constraint.Enforcement + if envEnforcement := os.Getenv("ATMOS_VERSION_ENFORCEMENT"); envEnforcement != "" { + enforcement = envEnforcement + } + + // Default enforcement is "fatal" + if enforcement == "" { + enforcement = "fatal" + } + + // Skip validation if silent + if enforcement == "silent" { + return nil + } + + // Validate constraint syntax + satisfied, err := version.ValidateConstraint(constraint.Require) + if err != nil { + // Invalid constraint syntax is always fatal - use error builder + return errUtils.Build(errUtils.ErrInvalidVersionConstraint). + WithHint("Please use valid semver constraint syntax"). + WithHint("Reference: https://github.com/hashicorp/go-version"). + WithContext("constraint", constraint.Require). + WithExitCode(1). + Wrap(err) + } + + if !satisfied { + // Build hints for error message + hints := []string{ + fmt.Sprintf("This configuration requires Atmos version %s", constraint.Require), + "Please upgrade: https://atmos.tools/install", + } + + // Add custom message as hint if provided + if constraint.Message != "" { + hints = append(hints, constraint.Message) + } + + if enforcement == "fatal" { + // Use error builder for proper error handling + builder := errUtils.Build(errUtils.ErrVersionConstraint). + WithContext("required", constraint.Require). + WithContext("current", version.Version). + WithExitCode(1) + + for _, hint := range hints { + builder = builder.WithHint(hint) + } + + return builder.Err() + } else if enforcement == "warn" { + // Warnings still go to UI, but no error returned + ui.Warning(fmt.Sprintf( + "Atmos version constraint not satisfied\n Required: %s\n Current: %s", + constraint.Require, + version.Version, + )) + if constraint.Message != "" { + ui.Warning(constraint.Message) + } + } + } + + return nil + } + ``` + +6. **`pkg/datafetcher/schema/atmos/1.0.json`** - Update JSON schema + ```json + { + "version": { + "type": "object", + "properties": { + "check": { ... }, + "constraint": { + "type": "object", + "properties": { + "require": { + "type": "string", + "description": "Semver constraint for required Atmos version (e.g., '>=2.5.0', '>=2.0.0, <3.0.0')" + }, + "enforcement": { + "type": "string", + "enum": ["fatal", "warn", "silent"], + "default": "fatal", + "description": "Enforcement level: 'fatal' exits with error, 'warn' shows warning, 'silent' skips validation" + }, + "message": { + "type": "string", + "description": "Custom message to display when constraint fails" + } + } + } + } + } + } + ``` + +### Phase 2: Documentation + +1. **`website/docs/cli/configuration.mdx`** - Add version constraint documentation +2. **`website/docs/cli/versioning.mdx`** - Update with constraint examples + +### Phase 3: Constraint-Aware Version Listing (Optional Enhancement) + +Add constraint-aware filtering to `atmos version list` command to help users find compatible versions. + +**Problem:** When users run `atmos version list`, they see all available versions including those that don't satisfy their configuration's constraints. This can be confusing when trying to upgrade to a compatible version. + +**Solution:** Add optional `--constraint-aware` flag to `atmos version list` that filters results based on `version.constraint.require` from `atmos.yaml`. + +**Implementation:** + +1. **`cmd/version/list.go`** - Add new flag and filtering logic + ```go + var ( + listConstraintAware bool // NEW flag + // ... existing flags + ) + + func init() { + // ... existing flags + listCmd.Flags().BoolVar(&listConstraintAware, "constraint-aware", false, + "Filter releases based on version.constraint.require from atmos.yaml") + } + ``` + +2. **Filtering logic in `listCmd.RunE`:** + ```go + // If constraint-aware flag is set, load atmos.yaml and filter releases + if listConstraintAware { + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + if err != nil { + return fmt.Errorf("failed to load atmos.yaml for constraint filtering: %w", err) + } + + if atmosConfig.Version.Constraint.Require != "" { + constraint, err := goversion.NewConstraint(atmosConfig.Version.Constraint.Require) + if err != nil { + return fmt.Errorf("invalid constraint in atmos.yaml: %w", err) + } + + // Filter releases that satisfy constraint + filtered := []*github.RepositoryRelease{} + for _, release := range releases { + ver, err := goversion.NewVersion(strings.TrimPrefix(*release.TagName, "v")) + if err != nil { + continue // Skip releases with invalid version format + } + if constraint.Check(ver) { + filtered = append(filtered, release) + } + } + releases = filtered + + // Show constraint info in output + ui.Info(fmt.Sprintf("Showing releases matching constraint: %s", + atmosConfig.Version.Constraint.Require)) + } + } + ``` + +**Usage Examples:** + +```bash +# Show all releases (default behavior, unchanged) +atmos version list + +# Show only releases that satisfy constraint from atmos.yaml +atmos version list --constraint-aware + +# Example: If atmos.yaml has ">=2.5.0, <3.0.0", only show 2.5.x - 2.9.x releases +atmos version list --constraint-aware --limit 20 +``` + +**Output Example:** + +```bash +$ atmos version list --constraint-aware + +ℹ Showing releases matching constraint: >=2.5.0, <3.0.0 + +VERSION PUBLISHED TYPE NOTES +2.9.0 2025-01-15 stable Latest stable +2.8.5 2025-01-10 stable Bug fixes +2.8.0 2024-12-20 stable New features +2.7.3 2024-12-15 stable Security patch +2.6.0 2024-11-30 stable +2.5.0 2024-11-15 stable + +# Versions 3.0.0+ are hidden because they don't satisfy <3.0.0 +# Versions <2.5.0 are hidden because they don't satisfy >=2.5.0 +``` + +**Design Considerations:** + +**Option 1: Flag-based (RECOMMENDED)** +- Pro: Explicit opt-in, doesn't change default behavior +- Pro: Users who want all versions can still get them +- Pro: Works well with other flags (--include-prereleases, etc.) +- Con: Requires users to know about the flag + +**Option 2: Always filter by default** +- Pro: Automatically helpful +- Con: Breaking change - users expect to see all versions +- Con: May hide newer versions users want to know about +- Con: Confusing if constraint is set to "warn" enforcement + +**Option 3: Auto-enable when constraint enforcement is "fatal"** +- Pro: Smart default based on enforcement level +- Con: Implicit behavior may be surprising +- Con: Still need flag to override + +**Recommendation:** Use **Option 1** (flag-based) for Phase 3. It's explicit, backward-compatible, and gives users control. + +**Future Enhancement:** Add warning when `atmos version list` shows versions outside the constraint: +```bash +$ atmos version list + +VERSION PUBLISHED TYPE NOTES +3.0.0 2025-02-01 stable Latest (⚠ outside constraint) +2.9.0 2025-01-15 stable +... + +⚠ Some versions shown are outside your configuration's constraint: >=2.5.0, <3.0.0 + Use --constraint-aware to filter results +``` + +## Error Handling + +### No Deep Exits + +This feature uses the **Atmos error builder pattern** for all error handling. There are **no deep exits** (`os.Exit()`, `log.Fatal()`, etc.) in the implementation. + +All errors are propagated up the call stack using Go's standard error return pattern, allowing: +- Proper error wrapping with context +- Consistent error formatting via the error builder +- Exit codes set via `errUtils.WithExitCode()` +- Testability without mocking `os.Exit()` + +```go +// CORRECT: Use error builder pattern +func validateVersionConstraint(cfg *schema.AtmosConfiguration) error { + // ... validation logic ... + + if !satisfied { + return errUtils.Build(errUtils.ErrVersionConstraint). + WithHint(fmt.Sprintf("This configuration requires Atmos version %s", constraint.Require)). + WithHint("Please upgrade: https://atmos.tools/install"). + WithContext("required", constraint.Require). + WithContext("current", version.Version). + WithExitCode(1). + Err() + } + return nil +} + +// WRONG: Never use deep exits +func validateVersionConstraint(cfg *schema.AtmosConfiguration) { + // ... validation logic ... + + if !satisfied { + fmt.Fprintf(os.Stderr, "Version mismatch\n") + os.Exit(1) // ❌ NEVER do this + } +} +``` + +## Error Messages + +**Fatal Error (enforcement: fatal):** +``` +✗ Atmos version constraint not satisfied + Required: >=2.5.0 + Current: 2.4.0 + +This configuration requires Atmos version >=2.5.0. +Please upgrade: https://atmos.tools/install + +Error: version constraint validation failed +``` + +**Warning (enforcement: warn):** +``` +⚠ Atmos version constraint not satisfied + Required: >=2.5.0, <3.0.0 + Current: 3.0.0 + +This stack configuration is tested with Atmos 2.x. Atmos 3.x may introduce breaking changes. + +Continuing execution... +``` + +**Invalid Constraint Syntax (always fatal):** +``` +✗ Invalid version constraint in configuration + Constraint: "invalid>>2.0" + +Error: Malformed constraint: invalid>>2.0 +Please use valid semver constraint syntax: https://github.com/hashicorp/go-version +``` + +## Validation Flow + +``` +1. Atmos CLI starts (any command) + ↓ +2. Load atmos.yaml configuration + ↓ +3. Parse version.constraint section + ↓ +4. Validate constraint (if specified) + ├─ Empty? → Skip validation + ├─ Invalid syntax? → Fatal error (cannot be bypassed) + └─ Version mismatch? + ├─ enforcement: "fatal" → Exit with error code 1 + ├─ enforcement: "warn" → Show warning, continue + └─ enforcement: "silent" → Skip validation + ↓ +5. Continue with command execution +``` + +## Testing Strategy + +1. **Unit tests** - `pkg/version/constraint_test.go` covers all constraint types +2. **Integration tests** - CLI tests with various configurations +3. **Error message tests** - Snapshot tests for all error/warning formats +4. **Environment variable tests** - Override behavior verification +5. **Edge cases** - Empty constraints, invalid syntax, malformed versions + +Target: >80% coverage + +## Backward Compatibility + +- **Fully backward compatible** - No breaking changes +- Existing `atmos.yaml` files without `version.constraint` work unchanged +- Empty or missing `version.constraint.require` is treated as "no constraint" +- Default enforcement is "fatal" for safety, but can be explicitly set to "warn" + +## Security Considerations + +- Version validation occurs **after** config loading but **before** any execution +- Invalid constraint syntax is always fatal (cannot be bypassed) +- Environment variable override requires explicit action (not accidental) +- No remote version fetching required (offline-safe) + +## Performance Considerations + +- Version parsing is cached (hashicorp/go-version is efficient) +- Validation adds <1ms overhead +- No network calls required +- Early exit on fatal constraint violation + +## Dependencies + +### SemVer Library: `hashicorp/go-version` + +**Already in Atmos dependencies** (`go.mod` v1.7.0) - currently used by `pkg/downloader/get_git.go` for git version checking. + +**Why this library:** +- ✅ **Zero new dependencies** - Already in `go.mod` +- ✅ **Terraform-compatible syntax** - Users already familiar with constraint format +- ✅ **Battle-tested** - Used by HashiCorp in production tools +- ✅ **Complete feature set** - All operators: `>=`, `<=`, `>`, `<`, `=`, `!=`, `~>` +- ✅ **Efficient** - Fast parsing and comparison +- ✅ **Well-documented** - Clear API and examples + +**Current usage in Atmos:** +```go +// pkg/downloader/get_git.go:330 +want, err := version.NewVersion(min) +have, err := version.NewVersion(v) +if have.LessThan(want) { + return fmt.Errorf("git version %s is too old", v) +} +``` + +**Proposed usage for constraints:** +```go +// pkg/version/constraint.go +current, err := goversion.NewVersion(Version) +constraints, err := goversion.NewConstraint(">=2.5.0, <3.0.0") +satisfied := constraints.Check(current) +``` + +### Constraint Format: String (Not List) + +**Design decision: Use single string field with comma-separated constraints** + +```yaml +# ✅ CHOSEN: String (comma-separated) +require: ">=2.5.0, <3.0.0, !=2.7.0" + +# ❌ NOT CHOSEN: List of strings +# require: +# - ">=2.5.0" +# - "<3.0.0" +# - "!=2.7.0" +``` + +**Rationale:** +1. **Terraform consistency** - Same syntax users already know +2. **Library native** - `hashicorp/go-version` parses comma-separated strings directly +3. **Simpler YAML** - No list nesting, cleaner configuration +4. **Atomic expression** - Single logical AND statement +5. **Less verbose** - Easier to read and write + +**Library support:** +```go +// Single call handles all comma-separated constraints (AND logic) +constraints, err := version.NewConstraint(">=2.5.0, <3.0.0, !=2.7.0") +// Parses three constraints: >=2.5.0 AND <3.0.0 AND !=2.7.0 +``` + +## Success Metrics + +- ✅ Zero breaking changes for existing configurations +- ✅ >80% test coverage for constraint validation +- ✅ <5ms overhead for version validation +- ✅ Clear, actionable error messages +- ✅ Comprehensive documentation with examples +- ✅ User adoption in Cloud Posse reference architectures + +## References + +- [hashicorp/go-version](https://github.com/hashicorp/go-version) - Constraint syntax +- [Semantic Versioning](https://semver.org/) - Version format specification +- [Terraform Version Constraints](https://www.terraform.io/language/expressions/version-constraints) - Similar constraint syntax +- Existing Atmos version handling: `pkg/version/version.go`, `internal/exec/version.go` diff --git a/errors/errors.go b/errors/errors.go index 5b943064b5..cb506b9d9a 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -306,6 +306,10 @@ var ( ErrVersionCacheLoadFailed = errors.New("failed to load version check cache") ErrVersionGitHubAPIFailed = errors.New("failed to query GitHub API for releases") + // Version constraint errors. + ErrVersionConstraint = errors.New("version constraint not satisfied") + ErrInvalidVersionConstraint = errors.New("invalid version constraint") + // Atlantis errors. ErrAtlantisInvalidFlags = errors.New("incompatible atlantis flags") ErrAtlantisProjectTemplateNotDef = errors.New("atlantis project template is not defined") diff --git a/pkg/config/utils.go b/pkg/config/utils.go index 01031aa185..522ddc2386 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -8,11 +8,14 @@ import ( "strconv" "strings" + errUtils "github.com/cloudposse/atmos/errors" log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/perf" "github.com/cloudposse/atmos/pkg/schema" "github.com/cloudposse/atmos/pkg/store" + "github.com/cloudposse/atmos/pkg/ui" u "github.com/cloudposse/atmos/pkg/utils" + "github.com/cloudposse/atmos/pkg/version" ) // FindAllStackConfigsInPathsForStack finds all stack manifests in the paths specified by globs for the provided stack. @@ -434,6 +437,87 @@ func checkConfig(atmosConfig schema.AtmosConfiguration, isProcessStack bool) err } } + // Validate version constraint. + if err := validateVersionConstraint(atmosConfig.Version.Constraint); err != nil { + return err + } + + return nil +} + +// getVersionEnforcement returns the enforcement level, checking env var override. +func getVersionEnforcement(configEnforcement string) string { + if envEnforcement := os.Getenv("ATMOS_VERSION_ENFORCEMENT"); envEnforcement != "" { //nolint:forbidigo + return envEnforcement + } + if configEnforcement == "" { + return "fatal" + } + return configEnforcement +} + +// buildVersionConstraintError builds the error for unsatisfied version constraint. +func buildVersionConstraintError(constraint schema.VersionConstraint) error { + builder := errUtils.Build(errUtils.ErrVersionConstraint). + WithExplanationf("This configuration requires Atmos version %s, but you are running %s", + constraint.Require, version.Version). + WithHint("Please upgrade: https://atmos.tools/install"). + WithContext("required", constraint.Require). + WithContext("current", version.Version). + WithExitCode(1) + + if constraint.Message != "" { + builder = builder.WithHint(constraint.Message) + } + + return builder.Err() +} + +// warnVersionConstraint logs a warning for unsatisfied version constraint. +func warnVersionConstraint(constraint schema.VersionConstraint) { + _ = ui.Warning(fmt.Sprintf( + "Atmos version constraint not satisfied\n Required: %s\n Current: %s", + constraint.Require, + version.Version, + )) + if constraint.Message != "" { + _ = ui.Warning(constraint.Message) + } +} + +// validateVersionConstraint validates the current Atmos version against the constraint +// specified in atmos.yaml. Uses the Atmos error builder pattern - no deep exits. +func validateVersionConstraint(constraint schema.VersionConstraint) error { + if constraint.Require == "" { + return nil + } + + enforcement := getVersionEnforcement(constraint.Enforcement) + if enforcement == "silent" { + return nil + } + + satisfied, err := version.ValidateConstraint(constraint.Require) + if err != nil { + return errUtils.Build(errors.Join(errUtils.ErrInvalidVersionConstraint, err)). + WithHint("Please use valid semver constraint syntax"). + WithHint("Reference: https://github.com/hashicorp/go-version"). + WithContext("constraint", constraint.Require). + WithExitCode(1). + Err() + } + + if satisfied { + return nil + } + + switch enforcement { + case "fatal": + return buildVersionConstraintError(constraint) + case "warn": + warnVersionConstraint(constraint) + } + return nil } diff --git a/pkg/config/version_constraint_test.go b/pkg/config/version_constraint_test.go new file mode 100644 index 0000000000..f63b6be17d --- /dev/null +++ b/pkg/config/version_constraint_test.go @@ -0,0 +1,325 @@ +package config + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/version" +) + +func TestValidateVersionConstraint(t *testing.T) { + tests := []struct { + name string + currentVersion string + constraint schema.VersionConstraint + envEnforcement string + expectError bool + expectedSentinel error + expectWarningOnly bool + }{ + // No constraint specified. + { + name: "no constraint specified", + currentVersion: "2.5.0", + constraint: schema.VersionConstraint{ + Require: "", + }, + expectError: false, + }, + + // Constraint satisfied. + { + name: "constraint satisfied minimum", + currentVersion: "2.5.0", + constraint: schema.VersionConstraint{ + Require: ">=1.0.0", + Enforcement: "fatal", + }, + expectError: false, + }, + { + name: "constraint satisfied range", + currentVersion: "2.5.0", + constraint: schema.VersionConstraint{ + Require: ">=2.0.0, <3.0.0", + Enforcement: "fatal", + }, + expectError: false, + }, + + // Constraint not satisfied with fatal enforcement. + { + name: "fatal unsatisfied too old", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=2.5.0", + Enforcement: "fatal", + }, + expectError: true, + expectedSentinel: errUtils.ErrVersionConstraint, + }, + { + name: "fatal unsatisfied too new", + currentVersion: "3.0.0", + constraint: schema.VersionConstraint{ + Require: ">=2.0.0, <3.0.0", + Enforcement: "fatal", + }, + expectError: true, + expectedSentinel: errUtils.ErrVersionConstraint, + }, + + // Default enforcement is fatal. + { + name: "default enforcement is fatal", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + // No enforcement specified - should default to fatal. + }, + expectError: true, + expectedSentinel: errUtils.ErrVersionConstraint, + }, + + // Warn enforcement - no error returned. + { + name: "warn unsatisfied logs warning no error", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "warn", + }, + expectError: false, + expectWarningOnly: true, + }, + + // Silent enforcement - no validation. + { + name: "silent unsatisfied no output", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "silent", + }, + expectError: false, + }, + + // Environment variable override to warn. + { + name: "env override fatal to warn", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "fatal", + }, + envEnforcement: "warn", + expectError: false, + expectWarningOnly: true, + }, + + // Environment variable override to silent. + { + name: "env override fatal to silent", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "fatal", + }, + envEnforcement: "silent", + expectError: false, + }, + + // Invalid constraint syntax. + { + name: "invalid constraint syntax", + currentVersion: "2.5.0", + constraint: schema.VersionConstraint{ + Require: "invalid>>2.0", + Enforcement: "fatal", + }, + expectError: true, + expectedSentinel: errUtils.ErrInvalidVersionConstraint, + }, + + // Custom message included in error. + { + name: "custom message included", + currentVersion: "1.0.0", + constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "fatal", + Message: "Please contact #infrastructure for upgrade assistance.", + }, + expectError: true, + expectedSentinel: errUtils.ErrVersionConstraint, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original version and restore after test. + originalVersion := version.Version + defer func() { version.Version = originalVersion }() + version.Version = tt.currentVersion + + // Set environment variable if specified. + if tt.envEnforcement != "" { + t.Setenv("ATMOS_VERSION_ENFORCEMENT", tt.envEnforcement) + } + + err := validateVersionConstraint(tt.constraint) + + if tt.expectError { + assert.Error(t, err, "expected error but got none") + if tt.expectedSentinel != nil { + assert.True(t, errors.Is(err, tt.expectedSentinel), + "expected error to wrap %v, got %v", tt.expectedSentinel, err) + } + // Verify exit code is set. + exitCode := errUtils.GetExitCode(err) + assert.Equal(t, 1, exitCode, "expected exit code 1") + } else { + assert.NoError(t, err, "unexpected error: %v", err) + } + }) + } +} + +func TestValidateVersionConstraint_ExitCode(t *testing.T) { + // Save original version and restore after test. + originalVersion := version.Version + defer func() { version.Version = originalVersion }() + version.Version = "1.0.0" + + constraint := schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "fatal", + } + + err := validateVersionConstraint(constraint) + assert.Error(t, err) + + // Verify exit code is extracted correctly. + exitCode := errUtils.GetExitCode(err) + assert.Equal(t, 1, exitCode) +} + +func TestValidateVersionConstraint_InvalidSyntaxExitCode(t *testing.T) { + // Save original version and restore after test. + originalVersion := version.Version + defer func() { version.Version = originalVersion }() + version.Version = "2.5.0" + + constraint := schema.VersionConstraint{ + Require: ">=", + Enforcement: "fatal", + } + + err := validateVersionConstraint(constraint) + assert.Error(t, err) + assert.True(t, errors.Is(err, errUtils.ErrInvalidVersionConstraint)) + + // Verify exit code is extracted correctly. + exitCode := errUtils.GetExitCode(err) + assert.Equal(t, 1, exitCode) +} + +func TestValidateVersionConstraint_EnvOverridePrecedence(t *testing.T) { + // Save original version and restore after test. + originalVersion := version.Version + defer func() { version.Version = originalVersion }() + version.Version = "1.0.0" + + // Test that env var takes precedence over config. + tests := []struct { + name string + configEnforce string + envEnforce string + expectError bool + expectSilent bool + }{ + { + name: "env silent overrides config fatal", + configEnforce: "fatal", + envEnforce: "silent", + expectError: false, + expectSilent: true, + }, + { + name: "env warn overrides config fatal", + configEnforce: "fatal", + envEnforce: "warn", + expectError: false, + }, + { + name: "env fatal overrides config silent", + configEnforce: "silent", + envEnforce: "fatal", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ATMOS_VERSION_ENFORCEMENT", tt.envEnforce) + defer os.Unsetenv("ATMOS_VERSION_ENFORCEMENT") + + constraint := schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: tt.configEnforce, + } + + err := validateVersionConstraint(constraint) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckConfig_WithVersionConstraint(t *testing.T) { + // Save original version and restore after test. + originalVersion := version.Version + defer func() { version.Version = originalVersion }() + version.Version = "1.0.0" + + // Test that checkConfig propagates version constraint errors. + atmosConfig := schema.AtmosConfiguration{ + Stacks: schema.Stacks{ + BasePath: "/some/path", + IncludedPaths: []string{"/some/path"}, + }, + Version: schema.Version{ + Constraint: schema.VersionConstraint{ + Require: ">=99.0.0", + Enforcement: "fatal", + }, + }, + } + + err := checkConfig(atmosConfig, false) + assert.Error(t, err) + assert.True(t, errors.Is(err, errUtils.ErrVersionConstraint)) +} + +func TestCheckConfig_WithoutVersionConstraint(t *testing.T) { + // Test that checkConfig passes when no constraint is specified. + atmosConfig := schema.AtmosConfiguration{ + Stacks: schema.Stacks{ + BasePath: "/some/path", + IncludedPaths: []string{"/some/path"}, + }, + // No version constraint. + } + + err := checkConfig(atmosConfig, false) + assert.NoError(t, err) +} diff --git a/pkg/schema/version.go b/pkg/schema/version.go index f726658430..073b6a7cd5 100644 --- a/pkg/schema/version.go +++ b/pkg/schema/version.go @@ -1,11 +1,44 @@ package schema +// VersionCheck configures automatic version checking against GitHub releases. type VersionCheck struct { - Enabled bool `yaml:"enabled,omitempty" mapstructure:"enabled"` - Timeout int `yaml:"timeout,omitempty" mapstructure:"timeout"` - Frequency string `yaml:"frequency,omitempty" mapstructure:"frequency"` + Enabled bool `yaml:"enabled,omitempty" mapstructure:"enabled" json:"enabled,omitempty"` + Timeout int `yaml:"timeout,omitempty" mapstructure:"timeout" json:"timeout,omitempty"` + Frequency string `yaml:"frequency,omitempty" mapstructure:"frequency" json:"frequency,omitempty"` } +// VersionConstraint configures version constraint validation for Atmos. +// When specified, Atmos validates that the current version satisfies the constraint +// before executing any command. +type VersionConstraint struct { + // Require specifies the semver constraint(s) for Atmos version as a single string. + // Multiple constraints are comma-separated and treated as logical AND. + // Uses hashicorp/go-version library syntax (same as Terraform). + // + // Examples: + // ">=1.2.3" - Minimum version + // "<2.0.0" - Maximum version (exclude) + // ">=1.2.3, <2.0.0" - Range (AND logic) + // ">=2.5.0, !=2.7.0, <3.0.0" - Complex (multiple AND constraints) + // "~>1.2" - Pessimistic constraint (>=1.2.0, <1.3.0) + // "~>1.2.3" - Pessimistic constraint (>=1.2.3, <1.3.0) + // "1.2.3" - Exact version + Require string `yaml:"require,omitempty" mapstructure:"require" json:"require,omitempty"` + + // Enforcement specifies the behavior when version constraint is not satisfied. + // Values: + // "fatal" - Exit immediately with error code 1 (default) + // "warn" - Log warning but continue execution + // "silent" - Skip validation entirely (for debugging) + Enforcement string `yaml:"enforcement,omitempty" mapstructure:"enforcement" json:"enforcement,omitempty"` + + // Message provides a custom message to display when constraint fails. + // If empty, a default message is shown. + Message string `yaml:"message,omitempty" mapstructure:"message" json:"message,omitempty"` +} + +// Version configures version checking and constraint validation. type Version struct { - Check VersionCheck `yaml:"check,omitempty" mapstructure:"check"` + Check VersionCheck `yaml:"check,omitempty" mapstructure:"check" json:"check,omitempty"` + Constraint VersionConstraint `yaml:"constraint,omitempty" mapstructure:"constraint" json:"constraint,omitempty"` } diff --git a/pkg/version/constraint.go b/pkg/version/constraint.go new file mode 100644 index 0000000000..7885d1f447 --- /dev/null +++ b/pkg/version/constraint.go @@ -0,0 +1,40 @@ +package version + +import ( + "fmt" + + goversion "github.com/hashicorp/go-version" + + "github.com/cloudposse/atmos/pkg/perf" +) + +// ValidateConstraint checks if the current Atmos version satisfies the given constraint. +// Returns (satisfied bool, error). If constraintStr is empty, returns (true, nil). +// +// The constraint string uses hashicorp/go-version syntax (same as Terraform): +// - ">=1.2.3" - Minimum version +// - "<2.0.0" - Maximum version (exclusive) +// - ">=1.2.3, <2.0.0" - Range (AND logic) +// - ">=2.5.0, !=2.7.0, <3.0.0" - Complex (multiple AND constraints) +// - "~>1.2" - Pessimistic constraint (>=1.2.0, <1.3.0) +// - "~>1.2.3" - Pessimistic constraint (>=1.2.3, <1.3.0) +// - "1.2.3" - Exact version +func ValidateConstraint(constraintStr string) (bool, error) { + defer perf.Track(nil, "version.ValidateConstraint")() + + if constraintStr == "" { + return true, nil + } + + current, err := goversion.NewVersion(Version) + if err != nil { + return false, fmt.Errorf("invalid current version %q: %w", Version, err) + } + + constraints, err := goversion.NewConstraint(constraintStr) + if err != nil { + return false, fmt.Errorf("invalid version constraint %q: %w", constraintStr, err) + } + + return constraints.Check(current), nil +} diff --git a/pkg/version/constraint_test.go b/pkg/version/constraint_test.go new file mode 100644 index 0000000000..fdf74d4a4b --- /dev/null +++ b/pkg/version/constraint_test.go @@ -0,0 +1,299 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateConstraint(t *testing.T) { + tests := []struct { + name string + currentVersion string + constraint string + expectPass bool + expectError bool + }{ + // Empty constraint always passes. + { + name: "empty constraint always passes", + currentVersion: "1.0.0", + constraint: "", + expectPass: true, + expectError: false, + }, + + // Minimum version constraints (>=). + { + name: "minimum version satisfied", + currentVersion: "2.6.0", + constraint: ">=2.5.0", + expectPass: true, + expectError: false, + }, + { + name: "minimum version exact match", + currentVersion: "2.5.0", + constraint: ">=2.5.0", + expectPass: true, + expectError: false, + }, + { + name: "minimum version not satisfied", + currentVersion: "2.4.0", + constraint: ">=2.5.0", + expectPass: false, + expectError: false, + }, + + // Maximum version constraints (<). + { + name: "maximum version satisfied", + currentVersion: "2.9.0", + constraint: "<3.0.0", + expectPass: true, + expectError: false, + }, + { + name: "maximum version not satisfied boundary", + currentVersion: "3.0.0", + constraint: "<3.0.0", + expectPass: false, + expectError: false, + }, + { + name: "maximum version not satisfied above", + currentVersion: "3.1.0", + constraint: "<3.0.0", + expectPass: false, + expectError: false, + }, + + // Range constraints (>=, <). + { + name: "range satisfied middle", + currentVersion: "2.7.0", + constraint: ">=2.5.0, <3.0.0", + expectPass: true, + expectError: false, + }, + { + name: "range satisfied lower bound", + currentVersion: "2.5.0", + constraint: ">=2.5.0, <3.0.0", + expectPass: true, + expectError: false, + }, + { + name: "range not satisfied too old", + currentVersion: "2.4.0", + constraint: ">=2.5.0, <3.0.0", + expectPass: false, + expectError: false, + }, + { + name: "range not satisfied too new", + currentVersion: "3.1.0", + constraint: ">=2.5.0, <3.0.0", + expectPass: false, + expectError: false, + }, + + // Pessimistic constraints (~>). + // Note: hashicorp/go-version uses: + // ~>2.5 means >=2.5.0, <3.0.0 (allows any 2.x >= 2.5) + // ~>2.5.3 means >=2.5.3, <2.6.0 (allows any 2.5.x >= 2.5.3) + { + name: "pessimistic two-segment satisfied same minor", + currentVersion: "2.5.3", + constraint: "~>2.5", + expectPass: true, + expectError: false, + }, + { + name: "pessimistic two-segment satisfied next minor", + currentVersion: "2.9.0", + constraint: "~>2.5", + expectPass: true, + expectError: false, + }, + { + name: "pessimistic two-segment not satisfied next major", + currentVersion: "3.0.0", + constraint: "~>2.5", + expectPass: false, + expectError: false, + }, + { + name: "pessimistic three-segment satisfied", + currentVersion: "2.5.5", + constraint: "~>2.5.3", + expectPass: true, + expectError: false, + }, + { + name: "pessimistic three-segment not satisfied next minor", + currentVersion: "2.6.0", + constraint: "~>2.5.3", + expectPass: false, + expectError: false, + }, + + // Exact version match. + { + name: "exact version match", + currentVersion: "2.5.0", + constraint: "2.5.0", + expectPass: true, + expectError: false, + }, + { + name: "exact version mismatch patch", + currentVersion: "2.5.1", + constraint: "2.5.0", + expectPass: false, + expectError: false, + }, + { + name: "exact version mismatch minor", + currentVersion: "2.6.0", + constraint: "2.5.0", + expectPass: false, + expectError: false, + }, + + // Exclusion constraints (!=). + { + name: "exclusion satisfied", + currentVersion: "2.6.0", + constraint: "!=2.7.0", + expectPass: true, + expectError: false, + }, + { + name: "exclusion not satisfied", + currentVersion: "2.7.0", + constraint: "!=2.7.0", + expectPass: false, + expectError: false, + }, + + // Complex constraints. + { + name: "complex constraint satisfied", + currentVersion: "2.8.0", + constraint: ">=2.5.0, !=2.7.0, <3.0.0", + expectPass: true, + expectError: false, + }, + { + name: "complex constraint not satisfied excluded version", + currentVersion: "2.7.0", + constraint: ">=2.5.0, !=2.7.0, <3.0.0", + expectPass: false, + expectError: false, + }, + { + name: "complex constraint not satisfied too old", + currentVersion: "2.4.0", + constraint: ">=2.5.0, !=2.7.0, <3.0.0", + expectPass: false, + expectError: false, + }, + + // Invalid constraint syntax. + { + name: "invalid constraint syntax", + currentVersion: "2.5.0", + constraint: "invalid>>2.0", + expectPass: false, + expectError: true, + }, + { + name: "malformed constraint", + currentVersion: "2.5.0", + constraint: ">=", + expectPass: false, + expectError: true, + }, + + // Invalid current version. + { + name: "invalid current version", + currentVersion: "not-semver", + constraint: ">=2.5.0", + expectPass: false, + expectError: true, + }, + + // Edge cases. + { + name: "prerelease version", + currentVersion: "2.5.0-beta.1", + constraint: ">=2.5.0", + expectPass: false, + expectError: false, + }, + { + name: "version with build metadata", + currentVersion: "2.5.0+build.123", + constraint: ">=2.5.0", + expectPass: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original version and restore after test. + originalVersion := Version + defer func() { Version = originalVersion }() + + Version = tt.currentVersion + + pass, err := ValidateConstraint(tt.constraint) + + if tt.expectError { + assert.Error(t, err, "expected error but got none") + } else { + assert.NoError(t, err, "unexpected error: %v", err) + } + + assert.Equal(t, tt.expectPass, pass, "expected pass=%v, got pass=%v", tt.expectPass, pass) + }) + } +} + +func TestValidateConstraint_EmptyVersion(t *testing.T) { + // Test with empty version string (invalid). + originalVersion := Version + defer func() { Version = originalVersion }() + + Version = "" + + pass, err := ValidateConstraint(">=1.0.0") + assert.Error(t, err, "expected error for empty version") + assert.False(t, pass, "expected pass=false for empty version") +} + +func TestValidateConstraint_ConcurrentAccess(t *testing.T) { + // Ensure the function is safe for concurrent calls. + originalVersion := Version + defer func() { Version = originalVersion }() + + Version = "2.5.0" + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + pass, err := ValidateConstraint(">=2.0.0, <3.0.0") + assert.NoError(t, err) + assert.True(t, pass) + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } +} diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden index 93d87e5079..a822cfd661 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -178,11 +178,8 @@ "packerDirAbsolutePath": "/absolute/path/to/repo/examples/demo-stacks", "default": false, "version": { - "Check": { - "Enabled": false, - "Timeout": 0, - "Frequency": "" - } + "check": {}, + "constraint": {} }, "validate": { "editorconfig": {} diff --git a/website/blog/2025-12-01-version-constraint-validation.mdx b/website/blog/2025-12-01-version-constraint-validation.mdx new file mode 100644 index 0000000000..c5bf7b30db --- /dev/null +++ b/website/blog/2025-12-01-version-constraint-validation.mdx @@ -0,0 +1,67 @@ +--- +slug: version-constraint-validation +title: "Enforce Atmos Version Requirements with Version Constraints" +authors: + - osterman +tags: + - feature + - dx +date: 2025-12-01T00:00:00.000Z +--- + +Atmos now supports version constraint validation, allowing you to specify required Atmos version ranges in your `atmos.yaml` configuration. When your configuration requires specific features or behaviors, you can ensure all team members and CI/CD pipelines use compatible Atmos versions. + + + +## The Problem + +Teams using Atmos often face version consistency challenges: + +- **Feature availability** - Newer configurations may use features that don't exist in older Atmos versions +- **Breaking changes** - Running an incompatible version can cause confusing errors +- **Environment drift** - CI pipelines, local development, and containers may run different versions +- **No clear signal** - Users don't know when upgrading is required vs. recommended + +## The Solution + +Add a `version.constraint` section to your `atmos.yaml`: + +```yaml +version: + constraint: + require: ">=1.100.0, <2.0.0" + enforcement: "fatal" + message: "Please upgrade Atmos to continue." +``` + +Atmos validates the constraint at startup. If the current version doesn't satisfy the requirement: + +- **`fatal`** (default) - Exit with a helpful error message +- **`warn`** - Show a warning and continue +- **`silent`** - Skip validation (for debugging) + +## Constraint Syntax + +Uses the same syntax as Terraform version constraints: + +| Constraint | Meaning | +|------------|---------| +| `>=1.100.0` | Minimum version | +| `<2.0.0` | Maximum version (exclusive) | +| `>=1.100.0, <2.0.0` | Version range | +| `~>1.100` | Pessimistic (>=1.100.0, <2.0.0) | +| `!=1.150.0` | Exclude specific version | + +## Override for Debugging + +Use the environment variable to temporarily bypass constraints: + +```bash +ATMOS_VERSION_ENFORCEMENT=warn atmos terraform plan +``` + +## Get Started + +Add version constraints to your `atmos.yaml` today to ensure consistent Atmos versions across your team and infrastructure. + +See the [Version Constraints documentation](/cli/configuration/version/constraint) for complete details. diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index 097b56db6a..ba322dee14 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -410,3 +410,4 @@ See [`atmos describe config`](/cli/commands/describe/config) for more details. - [Profiles](/cli/configuration/profiles) — Environment-specific configuration overrides - [Global Flags](/cli/global-flags) — Command-line flags that affect configuration +- [Version Constraints](/cli/configuration/version/constraint) — Enforce Atmos version requirements diff --git a/website/docs/cli/configuration/version/_category_.json b/website/docs/cli/configuration/version/_category_.json new file mode 100644 index 0000000000..f133f058c6 --- /dev/null +++ b/website/docs/cli/configuration/version/_category_.json @@ -0,0 +1,10 @@ +{ + "label": "version", + "position": 90, + "collapsed": false, + "link": { + "type": "doc", + "id": "version" + }, + "className": "command" +} diff --git a/website/docs/cli/configuration/version.mdx b/website/docs/cli/configuration/version/check.mdx similarity index 96% rename from website/docs/cli/configuration/version.mdx rename to website/docs/cli/configuration/version/check.mdx index 908f26c2a3..47f616c020 100644 --- a/website/docs/cli/configuration/version.mdx +++ b/website/docs/cli/configuration/version/check.mdx @@ -1,11 +1,11 @@ --- -title: Version -sidebar_position: 90 -sidebar_label: version +title: Version Check +sidebar_position: 1 +sidebar_label: check sidebar_class_name: command description: Configure automatic version checking and update notifications for Atmos CLI. -id: version -slug: /cli/configuration/version +id: check +slug: /cli/configuration/version/check --- import Intro from '@site/src/components/Intro' import File from '@site/src/components/File' diff --git a/website/docs/cli/configuration/version/constraint.mdx b/website/docs/cli/configuration/version/constraint.mdx new file mode 100644 index 0000000000..326e41d82c --- /dev/null +++ b/website/docs/cli/configuration/version/constraint.mdx @@ -0,0 +1,253 @@ +--- +title: Version Constraints +sidebar_position: 2 +sidebar_label: constraint +sidebar_class_name: command +description: Enforce Atmos version requirements in your configuration. +id: constraint +slug: /cli/configuration/version/constraint +--- +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' +import Terminal from '@site/src/components/Terminal' + + +Version constraints allow you to specify required Atmos version ranges in your `atmos.yaml` configuration, ensuring consistent behavior across teams, CI/CD pipelines, and different environments. + + +## Overview + +When your configuration relies on specific Atmos features or behaviors, version constraints ensure that: + +- Team members use compatible Atmos versions +- CI/CD pipelines fail early if the wrong version is installed +- Newer configurations don't silently fail on older Atmos versions +- Breaking changes are caught before causing confusing errors + +## Configuration + +Add a `version.constraint` section to your `atmos.yaml`: + + +```yaml +version: + constraint: + require: ">=1.100.0" + enforcement: "fatal" + message: "Please upgrade Atmos to use this configuration." +``` + + +
+
`version.constraint.require`
+
+ A semver constraint string specifying the required Atmos version(s). Uses the same syntax as Terraform version constraints. Multiple constraints are comma-separated and combined with AND logic. +
+ +
`version.constraint.enforcement`
+
+ Controls behavior when the constraint is not satisfied: +
+
`fatal` (default)
+
Exit immediately with error code 1
+
`warn`
+
Show a warning but continue execution
+
`silent`
+
Skip validation entirely (useful for debugging)
+
+
+ +
`version.constraint.message`
+
+ Optional custom message displayed when the constraint fails. Use this to provide team-specific upgrade instructions or context. +
+
+ +## Constraint Syntax + +Version constraints use [hashicorp/go-version](https://github.com/hashicorp/go-version) syntax, which is the same as Terraform: + +| Constraint | Meaning | Example | +|------------|---------|---------| +| `>=1.2.3` | Greater than or equal | `>=1.100.0` | +| `<=1.2.3` | Less than or equal | `<=1.200.0` | +| `>1.2.3` | Greater than (exclusive) | `>1.99.0` | +| `<1.2.3` | Less than (exclusive) | `<2.0.0` | +| `1.2.3` | Exact version | `1.200.0` | +| `!=1.2.3` | Not equal (exclude version) | `!=1.150.0` | +| `~>1.2` | Pessimistic (>=1.2.0, <2.0.0) | `~>1.100` | +| `~>1.2.3` | Pessimistic (>=1.2.3, <1.3.0) | `~>1.100.0` | +| Multiple | Comma-separated AND | `>=1.100.0, <2.0.0` | + +### Pessimistic Constraint Operator + +The pessimistic constraint operator `~>` allows only the rightmost version component to increment: + +- `~>1.100` allows `1.100.0`, `1.150.0`, `1.200.0`, but not `2.0.0` +- `~>1.100.0` allows `1.100.0`, `1.100.1`, `1.100.9`, but not `1.101.0` + +## Examples + +### Minimum Version Requirement + +Ensure users have at least version 1.100.0: + + +```yaml +version: + constraint: + require: ">=1.100.0" +``` + + +### Version Range + +Require a specific major version range: + + +```yaml +version: + constraint: + require: ">=1.100.0, <2.0.0" + enforcement: "fatal" + message: | + This configuration requires Atmos 1.x. + + To upgrade: brew upgrade atmos + Or download from: https://github.com/cloudposse/atmos/releases +``` + + +### Exclude Problematic Version + +Skip a version with known issues: + + +```yaml +version: + constraint: + require: ">=1.100.0, !=1.150.0, <2.0.0" +``` + + +### Soft Requirement (Warning Only) + +Warn about unsupported versions without blocking execution: + + +```yaml +version: + constraint: + require: ">=1.100.0, <1.150.0" + enforcement: "warn" + message: | + You are using an untested Atmos version. + This configuration is validated against Atmos 1.100.0-1.149.x. + Proceed at your own risk. +``` + + +### Combined with Version Checking + +Use both version constraints and update checking: + + +```yaml +version: + # Check for new releases periodically + check: + enabled: true + frequency: "24h" + + # Require minimum version + constraint: + require: ">=1.100.0" + enforcement: "fatal" +``` + + +## Environment Variable Override + +Override the enforcement level at runtime using `ATMOS_VERSION_ENFORCEMENT`: + +```bash +# Downgrade to warning +ATMOS_VERSION_ENFORCEMENT=warn atmos terraform plan + +# Disable validation entirely +ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan +``` + +**Precedence order:** +1. `ATMOS_VERSION_ENFORCEMENT` environment variable (highest) +2. `version.constraint.enforcement` in `atmos.yaml` +3. Default value `"fatal"` (lowest) + +## Error Messages + +### Fatal Constraint Violation + +When enforcement is `fatal` and the constraint is not satisfied: + + +``` +# Error + +**Error:** version constraint not satisfied + +## Explanation + +This configuration requires Atmos version >=1.100.0, but you are running 1.50.0 + +## Hints + +💡 Please upgrade: https://atmos.tools/install + +## Context + +• required=>=1.100.0 current=1.50.0 +``` + + +### Warning + +When enforcement is `warn`: + + +``` +⚠ Atmos version constraint not satisfied + Required: >=1.100.0, <2.0.0 + Current: 2.0.0 + +This stack configuration is tested with Atmos 1.x. +``` + + +### Invalid Constraint Syntax + +Invalid constraint syntax is always a fatal error: + + +``` +# Error + +**Error:** invalid version constraint + +## Hints + +💡 Please use valid semver constraint syntax +💡 Reference: https://github.com/hashicorp/go-version + +## Context + +• constraint=invalid>>2.0 +``` + + +## Best Practices + +1. **Start with minimum version** - Use `>=X.Y.Z` to ensure feature availability +2. **Add upper bound for stability** - Use ` +The `version` section configures how Atmos handles version management, including automatic update checking and version constraint enforcement to ensure consistent behavior across teams and environments. + + +## Configuration + + +```yaml +version: + # Automatic update checking + check: + enabled: true + frequency: daily + + # Version constraint enforcement + constraint: + require: ">=1.100.0" + enforcement: fatal + message: "Please upgrade Atmos to use this configuration." +``` + + +## Version Sections + +| Section | Description | +|---------|-------------| +| [Check](/cli/configuration/version/check) | Automatic version checking and update notifications | +| [Constraint](/cli/configuration/version/constraint) | Enforce Atmos version requirements for your configuration | + +## Environment Variables + +
+
`ATMOS_VERSION_CHECK_ENABLED`
+
Enable or disable automatic version checking.
+ +
`ATMOS_VERSION_ENFORCEMENT`
+
Override version constraint enforcement level: `fatal`, `warn`, or `silent`.
+
+ +## See Also + +- [CLI Configuration](/cli/configuration) — Overview of CLI configuration +- [`atmos version`](/cli/commands/version/usage) — Display version and check for updates diff --git a/website/docs/cli/versioning.mdx b/website/docs/cli/versioning.mdx index 3d2ebdf0d9..f7d156bb02 100644 --- a/website/docs/cli/versioning.mdx +++ b/website/docs/cli/versioning.mdx @@ -38,6 +38,19 @@ A patch release will be published when bug fixes were included, but no breaking To ensure patch releases can fix existing code without introducing new issues from the new features, patch releases will always be published prior to a minor release. +## Version Constraints + +You can enforce Atmos version requirements in your `atmos.yaml` configuration to ensure consistent behavior across teams and environments: + +```yaml +version: + constraint: + require: ">=1.100.0, <2.0.0" + enforcement: "fatal" +``` + +See [Version Constraints](/cli/configuration/version/constraint) for complete documentation. + ## Changelog To see a list of all notable changes to `atmos` please refer to the [changelog](/changelog).