Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4aa5402
[AP-163] feat: AP-163 send raw command + exit_code to Atmos Pro, exte…
milldr Mar 17, 2026
ce193ba
[AP-163] docs: remove internal references from instance status PRD
milldr Mar 17, 2026
89b0fa9
Merge branch 'main' into feat/ap-163-instance-status-hook
milldr Mar 18, 2026
22ca16d
[AP-163] fix: read upload-status flag from Cobra/Viper instead of raw…
milldr Mar 18, 2026
304eafe
[AP-163] fix: treat plan exit code 2 as success after upload-status c…
milldr Mar 18, 2026
2cf971f
Merge branch 'main' into feat/ap-163-instance-status-hook
milldr Mar 19, 2026
6ffd326
[AP-163] fix: only upload instance status when --upload-status flag i…
milldr Mar 19, 2026
56747b2
[AP-163] feat: add configurable CI exit code mapping under components…
milldr Mar 19, 2026
182650f
[AP-163] docs: add blog post and roadmap entry for instance status up…
milldr Mar 19, 2026
2bf0650
[AP-163] docs: rewrite blog post to focus on Atmos Pro dashboard value
milldr Mar 19, 2026
b97b997
Apply suggestion from @milldr
milldr Mar 19, 2026
920998a
Apply suggestion from @milldr
milldr Mar 19, 2026
cf54952
[AP-163] test: regenerate golden snapshots for TerraformCI schema add…
milldr Mar 19, 2026
ffb8fdc
[AP-163] fix: detect output-only changes in CI plan summary parser
milldr Mar 19, 2026
369575d
[AP-163] security: remove leaked Atmos Pro token from golden snapshots
milldr Mar 19, 2026
a2c1ccf
[AP-163] feat: default CI exit code mapping (0→success, 1→failure, 2→…
milldr Mar 19, 2026
9417cad
[AP-163] fix: HasChanges template method must include parser result f…
milldr Mar 19, 2026
1d13b96
[AP-163] revert: output-only changes should report as no changes in C…
milldr Mar 19, 2026
94c76f9
[AP-163] feat: include Atmos version in instance status upload payload
milldr Mar 20, 2026
542c826
[AP-163] feat: include OS and architecture in instance status upload …
milldr Mar 20, 2026
3a09326
[AP-163] docs: update blog post author and scope to Atmos Pro users
milldr Mar 20, 2026
84719d1
Merge branch 'main' into feat/ap-163-instance-status-hook
milldr Mar 20, 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
4 changes: 4 additions & 0 deletions cmd/terraform/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type TerraformRunOptions struct {
Components []string
All bool
Affected bool

// Status upload flag.
UploadStatus bool
}

// ParseTerraformRunOptions parses shared terraform flags from Viper.
Expand All @@ -57,5 +60,6 @@ func ParseTerraformRunOptions(v *viper.Viper) *TerraformRunOptions {
Components: v.GetStringSlice("components"),
All: v.GetBool("all"),
Affected: v.GetBool("affected"),
UploadStatus: v.GetBool("upload-status"),
}
}
1 change: 1 addition & 0 deletions cmd/terraform/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ func applyOptionsToInfo(info *schema.ConfigAndStacksInfo, opts *TerraformRunOpti
info.Components = opts.Components
info.DryRun = opts.DryRun
info.SkipInit = opts.SkipInit
info.UploadStatus = opts.UploadStatus
info.All = opts.All
info.Affected = opts.Affected
info.Query = opts.Query
Expand Down
68 changes: 68 additions & 0 deletions docs/prd/instance-status-raw-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Raw Instance Status Upload to Atmos Pro

## Overview

Previously, the Atmos CLI interpreted terraform exit codes locally — mapping them to status strings like `"in_sync"` or `"drifted"` — before sending the result to Atmos Pro. This meant Atmos Pro never saw the raw data, status interpretation was coupled to CLI releases, and error exit codes were silently dropped.

This change makes the CLI a dumb pipe: it sends the raw command (`plan` or `apply`) and exit code to Atmos Pro, which interprets them server-side. This lets Atmos Pro evolve status logic without requiring CLI updates. The upload is also extended from plan-only to both plan and apply, eliminating the "Unknown" status problem on the dashboard.

## What Changed in the CLI

1. **DTO** (`pkg/pro/dtos/instances.go`): Replaced `HasDrift bool` with `Command string` + `ExitCode int`.
2. **API client** (`pkg/pro/api_client_instance_status.go`): Sends `{ command, exit_code }` instead of `{ status }`.
3. **Upload logic** (`internal/exec/pro.go`): Removed exit code filtering (previously skipped non-0/non-2). Removed `HasDrift` interpretation. Passes raw `SubCommand` and `exitCode`.
4. **Upload scope** (`internal/exec/pro.go`): `shouldUploadStatus()` now accepts `apply` in addition to `plan`.
5. **Upload trigger**: Both plan and apply require the explicit `--upload-status` flag.
6. **Flag reading**: The `--upload-status` flag is read from Cobra/Viper via `info.UploadStatus`, with a fallback to parsing `AdditionalArgsAndFlags` for backward compatibility with legacy code paths.

## CI Exit Code Mapping

Atmos always preserves the real exit code from terraform subcommands. When `--upload-status` is used with `terraform plan`, Atmos adds `--detailed-exitcode`, which makes terraform return exit code 2 for "changes detected". In CI environments with `set -e`, this causes the workflow step to fail.

To handle this, Atmos supports a configurable exit code mapping under `components.terraform.ci`:

```yaml
ci:
enabled: true # global CI gate — must be true for mapping to apply

components:
terraform:
ci:
exit_codes:
0: true # exit 0 → success (exit 0)
1: false # exit 1 → failure (preserve exit 1)
2: true # exit 2 → success (exit 0)
```

**Behavior:**
- Only active when the global `ci.enabled` is true.
- After all side-effects (status upload) complete, the mapping is applied.
- If an exit code maps to `true`, Atmos exits 0 (success for the CI runner).
- If an exit code maps to `false` or is unmapped, the original exit code is preserved.
- The upload always sends the *original* exit code to Atmos Pro, never the remapped one.

**Design rationale:** The exit code mapping and the status upload are independent concerns. Upload captures what happened (raw data for Atmos Pro). The CI mapping controls what the *caller* (CI runner) sees. This separation means you can use CI exit code mapping without upload, and upload without CI exit code mapping.

## API Contract

The CLI sends a PATCH to `/api/v1/repos/{owner}/{repo}/instances?stack={stack}&component={component}` with:

```json
{
"command": "plan" | "apply",
"exit_code": <integer>,
"last_run": "<ISO 8601 datetime>"
}
```

Atmos Pro interprets exit codes server-side:

| Command | Exit Code | Instance Status |
|---------|-----------|-----------------|
| plan | 0 | `in_sync` |
| plan | 2 | `drifted` |
| plan | other | `error` |
| apply | 0 | `in_sync` |
| apply | != 0 | `error` |

The legacy `{ status }` format is still accepted for backward compatibility.
40 changes: 40 additions & 0 deletions internal/exec/ci_exit_codes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package exec

import (
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
)

// defaultCIExitCodes are the default exit code mappings when ci.enabled is true
// but components.terraform.ci.exit_codes is not explicitly configured.
// Exit code 0 (no changes) and 2 (changes detected) are treated as success;
// exit code 1 (error) is preserved as failure.
var defaultCIExitCodes = map[int]bool{
0: true,
1: false,
2: true,
}

// mapCIExitCode checks the CI exit code mapping and returns the remapped exit code.
// When global ci.enabled is true and components.terraform.ci.exit_codes maps the
// given exit code to true, it returns 0 (success). Otherwise the original exit code
// is returned unchanged. If no exit_codes are configured, sensible defaults apply.
func mapCIExitCode(atmosConfig *schema.AtmosConfiguration, exitCode int) int {
if !atmosConfig.CI.Enabled {
return exitCode
}

exitCodes := atmosConfig.Components.Terraform.CI.ExitCodes
if exitCodes == nil {
exitCodes = defaultCIExitCodes
}

if success, ok := exitCodes[exitCode]; ok && success {
log.Debug("CI exit code mapping: remapping to success",
"original_exit_code", exitCode,
)
return 0
}

return exitCode
}
91 changes: 91 additions & 0 deletions internal/exec/ci_exit_codes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package exec

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/cloudposse/atmos/pkg/schema"
)

func TestMapCIExitCode(t *testing.T) {
tests := []struct {
name string
ci schema.CIConfig
tf schema.TerraformCI
exitCode int
expected int
}{
{
name: "CI disabled preserves exit code",
ci: schema.CIConfig{Enabled: false},
tf: schema.TerraformCI{ExitCodes: map[int]bool{2: true}},
exitCode: 2,
expected: 2,
},
{
name: "CI enabled but no exit_codes map uses defaults (exit 2 → success)",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{},
exitCode: 2,
expected: 0,
},
{
name: "CI enabled but no exit_codes map uses defaults (exit 1 → failure)",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{},
exitCode: 1,
expected: 1,
},
{
name: "CI enabled with code mapped true returns 0",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true, 2: true}},
exitCode: 2,
expected: 0,
},
{
name: "CI enabled with code mapped false preserves exit code",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{ExitCodes: map[int]bool{1: false}},
exitCode: 1,
expected: 1,
},
{
name: "CI enabled with unmapped code preserves exit code",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true, 2: true}},
exitCode: 1,
expected: 1,
},
{
name: "CI enabled with exit 0 mapped true returns 0",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true}},
exitCode: 0,
expected: 0,
},
{
name: "exit code 0 without mapping preserves 0",
ci: schema.CIConfig{Enabled: true},
tf: schema.TerraformCI{ExitCodes: map[int]bool{2: true}},
exitCode: 0,
expected: 0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := &schema.AtmosConfiguration{
CI: tt.ci,
Components: schema.Components{
Terraform: schema.Terraform{
CI: tt.tf,
},
},
}
result := mapCIExitCode(config, tt.exitCode)
assert.Equal(t, tt.expected, result)
})
}
}
17 changes: 9 additions & 8 deletions internal/exec/pro.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"runtime"

"github.com/cloudposse/atmos/pkg/perf"

Expand All @@ -16,6 +17,7 @@ import (
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui/theme"
u "github.com/cloudposse/atmos/pkg/utils"
pkgversion "github.com/cloudposse/atmos/pkg/version"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -214,11 +216,6 @@ func executeProUnlock(a *ProUnlockCmdArgs, apiClient pro.AtmosProAPIClientInterf

// uploadStatus uploads the terraform results to the pro API.
func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.AtmosProAPIClientInterface, gitRepo git.GitRepoInterface) error {
// Only upload if exit code is 0 (no changes) or 2 (changes)
if exitCode != 0 && exitCode != 2 {
return nil
}

// Get the git repository info
repoInfo, err := gitRepo.GetLocalRepoInfo()
if err != nil {
Expand All @@ -242,14 +239,18 @@ func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.Atm
// Create the DTO
dto := dtos.InstanceStatusUploadRequest{
AtmosProRunID: atmosProRunID,
AtmosVersion: pkgversion.Version,
AtmosOS: runtime.GOOS,
AtmosArch: runtime.GOARCH,
GitSHA: gitSHA,
RepoURL: repoInfo.RepoUrl,
RepoName: repoInfo.RepoName,
RepoOwner: repoInfo.RepoOwner,
RepoHost: repoInfo.RepoHost,
Stack: info.Stack,
Component: info.Component,
HasDrift: exitCode == 2,
Command: info.SubCommand,
ExitCode: exitCode,
}

// Upload the status
Expand All @@ -262,8 +263,8 @@ func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.Atm

// shouldUploadStatus determines if status should be uploaded.
func shouldUploadStatus(info *schema.ConfigAndStacksInfo) bool {
// Only upload for plan command
if info.SubCommand != "plan" {
// Only upload for plan and apply commands.
if info.SubCommand != "plan" && info.SubCommand != "apply" {
return false
}

Expand Down
Loading
Loading