Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
30 changes: 30 additions & 0 deletions internal/cmd/stack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# `spacectl stack state`

`spacectl stack state` manages Terraform/OpenTofu state files for Spacelift stacks.

## `pull`

Downloads the current state file for a stack.

```bash
# Output to stdout
spacectl stack state pull --id my-stack

# Save to file
spacectl stack state pull --id my-stack -o terraform.tfstate

# Auto-detect stack from current directory
spacectl stack state pull

# Pretty-print
spacectl stack state pull --id my-stack | jq .
```

### Prerequisites

The stack must have:

- **Manages state** enabled (Spacelift manages the Terraform state)
- **External state access** enabled (stack setting)

The user must have **State download** permission or Space admin role.
30 changes: 30 additions & 0 deletions internal/cmd/stack/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,36 @@ func Command() cmd.Command {
},
},
},
{
Name: "state",
Usage: "Manage stack state",
Versions: []cmd.VersionedCommand{
{
EarliestVersion: cmd.SupportedVersionAll,
Command: &cli.Command{},
},
},
Subcommands: []cmd.Command{
{
Name: "pull",
Usage: "Download the current state file for a stack",
Versions: []cmd.VersionedCommand{
{
EarliestVersion: cmd.SupportedVersionAll,
Command: &cli.Command{
Flags: []cli.Flag{
flagStackID,
flagOutputFile,
},
Action: statePull(),
Before: authenticated.Ensure,
ArgsUsage: cmd.EmptyArgsUsage,
},
},
},
},
},
},
{
Name: "resources",
Usage: "Manage and view resources for stacks",
Expand Down
90 changes: 90 additions & 0 deletions internal/cmd/stack/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package stack

import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"

"github.com/shurcooL/graphql"
"github.com/urfave/cli/v3"

"github.com/spacelift-io/spacectl/internal/cmd/authenticated"
)

var flagOutputFile = &cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "[Optional] `PATH` to write the state file to (defaults to stdout)",
}

func statePull() cli.ActionFunc {
return func(ctx context.Context, cliCmd *cli.Command) error {
stackID, err := getStackID(ctx, cliCmd)
if err != nil {
return err
}

var mutation struct {
StateDownloadURL struct {
URL string `graphql:"url"`
} `graphql:"stateDownloadUrl(input: $input)"`
}

variables := map[string]any{
"input": StateDownloadUrlInput{StackID: graphql.ID(stackID)},
}

if err := authenticated.Client().Mutate(ctx, &mutation, variables); err != nil {
return fmt.Errorf("failed to get state download URL for stack %q: %w", stackID, err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, mutation.StateDownloadURL.URL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download state: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("failed to download state: HTTP %d: %s", resp.StatusCode, string(body))
}

outputPath := cliCmd.String(flagOutputFile.Name)
if outputPath == "" {
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
return fmt.Errorf("failed to write state: %w", err)
}
return nil
}

f, err := os.OpenFile(filepath.Clean(outputPath), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}

if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(filepath.Clean(outputPath))
return fmt.Errorf("failed to write state: %w", err)
}

if err := f.Close(); err != nil {
os.Remove(filepath.Clean(outputPath))
return fmt.Errorf("failed to flush state file: %w", err)
}
Comment thread
michalrom089 marked this conversation as resolved.
Outdated

return nil
}
}

type StateDownloadUrlInput struct { //nolint:staticcheck // type name must match GraphQL schema exactly
StackID graphql.ID `json:"stackId"`
}
Loading