Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
39 changes: 39 additions & 0 deletions docs/prd/instance-status-raw-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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. **Apply wiring** (`internal/exec/terraform.go`): Apply uploads automatically when `settings.pro.enabled` is true — no `--upload-status` flag required.

## 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.
12 changes: 4 additions & 8 deletions internal/exec/pro.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,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 Down Expand Up @@ -249,7 +244,8 @@ func uploadStatus(info *schema.ConfigAndStacksInfo, exitCode int, client pro.Atm
RepoHost: repoInfo.RepoHost,
Stack: info.Stack,
Component: info.Component,
HasDrift: exitCode == 2,
Command: info.SubCommand,
ExitCode: exitCode,
}

// Upload the status
Expand All @@ -262,8 +258,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
99 changes: 42 additions & 57 deletions internal/exec/pro_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func TestShouldUploadStatus(t *testing.T) {
expected: false,
},
{
name: "should return false for non-plan command",
name: "should return true for apply command with pro enabled",
info: &schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSettingsSection: map[string]interface{}{
Expand All @@ -144,6 +144,18 @@ func TestShouldUploadStatus(t *testing.T) {
},
},
},
expected: true,
},
{
name: "should return false for destroy command",
info: &schema.ConfigAndStacksInfo{
SubCommand: "destroy",
ComponentSettingsSection: map[string]interface{}{
"pro": map[string]interface{}{
"enabled": true,
},
},
},
expected: false,
},
}
Expand All @@ -165,75 +177,55 @@ func TestUploadStatus(t *testing.T) {
RepoHost: "github.com",
}

// Test cases
// Test cases — all exit codes now upload.
testCases := []struct {
name string
exitCode int
proEnabled bool
expectedError bool
expectedDrift bool
}{
{
name: "drift detected",
name: "drift detected (exit code 2)",
exitCode: 2,
proEnabled: true,
expectedError: false,
expectedDrift: true,
},
{
name: "no drift",
name: "no drift (exit code 0)",
exitCode: 0,
proEnabled: true,
expectedError: false,
expectedDrift: false,
},
{
name: "error exit code",
exitCode: 1,
proEnabled: true,
expectedError: false,
expectedDrift: false,
},
{
name: "pro disabled",
exitCode: 2,
proEnabled: false,
expectedError: false,
expectedDrift: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create fresh mock clients for each subtest
mockProClient := new(MockProAPIClient)
mockGitRepo := new(MockGitRepo)

// Create test info
info := createTestInfo(tc.proEnabled)

// Set up mock expectations based on exit code
// The function only processes exit codes 0 and 2
if tc.exitCode == 0 || tc.exitCode == 2 {
// Set up mock expectations for git functions
mockGitRepo.On("GetLocalRepoInfo").Return(testRepoInfo, nil)
mockGitRepo.On("GetCurrentCommitSHA").Return("abc123def456", nil)
// All exit codes now upload.
mockGitRepo.On("GetLocalRepoInfo").Return(testRepoInfo, nil)
mockGitRepo.On("GetCurrentCommitSHA").Return("abc123def456", nil)
mockProClient.On("UploadInstanceStatus", mock.MatchedBy(func(dto *dtos.InstanceStatusUploadRequest) bool {
return dto.Command == "plan" && dto.ExitCode == tc.exitCode
})).Return(nil)

// Set up mock expectations for pro client
mockProClient.On("UploadInstanceStatus", mock.AnythingOfType("*dtos.InstanceStatusUploadRequest")).Return(nil)
}

// Call the function
err := uploadStatus(&info, tc.exitCode, mockProClient, mockGitRepo)

// Check results
if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}

// Verify mock expectations
mockProClient.AssertExpectations(t)
mockGitRepo.AssertExpectations(t)
})
Expand Down Expand Up @@ -284,34 +276,24 @@ func TestUploadStatusWithDifferentExitCodes(t *testing.T) {
}

testCases := []struct {
name string
exitCode int
shouldUpload bool
expectedHasDrift bool
name string
exitCode int
}{
{
name: "exit code 0 - no changes",
exitCode: 0,
shouldUpload: true,
expectedHasDrift: false,
name: "exit code 0 - no changes",
exitCode: 0,
},
{
name: "exit code 1 - error",
exitCode: 1,
shouldUpload: false,
expectedHasDrift: false,
name: "exit code 1 - error",
exitCode: 1,
},
{
name: "exit code 2 - changes detected",
exitCode: 2,
shouldUpload: true,
expectedHasDrift: true,
name: "exit code 2 - changes detected",
exitCode: 2,
},
{
name: "exit code 3 - unknown",
exitCode: 3,
shouldUpload: false,
expectedHasDrift: false,
name: "exit code 3 - unknown",
exitCode: 3,
},
}

Expand All @@ -322,11 +304,12 @@ func TestUploadStatusWithDifferentExitCodes(t *testing.T) {

info := createTestInfo(true)

if tc.shouldUpload {
mockGitRepo.On("GetLocalRepoInfo").Return(testRepoInfo, nil)
mockGitRepo.On("GetCurrentCommitSHA").Return("abc123", nil)
mockProClient.On("UploadInstanceStatus", mock.AnythingOfType("*dtos.InstanceStatusUploadRequest")).Return(nil)
}
// All exit codes now upload with raw command + exit_code.
mockGitRepo.On("GetLocalRepoInfo").Return(testRepoInfo, nil)
mockGitRepo.On("GetCurrentCommitSHA").Return("abc123", nil)
mockProClient.On("UploadInstanceStatus", mock.MatchedBy(func(dto *dtos.InstanceStatusUploadRequest) bool {
return dto.Command == "plan" && dto.ExitCode == tc.exitCode
})).Return(nil)

err := uploadStatus(&info, tc.exitCode, mockProClient, mockGitRepo)
assert.NoError(t, err)
Expand Down Expand Up @@ -392,7 +375,8 @@ func TestUploadStatusDTO(t *testing.T) {
RepoHost: "github.com",
Stack: "dev",
Component: "vpc",
HasDrift: true,
Command: "plan",
ExitCode: 2,
}

assert.Equal(t, "run-123", dto.AtmosProRunID)
Expand All @@ -403,7 +387,8 @@ func TestUploadStatusDTO(t *testing.T) {
assert.Equal(t, "github.com", dto.RepoHost)
assert.Equal(t, "dev", dto.Stack)
assert.Equal(t, "vpc", dto.Component)
assert.True(t, dto.HasDrift)
assert.Equal(t, "plan", dto.Command)
assert.Equal(t, 2, dto.ExitCode)
})
}

Expand Down
4 changes: 2 additions & 2 deletions internal/exec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,8 +762,8 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo, opts ...ShellCommandOptio
exitCode = 0
}

// Upload plan status if requested.
if uploadStatusFlag && shouldUploadStatus(&info) {
// Upload status if requested (plan) or automatically (apply).
if (uploadStatusFlag || info.SubCommand == "apply") && shouldUploadStatus(&info) {
client, cerr := pro.NewAtmosProAPIClientFromEnv(&atmosConfig)
if cerr != nil {
return cerr
Expand Down
11 changes: 3 additions & 8 deletions pkg/pro/api_client_instance_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,10 @@ func (c *AtmosProAPIClient) UploadInstanceStatus(dto *dtos.InstanceStatusUploadR
url.QueryEscape(dto.Component))
log.Debug("Uploading drift status.", "url", targetURL)

// Map HasDrift to the correct status format
status := "in_sync"
if dto.HasDrift {
status = "drifted"
}

// Create the correct payload structure expected by the API
// Send raw command and exit code — the server interprets them.
payload := map[string]interface{}{
"status": status,
"command": dto.Command,
"exit_code": dto.ExitCode,
}

// Add last_run if we have atmos_pro_run_id or git_sha
Expand Down
3 changes: 2 additions & 1 deletion pkg/pro/dtos/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ type InstanceStatusUploadRequest struct {
RepoHost string `json:"repo_host"`
Component string `json:"component"`
Stack string `json:"stack"`
HasDrift bool `json:"has_drift"`
Command string `json:"command"`
ExitCode int `json:"exit_code"`
}
12 changes: 8 additions & 4 deletions pkg/pro/dtos/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ func TestInstanceStatusUploadRequest(t *testing.T) {
RepoHost: "github.com",
Component: "test-component",
Stack: "test-stack",
HasDrift: true,
Command: "plan",
ExitCode: 2,
}

assert.Equal(t, "run-123", req.AtmosProRunID)
Expand All @@ -29,7 +30,8 @@ func TestInstanceStatusUploadRequest(t *testing.T) {
assert.Equal(t, "github.com", req.RepoHost)
assert.Equal(t, "test-component", req.Component)
assert.Equal(t, "test-stack", req.Stack)
assert.True(t, req.HasDrift)
assert.Equal(t, "plan", req.Command)
assert.Equal(t, 2, req.ExitCode)
})

t.Run("valid request with minimal fields", func(t *testing.T) {
Expand All @@ -38,7 +40,8 @@ func TestInstanceStatusUploadRequest(t *testing.T) {
RepoOwner: "test-owner",
Component: "test-component",
Stack: "test-stack",
HasDrift: false,
Command: "apply",
ExitCode: 0,
}

assert.Equal(t, "", req.AtmosProRunID)
Expand All @@ -49,7 +52,8 @@ func TestInstanceStatusUploadRequest(t *testing.T) {
assert.Equal(t, "", req.RepoHost)
assert.Equal(t, "test-component", req.Component)
assert.Equal(t, "test-stack", req.Stack)
assert.False(t, req.HasDrift)
assert.Equal(t, "apply", req.Command)
assert.Equal(t, 0, req.ExitCode)
})
}

Expand Down
Loading