Skip to content

Commit bb6f71d

Browse files
authored
feat: AP-163 send raw instance status to Atmos Pro, extend to apply (#2216)
* [AP-163] feat: AP-163 send raw command + exit_code to Atmos Pro, extend upload to apply * [AP-163] docs: remove internal references from instance status PRD * [AP-163] fix: read upload-status flag from Cobra/Viper instead of raw args * [AP-163] fix: treat plan exit code 2 as success after upload-status completes * [AP-163] fix: only upload instance status when --upload-status flag is explicitly set * [AP-163] feat: add configurable CI exit code mapping under components.terraform.ci * [AP-163] docs: add blog post and roadmap entry for instance status upload * [AP-163] docs: rewrite blog post to focus on Atmos Pro dashboard value * Apply suggestion from @milldr * Apply suggestion from @milldr * [AP-163] test: regenerate golden snapshots for TerraformCI schema addition * [AP-163] fix: detect output-only changes in CI plan summary parser * [AP-163] security: remove leaked Atmos Pro token from golden snapshots * [AP-163] feat: default CI exit code mapping (0→success, 1→failure, 2→success) * [AP-163] fix: HasChanges template method must include parser result for output-only changes * [AP-163] revert: output-only changes should report as no changes in CI summary * [AP-163] feat: include Atmos version in instance status upload payload * [AP-163] feat: include OS and architecture in instance status upload payload * [AP-163] docs: update blog post author and scope to Atmos Pro users
1 parent 990b763 commit bb6f71d

22 files changed

+384
-99
lines changed

cmd/terraform/options.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type TerraformRunOptions struct {
3434
Components []string
3535
All bool
3636
Affected bool
37+
38+
// Status upload flag.
39+
UploadStatus bool
3740
}
3841

3942
// ParseTerraformRunOptions parses shared terraform flags from Viper.
@@ -57,5 +60,6 @@ func ParseTerraformRunOptions(v *viper.Viper) *TerraformRunOptions {
5760
Components: v.GetStringSlice("components"),
5861
All: v.GetBool("all"),
5962
Affected: v.GetBool("affected"),
63+
UploadStatus: v.GetBool("upload-status"),
6064
}
6165
}

cmd/terraform/utils.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ func applyOptionsToInfo(info *schema.ConfigAndStacksInfo, opts *TerraformRunOpti
294294
info.Components = opts.Components
295295
info.DryRun = opts.DryRun
296296
info.SkipInit = opts.SkipInit
297+
info.UploadStatus = opts.UploadStatus
297298
info.All = opts.All
298299
info.Affected = opts.Affected
299300
info.Query = opts.Query
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Raw Instance Status Upload to Atmos Pro
2+
3+
## Overview
4+
5+
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.
6+
7+
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.
8+
9+
## What Changed in the CLI
10+
11+
1. **DTO** (`pkg/pro/dtos/instances.go`): Replaced `HasDrift bool` with `Command string` + `ExitCode int`.
12+
2. **API client** (`pkg/pro/api_client_instance_status.go`): Sends `{ command, exit_code }` instead of `{ status }`.
13+
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`.
14+
4. **Upload scope** (`internal/exec/pro.go`): `shouldUploadStatus()` now accepts `apply` in addition to `plan`.
15+
5. **Upload trigger**: Both plan and apply require the explicit `--upload-status` flag.
16+
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.
17+
18+
## CI Exit Code Mapping
19+
20+
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.
21+
22+
To handle this, Atmos supports a configurable exit code mapping under `components.terraform.ci`:
23+
24+
```yaml
25+
ci:
26+
enabled: true # global CI gate — must be true for mapping to apply
27+
28+
components:
29+
terraform:
30+
ci:
31+
exit_codes:
32+
0: true # exit 0 → success (exit 0)
33+
1: false # exit 1 → failure (preserve exit 1)
34+
2: true # exit 2 → success (exit 0)
35+
```
36+
37+
**Behavior:**
38+
- Only active when the global `ci.enabled` is true.
39+
- After all side-effects (status upload) complete, the mapping is applied.
40+
- If an exit code maps to `true`, Atmos exits 0 (success for the CI runner).
41+
- If an exit code maps to `false` or is unmapped, the original exit code is preserved.
42+
- The upload always sends the *original* exit code to Atmos Pro, never the remapped one.
43+
44+
**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.
45+
46+
## API Contract
47+
48+
The CLI sends a PATCH to `/api/v1/repos/{owner}/{repo}/instances?stack={stack}&component={component}` with:
49+
50+
```json
51+
{
52+
"command": "plan" | "apply",
53+
"exit_code": <integer>,
54+
"last_run": "<ISO 8601 datetime>"
55+
}
56+
```
57+
58+
Atmos Pro interprets exit codes server-side:
59+
60+
| Command | Exit Code | Instance Status |
61+
|---------|-----------|-----------------|
62+
| plan | 0 | `in_sync` |
63+
| plan | 2 | `drifted` |
64+
| plan | other | `error` |
65+
| apply | 0 | `in_sync` |
66+
| apply | != 0 | `error` |
67+
68+
The legacy `{ status }` format is still accepted for backward compatibility.

internal/exec/ci_exit_codes.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package exec
2+
3+
import (
4+
log "github.com/cloudposse/atmos/pkg/logger"
5+
"github.com/cloudposse/atmos/pkg/schema"
6+
)
7+
8+
// defaultCIExitCodes are the default exit code mappings when ci.enabled is true
9+
// but components.terraform.ci.exit_codes is not explicitly configured.
10+
// Exit code 0 (no changes) and 2 (changes detected) are treated as success;
11+
// exit code 1 (error) is preserved as failure.
12+
var defaultCIExitCodes = map[int]bool{
13+
0: true,
14+
1: false,
15+
2: true,
16+
}
17+
18+
// mapCIExitCode checks the CI exit code mapping and returns the remapped exit code.
19+
// When global ci.enabled is true and components.terraform.ci.exit_codes maps the
20+
// given exit code to true, it returns 0 (success). Otherwise the original exit code
21+
// is returned unchanged. If no exit_codes are configured, sensible defaults apply.
22+
func mapCIExitCode(atmosConfig *schema.AtmosConfiguration, exitCode int) int {
23+
if !atmosConfig.CI.Enabled {
24+
return exitCode
25+
}
26+
27+
exitCodes := atmosConfig.Components.Terraform.CI.ExitCodes
28+
if exitCodes == nil {
29+
exitCodes = defaultCIExitCodes
30+
}
31+
32+
if success, ok := exitCodes[exitCode]; ok && success {
33+
log.Debug("CI exit code mapping: remapping to success",
34+
"original_exit_code", exitCode,
35+
)
36+
return 0
37+
}
38+
39+
return exitCode
40+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package exec
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/cloudposse/atmos/pkg/schema"
9+
)
10+
11+
func TestMapCIExitCode(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
ci schema.CIConfig
15+
tf schema.TerraformCI
16+
exitCode int
17+
expected int
18+
}{
19+
{
20+
name: "CI disabled preserves exit code",
21+
ci: schema.CIConfig{Enabled: false},
22+
tf: schema.TerraformCI{ExitCodes: map[int]bool{2: true}},
23+
exitCode: 2,
24+
expected: 2,
25+
},
26+
{
27+
name: "CI enabled but no exit_codes map uses defaults (exit 2 → success)",
28+
ci: schema.CIConfig{Enabled: true},
29+
tf: schema.TerraformCI{},
30+
exitCode: 2,
31+
expected: 0,
32+
},
33+
{
34+
name: "CI enabled but no exit_codes map uses defaults (exit 1 → failure)",
35+
ci: schema.CIConfig{Enabled: true},
36+
tf: schema.TerraformCI{},
37+
exitCode: 1,
38+
expected: 1,
39+
},
40+
{
41+
name: "CI enabled with code mapped true returns 0",
42+
ci: schema.CIConfig{Enabled: true},
43+
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true, 2: true}},
44+
exitCode: 2,
45+
expected: 0,
46+
},
47+
{
48+
name: "CI enabled with code mapped false preserves exit code",
49+
ci: schema.CIConfig{Enabled: true},
50+
tf: schema.TerraformCI{ExitCodes: map[int]bool{1: false}},
51+
exitCode: 1,
52+
expected: 1,
53+
},
54+
{
55+
name: "CI enabled with unmapped code preserves exit code",
56+
ci: schema.CIConfig{Enabled: true},
57+
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true, 2: true}},
58+
exitCode: 1,
59+
expected: 1,
60+
},
61+
{
62+
name: "CI enabled with exit 0 mapped true returns 0",
63+
ci: schema.CIConfig{Enabled: true},
64+
tf: schema.TerraformCI{ExitCodes: map[int]bool{0: true}},
65+
exitCode: 0,
66+
expected: 0,
67+
},
68+
{
69+
name: "exit code 0 without mapping preserves 0",
70+
ci: schema.CIConfig{Enabled: true},
71+
tf: schema.TerraformCI{ExitCodes: map[int]bool{2: true}},
72+
exitCode: 0,
73+
expected: 0,
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
config := &schema.AtmosConfiguration{
80+
CI: tt.ci,
81+
Components: schema.Components{
82+
Terraform: schema.Terraform{
83+
CI: tt.tf,
84+
},
85+
},
86+
}
87+
result := mapCIExitCode(config, tt.exitCode)
88+
assert.Equal(t, tt.expected, result)
89+
})
90+
}
91+
}

internal/exec/pro.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"runtime"
78

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

@@ -16,6 +17,7 @@ import (
1617
"github.com/cloudposse/atmos/pkg/schema"
1718
"github.com/cloudposse/atmos/pkg/ui/theme"
1819
u "github.com/cloudposse/atmos/pkg/utils"
20+
pkgversion "github.com/cloudposse/atmos/pkg/version"
1921
"github.com/spf13/cobra"
2022
)
2123

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

215217
// uploadStatus uploads the terraform results to the pro API.
216218
func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.AtmosProAPIClientInterface, gitRepo git.GitRepoInterface) error {
217-
// Only upload if exit code is 0 (no changes) or 2 (changes)
218-
if exitCode != 0 && exitCode != 2 {
219-
return nil
220-
}
221-
222219
// Get the git repository info
223220
repoInfo, err := gitRepo.GetLocalRepoInfo()
224221
if err != nil {
@@ -242,14 +239,18 @@ func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.Atm
242239
// Create the DTO
243240
dto := dtos.InstanceStatusUploadRequest{
244241
AtmosProRunID: atmosProRunID,
242+
AtmosVersion: pkgversion.Version,
243+
AtmosOS: runtime.GOOS,
244+
AtmosArch: runtime.GOARCH,
245245
GitSHA: gitSHA,
246246
RepoURL: repoInfo.RepoUrl,
247247
RepoName: repoInfo.RepoName,
248248
RepoOwner: repoInfo.RepoOwner,
249249
RepoHost: repoInfo.RepoHost,
250250
Stack: info.Stack,
251251
Component: info.Component,
252-
HasDrift: exitCode == 2,
252+
Command: info.SubCommand,
253+
ExitCode: exitCode,
253254
}
254255

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

263264
// shouldUploadStatus determines if status should be uploaded.
264265
func shouldUploadStatus(info *schema.ConfigAndStacksInfo) bool {
265-
// Only upload for plan command
266-
if info.SubCommand != "plan" {
266+
// Only upload for plan and apply commands.
267+
if info.SubCommand != "plan" && info.SubCommand != "apply" {
267268
return false
268269
}
269270

0 commit comments

Comments
 (0)