Skip to content

Commit 9418300

Browse files
committed
feat: add stack state pull command
Downloads the current Terraform/OpenTofu state file for a stack via the stateDownloadUrl GraphQL mutation and presigned S3 URL. Usage: spacectl stack state pull [--id <stack-id>] [--output <file>]
1 parent 9aa0610 commit 9418300

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

internal/cmd/stack/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# `spacectl stack state`
2+
3+
`spacectl stack state` manages Terraform/OpenTofu state files for Spacelift stacks.
4+
5+
## `pull`
6+
7+
Downloads the current state file for a stack.
8+
9+
```bash
10+
# Output to stdout
11+
spacectl stack state pull --id my-stack
12+
13+
# Save to file
14+
spacectl stack state pull --id my-stack -o terraform.tfstate
15+
16+
# Auto-detect stack from current directory
17+
spacectl stack state pull
18+
19+
# Pretty-print
20+
spacectl stack state pull --id my-stack | jq .
21+
```
22+
23+
### Prerequisites
24+
25+
The stack must have:
26+
27+
- **Manages state** enabled (Spacelift manages the Terraform state)
28+
- **External state access** enabled (stack setting)
29+
30+
The user must have **State download** permission or Space admin role.

internal/cmd/stack/stack.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,36 @@ func Command() cmd.Command {
769769
},
770770
},
771771
},
772+
{
773+
Name: "state",
774+
Usage: "Manage stack state",
775+
Versions: []cmd.VersionedCommand{
776+
{
777+
EarliestVersion: cmd.SupportedVersionAll,
778+
Command: &cli.Command{},
779+
},
780+
},
781+
Subcommands: []cmd.Command{
782+
{
783+
Name: "pull",
784+
Usage: "Download the current state file for a stack",
785+
Versions: []cmd.VersionedCommand{
786+
{
787+
EarliestVersion: cmd.SupportedVersionAll,
788+
Command: &cli.Command{
789+
Flags: []cli.Flag{
790+
flagStackID,
791+
flagOutputFile,
792+
},
793+
Action: statePull(),
794+
Before: authenticated.Ensure,
795+
ArgsUsage: cmd.EmptyArgsUsage,
796+
},
797+
},
798+
},
799+
},
800+
},
801+
},
772802
{
773803
Name: "resources",
774804
Usage: "Manage and view resources for stacks",

internal/cmd/stack/state.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package stack
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/shurcooL/graphql"
12+
"github.com/urfave/cli/v3"
13+
14+
"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
15+
)
16+
17+
var flagOutputFile = &cli.StringFlag{
18+
Name: "output",
19+
Aliases: []string{"o"},
20+
Usage: "[Optional] `PATH` to write the state file to (defaults to stdout)",
21+
}
22+
23+
func statePull() cli.ActionFunc {
24+
return func(ctx context.Context, cliCmd *cli.Command) error {
25+
stackID, err := getStackID(ctx, cliCmd)
26+
if err != nil {
27+
return err
28+
}
29+
30+
var mutation struct {
31+
StateDownloadURL struct {
32+
URL string `graphql:"url"`
33+
} `graphql:"stateDownloadUrl(input: $input)"`
34+
}
35+
36+
variables := map[string]any{
37+
"input": StateDownloadUrlInput{StackID: graphql.ID(stackID)},
38+
}
39+
40+
if err := authenticated.Client().Mutate(ctx, &mutation, variables); err != nil {
41+
return fmt.Errorf("failed to get state download URL for stack %q: %w", stackID, err)
42+
}
43+
44+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mutation.StateDownloadURL.URL, nil)
45+
if err != nil {
46+
return fmt.Errorf("failed to create download request: %w", err)
47+
}
48+
49+
resp, err := http.DefaultClient.Do(req)
50+
if err != nil {
51+
return fmt.Errorf("failed to download state: %w", err)
52+
}
53+
defer resp.Body.Close()
54+
55+
if resp.StatusCode != http.StatusOK {
56+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
57+
return fmt.Errorf("failed to download state: HTTP %d: %s", resp.StatusCode, string(body))
58+
}
59+
60+
outputPath := cliCmd.String(flagOutputFile.Name)
61+
if outputPath == "" {
62+
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
63+
return fmt.Errorf("failed to write state: %w", err)
64+
}
65+
return nil
66+
}
67+
68+
f, err := os.OpenFile(filepath.Clean(outputPath), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
69+
if err != nil {
70+
return fmt.Errorf("failed to create output file: %w", err)
71+
}
72+
73+
if _, err := io.Copy(f, resp.Body); err != nil {
74+
f.Close()
75+
os.Remove(filepath.Clean(outputPath))
76+
return fmt.Errorf("failed to write state: %w", err)
77+
}
78+
79+
if err := f.Close(); err != nil {
80+
os.Remove(filepath.Clean(outputPath))
81+
return fmt.Errorf("failed to flush state file: %w", err)
82+
}
83+
84+
return nil
85+
}
86+
}
87+
88+
type StateDownloadUrlInput struct { //nolint:staticcheck // type name must match GraphQL schema exactly
89+
StackID graphql.ID `json:"stackId"`
90+
}

0 commit comments

Comments
 (0)