Skip to content

Commit 62b8227

Browse files
ostermanclaude
andcommitted
feat: add version constraint validation for atmos.yaml
Add ability to enforce Atmos version requirements in atmos.yaml configuration. Teams can now ensure consistent Atmos versions across developers and CI/CD. Configuration: - version.constraint.require: semver constraint (e.g., ">=1.100.0, <2.0.0") - version.constraint.enforcement: fatal|warn|silent (default: fatal) - version.constraint.message: custom error message - ATMOS_VERSION_ENFORCEMENT env var override Uses hashicorp/go-version library (already in go.mod) for Terraform-compatible constraint syntax including pessimistic operator (~>). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 5f3262e commit 62b8227

File tree

12 files changed

+1265
-33
lines changed

12 files changed

+1265
-33
lines changed

CLAUDE.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ Follow template (what/why/references).
417417
**Blog Posts (CI Enforced):**
418418
- PRs labeled `minor`/`major` MUST include blog post: `website/blog/YYYY-MM-DD-feature-name.mdx`
419419
- Use `.mdx` with YAML front matter, `<!--truncate-->` after intro
420-
- Tags: User-facing (`feature`/`enhancement`/`bugfix`) or Contributors (`contributors`)
420+
- **ONLY use existing tags** - check `website/blog/*.mdx` for valid tags before writing
421421
- Author: committer's GitHub username, add to `website/blog/authors.yml`
422422

423423
**Blog Template:**
@@ -433,11 +433,14 @@ Brief intro.
433433
## What Changed / Why This Matters / How to Use It / Get Involved
434434
```
435435

436-
**Tag Reference:**
437-
- Primary: `feature`, `enhancement`, `bugfix`, `contributors`
438-
- Technical (contributor): `atmos-core`, `refactoring`, `testing`, `ci-cd`, `developer-experience`
439-
- Technical (user): `terraform`, `helmfile`, `workflows`, `validation`, `performance`, `cloud-architecture`
440-
- General: `announcements`, `breaking-changes`
436+
**Existing Tags (use only these):**
437+
- Primary: `feature`, `enhancement`, `bugfix`
438+
- Secondary: `dx`, `security`, `documentation`, `core`, `breaking-change`
439+
440+
**Finding valid tags:**
441+
```bash
442+
grep -h "^ - " website/blog/*.mdx | sort | uniq -c | sort -rn
443+
```
441444

442445
Use `no-release` label for docs-only changes.
443446

docs/prd/version-constraint.md

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,20 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
276276

277277
**File Changes:**
278278

279-
1. **`pkg/schema/version.go`** - Add `VersionConstraint` struct
279+
1. **`errors/errors.go`** - Add sentinel errors for version constraint validation
280+
```go
281+
// Version constraint errors.
282+
var (
283+
// ErrVersionConstraint indicates the current Atmos version does not satisfy
284+
// the version constraint specified in atmos.yaml.
285+
ErrVersionConstraint = errors.New("version constraint not satisfied")
286+
287+
// ErrInvalidVersionConstraint indicates the version constraint syntax is invalid.
288+
ErrInvalidVersionConstraint = errors.New("invalid version constraint")
289+
)
290+
```
291+
292+
2. **`pkg/schema/version.go`** - Add `VersionConstraint` struct
280293
```go
281294
type VersionConstraint struct {
282295
Require string `yaml:"require,omitempty" mapstructure:"require" json:"require,omitempty"`
@@ -290,7 +303,7 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
290303
}
291304
```
292305

293-
2. **`pkg/version/constraint.go`** (new file) - Validation logic
306+
3. **`pkg/version/constraint.go`** (new file) - Validation logic (returns errors, no deep exits)
294307
```go
295308
package version
296309

@@ -321,7 +334,7 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
321334
}
322335
```
323336

324-
3. **`pkg/version/constraint_test.go`** (new file) - Comprehensive tests
337+
4. **`pkg/version/constraint_test.go`** (new file) - Comprehensive tests
325338
```go
326339
package version
327340

@@ -426,7 +439,7 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
426439
}
427440
```
428441

429-
4. **`cmd/cmd_utils.go`** - Add validation call in `initConfig()`
442+
5. **`cmd/cmd_utils.go`** - Add validation call in `initConfig()`
430443
```go
431444
// Add after config is loaded, before any command execution
432445
func initConfig() error {
@@ -440,6 +453,8 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
440453
return nil
441454
}
442455

456+
// validateVersionConstraint uses the Atmos error builder pattern.
457+
// No deep exits - all errors are returned for proper propagation.
443458
func validateVersionConstraint(cfg *schema.AtmosConfiguration) error {
444459
constraint := cfg.Version.Constraint
445460

@@ -464,42 +479,60 @@ ATMOS_VERSION_ENFORCEMENT=silent atmos terraform plan
464479
return nil
465480
}
466481

467-
// Validate constraint
482+
// Validate constraint syntax
468483
satisfied, err := version.ValidateConstraint(constraint.Require)
469484
if err != nil {
470-
// Invalid constraint syntax is always fatal
471-
return fmt.Errorf("invalid version constraint in configuration: %w", err)
485+
// Invalid constraint syntax is always fatal - use error builder
486+
return errUtils.Build(errUtils.ErrInvalidVersionConstraint).
487+
WithHint("Please use valid semver constraint syntax").
488+
WithHint("Reference: https://github.com/hashicorp/go-version").
489+
WithContext("constraint", constraint.Require).
490+
WithExitCode(1).
491+
Wrap(err)
472492
}
473493

474494
if !satisfied {
475-
// Build error message
476-
msg := constraint.Message
477-
if msg == "" {
478-
msg = fmt.Sprintf(
479-
"This configuration requires Atmos version %s.\nPlease upgrade: https://atmos.tools/install",
480-
constraint.Require,
481-
)
495+
// Build hints for error message
496+
hints := []string{
497+
fmt.Sprintf("This configuration requires Atmos version %s", constraint.Require),
498+
"Please upgrade: https://atmos.tools/install",
482499
}
483500

484-
fullMsg := fmt.Sprintf(
485-
"Atmos version constraint not satisfied\n Required: %s\n Current: %s\n\n%s",
486-
constraint.Require,
487-
version.Version,
488-
msg,
489-
)
501+
// Add custom message as hint if provided
502+
if constraint.Message != "" {
503+
hints = append(hints, constraint.Message)
504+
}
490505

491506
if enforcement == "fatal" {
492-
return fmt.Errorf(fullMsg)
507+
// Use error builder for proper error handling
508+
builder := errUtils.Build(errUtils.ErrVersionConstraint).
509+
WithContext("required", constraint.Require).
510+
WithContext("current", version.Version).
511+
WithExitCode(1)
512+
513+
for _, hint := range hints {
514+
builder = builder.WithHint(hint)
515+
}
516+
517+
return builder.Err()
493518
} else if enforcement == "warn" {
494-
ui.Warning(fullMsg)
519+
// Warnings still go to UI, but no error returned
520+
ui.Warning(fmt.Sprintf(
521+
"Atmos version constraint not satisfied\n Required: %s\n Current: %s",
522+
constraint.Require,
523+
version.Version,
524+
))
525+
if constraint.Message != "" {
526+
ui.Warning(constraint.Message)
527+
}
495528
}
496529
}
497530

498531
return nil
499532
}
500533
```
501534

502-
5. **`pkg/datafetcher/schema/atmos/1.0.json`** - Update JSON schema
535+
6. **`pkg/datafetcher/schema/atmos/1.0.json`** - Update JSON schema
503536
```json
504537
{
505538
"version": {
@@ -660,6 +693,46 @@ VERSION PUBLISHED TYPE NOTES
660693
Use --constraint-aware to filter results
661694
```
662695
696+
## Error Handling
697+
698+
### No Deep Exits
699+
700+
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.
701+
702+
All errors are propagated up the call stack using Go's standard error return pattern, allowing:
703+
- Proper error wrapping with context
704+
- Consistent error formatting via the error builder
705+
- Exit codes set via `errUtils.WithExitCode()`
706+
- Testability without mocking `os.Exit()`
707+
708+
```go
709+
// CORRECT: Use error builder pattern
710+
func validateVersionConstraint(cfg *schema.AtmosConfiguration) error {
711+
// ... validation logic ...
712+
713+
if !satisfied {
714+
return errUtils.Build(errUtils.ErrVersionConstraint).
715+
WithHint(fmt.Sprintf("This configuration requires Atmos version %s", constraint.Require)).
716+
WithHint("Please upgrade: https://atmos.tools/install").
717+
WithContext("required", constraint.Require).
718+
WithContext("current", version.Version).
719+
WithExitCode(1).
720+
Err()
721+
}
722+
return nil
723+
}
724+
725+
// WRONG: Never use deep exits
726+
func validateVersionConstraint(cfg *schema.AtmosConfiguration) {
727+
// ... validation logic ...
728+
729+
if !satisfied {
730+
fmt.Fprintf(os.Stderr, "Version mismatch\n")
731+
os.Exit(1) // ❌ NEVER do this
732+
}
733+
}
734+
```
735+
663736
## Error Messages
664737
665738
**Fatal Error (enforcement: fatal):**

errors/errors.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ var (
301301
ErrVersionCacheLoadFailed = errors.New("failed to load version check cache")
302302
ErrVersionGitHubAPIFailed = errors.New("failed to query GitHub API for releases")
303303

304+
// Version constraint errors.
305+
ErrVersionConstraint = errors.New("version constraint not satisfied")
306+
ErrInvalidVersionConstraint = errors.New("invalid version constraint")
307+
304308
// Atlantis errors.
305309
ErrAtlantisInvalidFlags = errors.New("incompatible atlantis flags")
306310
ErrAtlantisProjectTemplateNotDef = errors.New("atlantis project template is not defined")

pkg/config/utils.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import (
88
"strconv"
99
"strings"
1010

11+
errUtils "github.com/cloudposse/atmos/errors"
1112
log "github.com/cloudposse/atmos/pkg/logger"
1213
"github.com/cloudposse/atmos/pkg/perf"
1314
"github.com/cloudposse/atmos/pkg/schema"
1415
"github.com/cloudposse/atmos/pkg/store"
16+
"github.com/cloudposse/atmos/pkg/ui"
1517
u "github.com/cloudposse/atmos/pkg/utils"
18+
"github.com/cloudposse/atmos/pkg/version"
1619
)
1720

1821
// 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
434437
}
435438
}
436439

440+
// Validate version constraint.
441+
if err := validateVersionConstraint(atmosConfig.Version.Constraint); err != nil {
442+
return err
443+
}
444+
445+
return nil
446+
}
447+
448+
// getVersionEnforcement returns the enforcement level, checking env var override.
449+
func getVersionEnforcement(configEnforcement string) string {
450+
if envEnforcement := os.Getenv("ATMOS_VERSION_ENFORCEMENT"); envEnforcement != "" { //nolint:forbidigo
451+
return envEnforcement
452+
}
453+
if configEnforcement == "" {
454+
return "fatal"
455+
}
456+
return configEnforcement
457+
}
458+
459+
// buildVersionConstraintError builds the error for unsatisfied version constraint.
460+
func buildVersionConstraintError(constraint schema.VersionConstraint) error {
461+
builder := errUtils.Build(errUtils.ErrVersionConstraint).
462+
WithExplanationf("This configuration requires Atmos version %s, but you are running %s",
463+
constraint.Require, version.Version).
464+
WithHint("Please upgrade: https://atmos.tools/install").
465+
WithContext("required", constraint.Require).
466+
WithContext("current", version.Version).
467+
WithExitCode(1)
468+
469+
if constraint.Message != "" {
470+
builder = builder.WithHint(constraint.Message)
471+
}
472+
473+
return builder.Err()
474+
}
475+
476+
// warnVersionConstraint logs a warning for unsatisfied version constraint.
477+
func warnVersionConstraint(constraint schema.VersionConstraint) {
478+
_ = ui.Warning(fmt.Sprintf(
479+
"Atmos version constraint not satisfied\n Required: %s\n Current: %s",
480+
constraint.Require,
481+
version.Version,
482+
))
483+
if constraint.Message != "" {
484+
_ = ui.Warning(constraint.Message)
485+
}
486+
}
487+
488+
// validateVersionConstraint validates the current Atmos version against the constraint
489+
// specified in atmos.yaml. Uses the Atmos error builder pattern - no deep exits.
490+
func validateVersionConstraint(constraint schema.VersionConstraint) error {
491+
if constraint.Require == "" {
492+
return nil
493+
}
494+
495+
enforcement := getVersionEnforcement(constraint.Enforcement)
496+
if enforcement == "silent" {
497+
return nil
498+
}
499+
500+
satisfied, err := version.ValidateConstraint(constraint.Require)
501+
if err != nil {
502+
return errUtils.Build(errors.Join(errUtils.ErrInvalidVersionConstraint, err)).
503+
WithHint("Please use valid semver constraint syntax").
504+
WithHint("Reference: https://github.com/hashicorp/go-version").
505+
WithContext("constraint", constraint.Require).
506+
WithExitCode(1).
507+
Err()
508+
}
509+
510+
if satisfied {
511+
return nil
512+
}
513+
514+
switch enforcement {
515+
case "fatal":
516+
return buildVersionConstraintError(constraint)
517+
case "warn":
518+
warnVersionConstraint(constraint)
519+
}
520+
437521
return nil
438522
}
439523

0 commit comments

Comments
 (0)