Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,13 @@ lstk proxies third-party IaC tools at the AWS emulator so they run against Local
- `lstk snapshot load REF` — restore state, starting the emulator first if needed; `--merge` controls how snapshot state combines with running state (`account-region-merge` (default), `overwrite`, `service-merge`).
- `lstk snapshot list` — list cloud snapshots on the LocalStack platform. Lists only snapshots you created by default; pass `--all` to include every snapshot in your organization. Cloud-only; requires auth.
- `lstk snapshot remove REF` — delete a cloud snapshot. Cloud-only; local files are never deleted by the CLI. Prompts for confirmation in interactive mode; `--force` is required to skip the prompt in non-interactive mode.
- `lstk snapshot show REF` — show metadata for a single cloud snapshot (name, created date, size, LocalStack version, message, services, and per-service resource counts). Resource counts render only when the platform has them for that snapshot. Cloud-only; requires auth.

A REF is parsed by helpers in `internal/snapshot/destination.go`:
- **local file** — absolute/relative path; the `.snapshot` extension is forced (any other extension is replaced). On load, `.zip` files saved by older lstk versions are still accepted.
- **cloud snapshot** — `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires auth (`LOCALSTACK_AUTH_TOKEN` or `lstk login`).

`ParseDestination` (save), `ParseSource` (load), and `ParseRemovable` (remove) share pod-name validation; `ParseRemovable` rejects local paths so the CLI cannot delete local files.
`ParseDestination` (save), `ParseSource` (load), `ParseRemovable` (remove), and `ParseShowable` (show) share pod-name validation; `ParseRemovable` and `ParseShowable` reject local paths (via the shared `parseCloudOnly` helper) so those cloud-only commands never touch local files.

# Code Style

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ lstk snapshot load pod:my-baseline
# List cloud snapshots on the LocalStack platform (--all for the whole organization)
lstk snapshot list

# Show metadata for a single cloud snapshot
lstk snapshot show pod:my-baseline

# Delete a cloud snapshot (prompts for confirmation; --force to skip)
lstk snapshot remove pod:my-baseline

Expand Down Expand Up @@ -309,6 +312,9 @@ lstk snapshot load pod:my-baseline
lstk snapshot list
lstk snapshot list --all

# Show metadata for a single cloud snapshot
lstk snapshot show pod:my-baseline

# Remove — cloud snapshots only; local files are never deleted by the CLI
lstk snapshot remove pod:my-baseline # prompts for confirmation
lstk snapshot remove pod:my-baseline --force # skip the prompt (required in non-interactive mode)
Expand Down
41 changes: 41 additions & 0 deletions cmd/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const snapshotListLong = `List Cloud Pod snapshots available on the LocalStack p

By default only snapshots you created are listed. Pass --all to include all snapshots in your organisation.`

const snapshotShowLong = `Show metadata for a cloud snapshot on the LocalStack platform.

lstk snapshot show pod:my-baseline # prints name, created date, size, version, services, and resource counts`

const snapshotRemoveLong = `Delete a cloud snapshot from the LocalStack platform.

Only cloud snapshots (pod: prefix) can be removed. This operation cannot be undone.
Expand Down Expand Up @@ -74,6 +78,7 @@ func newSnapshotCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cob
cmd.AddCommand(newSnapshotLoadCmd(cfg, tel, logger))
cmd.AddCommand(newSnapshotListCmd(cfg, logger))
cmd.AddCommand(newSnapshotRemoveCmd(cfg))
cmd.AddCommand(newSnapshotShowCmd(cfg, logger))
return cmd
}

Expand Down Expand Up @@ -265,6 +270,42 @@ func runSnapshotList(cfg *env.Env, logger log.Logger) func(*cobra.Command, []str
}
}

func newSnapshotShowCmd(cfg *env.Env, logger log.Logger) *cobra.Command {
return &cobra.Command{
Use: "show REF",
Short: "Show metadata for a cloud snapshot",
Long: snapshotShowLong,
Args: cobra.ExactArgs(1),
PreRunE: initConfig(nil),
RunE: runSnapshotShow(cfg, logger),
}
}

func runSnapshotShow(cfg *env.Env, logger log.Logger) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}

ref, err := snapshot.ParseShowable(args[0], cwd, home)
if err != nil {
return err
}

client := api.NewPlatformClient(cfg.APIEndpoint, logger)
if isInteractiveMode(cfg) {
return ui.RunSnapshotShow(cmd.Context(), client, cfg.AuthToken, ref.Value)
}
sink := output.NewPlainSink(os.Stdout)
return snapshot.Show(cmd.Context(), client, cfg.AuthToken, ref.Value, sink)
}
}

func newSnapshotSaveCmd(cfg *env.Env) *cobra.Command {
return &cobra.Command{
Use: "save [destination]",
Expand Down
257 changes: 253 additions & 4 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -102,9 +104,9 @@ func (r *LicenseResponse) PlanDisplayName() string {
// IsUnsupportedTag is set when the server rejects the image tag format, letting
// callers that know the config context replace Message with a more specific suggestion.
type LicenseError struct {
Status int
Message string
Detail string
Status int
Message string
Detail string
IsUnsupportedTag bool
}

Expand All @@ -118,6 +120,37 @@ type CloudPod struct {
LastChanged *time.Time
}

// ErrCloudPodNotFound is returned by GetCloudPod when the platform reports the
// requested pod does not exist (HTTP 404).
var ErrCloudPodNotFound = errors.New("cloud pod not found")

// CloudPodResourceCount is a count of a single resource kind within a service,
// e.g. {Noun: "buckets", Count: 3}.
type CloudPodResourceCount struct {
Noun string
Count int
}

// CloudPodResource groups the resource counts of a single service.
type CloudPodResource struct {
Service string
Counts []CloudPodResourceCount
}

// CloudPodDetails is the metadata for a single cloud snapshot, taken from its
// latest version. Resources is empty when the platform has no resource breakdown
// for the snapshot (e.g. it was saved without resource indexing enabled).
type CloudPodDetails struct {
Name string
Version int
Created *time.Time
Size int64
LocalStackVersion string
Message string
Services []string
Resources []CloudPodResource
}

type PlatformClient struct {
baseURL string
httpClient *http.Client
Expand All @@ -128,7 +161,7 @@ func NewPlatformClient(apiEndpoint string, logger log.Logger) *PlatformClient {
return &PlatformClient{
baseURL: apiEndpoint,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Timeout: 30 * time.Second,
Transport: otelhttp.NewTransport(
http.DefaultTransport,
otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
Expand Down Expand Up @@ -395,3 +428,219 @@ func (c *PlatformClient) ListCloudPods(ctx context.Context, authToken, creator s
return pods, nil
}

// rawCloudPodVersion mirrors a single entry in the platform's "versions" array.
// The platform reports the byte size as "storage_size"; "size" is accepted as a
// fallback for forward/backward compatibility. The created timestamp is captured
// raw and parsed leniently since its key and encoding vary.
type rawCloudPodVersion struct {
Version int `json:"version"`
LocalStackVersion string `json:"localstack_version"`
Services []string `json:"services"`
StorageSize int64 `json:"storage_size"`
Size int64 `json:"size"`
Description string `json:"description"`
CreatedAt json.RawMessage `json:"created_at"`
LastChange json.RawMessage `json:"last_change"`
CloudControlResources string `json:"cloud_control_resources"`
}

// size returns the version's byte size, preferring storage_size.
func (v rawCloudPodVersion) size() int64 {
if v.StorageSize > 0 {
return v.StorageSize
}
return v.Size
}

type rawCloudPod struct {
PodName string `json:"pod_name"`
MaxVersion int `json:"max_version"`
StorageSize int64 `json:"storage_size"`
Versions []rawCloudPodVersion `json:"versions"`
}

// GetCloudPod fetches metadata for a single cloud snapshot from the platform.
// It returns ErrCloudPodNotFound when the pod does not exist.
func (c *PlatformClient) GetCloudPod(ctx context.Context, authToken, podName string) (*CloudPodDetails, error) {
u := c.baseURL + "/v1/cloudpods/" + url.PathEscape(podName)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(":"+authToken)))

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get cloud pod: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Error("failed to close response body: %v", err)
}
}()

if resp.StatusCode == http.StatusNotFound {
return nil, ErrCloudPodNotFound
}
if resp.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("failed to get cloud pod: status %d: %s", resp.StatusCode, strings.TrimSpace(string(detail)))
}

var raw rawCloudPod
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("failed to decode cloud pod response: %w", err)
}
return raw.toDetails(podName), nil
}

// toDetails projects the latest version's metadata into CloudPodDetails.
func (r rawCloudPod) toDetails(fallbackName string) *CloudPodDetails {
name := r.PodName
if name == "" {
name = fallbackName
}
details := &CloudPodDetails{Name: name, Version: r.MaxVersion}

v := r.latestVersion()
if v == nil {
return details
}
if v.Version != 0 {
details.Version = v.Version
}
details.Size = v.size()
if details.Size == 0 {
details.Size = r.StorageSize
}
details.LocalStackVersion = v.LocalStackVersion
details.Message = v.Description
details.Services = v.Services
if t := parseFlexibleTime(v.CreatedAt); t != nil {
details.Created = t
} else if t := parseFlexibleTime(v.LastChange); t != nil {
details.Created = t
}
details.Resources = resourceCountsFromCloudControl(v.CloudControlResources)
return details
}

// latestVersion returns the version matching MaxVersion, falling back to the last
// entry in the array.
func (r rawCloudPod) latestVersion() *rawCloudPodVersion {
if len(r.Versions) == 0 {
return nil
}
for i := range r.Versions {
if r.Versions[i].Version == r.MaxVersion {
return &r.Versions[i]
}
}
return &r.Versions[len(r.Versions)-1]
}

// parseFlexibleTime parses a timestamp encoded either as a Unix epoch number or
// an RFC3339 string. Returns nil when the value is absent or unrecognized.
func parseFlexibleTime(raw json.RawMessage) *time.Time {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var epoch int64
if err := json.Unmarshal(raw, &epoch); err == nil {
t := time.Unix(epoch, 0).UTC()
return &t
}
var s string
if err := json.Unmarshal(raw, &s); err == nil && s != "" {
if t, err := time.Parse(time.RFC3339, s); err == nil {
u := t.UTC()
return &u
}
}
return nil
}

// resourceCountsFromCloudControl decodes the cloud_control_resources JSON string
// (a map of CloudFormation type → resource entries) into per-service counts.
// Any decoding problem yields an empty result so callers never fail on it.
func resourceCountsFromCloudControl(raw string) []CloudPodResource {
if strings.TrimSpace(raw) == "" {
return nil
}
var byType map[string][]json.RawMessage
if err := json.Unmarshal([]byte(raw), &byType); err != nil {
return nil
}

// service → singular noun → count. The map keys stay singular; each noun is
// pluralized below based on its final count (e.g. "1 topic", "5 queues").
counts := map[string]map[string]int{}
for cfnType, entries := range byType {
parts := strings.Split(cfnType, "::")
if len(parts) < 3 {
continue
}
service := strings.ToLower(parts[1])
noun := strings.ToLower(parts[len(parts)-1])
if counts[service] == nil {
counts[service] = map[string]int{}
}
counts[service][noun] += len(entries)
}

services := make([]string, 0, len(counts))
for s := range counts {
services = append(services, s)
}
sort.Strings(services)

resources := make([]CloudPodResource, 0, len(services))
for _, s := range services {
nouns := make([]string, 0, len(counts[s]))
for n := range counts[s] {
nouns = append(nouns, n)
}
sort.Strings(nouns)
nc := make([]CloudPodResourceCount, 0, len(nouns))
for _, n := range nouns {
nc = append(nc, CloudPodResourceCount{Noun: pluralizeFor(n, counts[s][n]), Count: counts[s][n]})
}
resources = append(resources, CloudPodResource{Service: s, Counts: nc})
}
return resources
}

// pluralizeFor returns the singular noun for a count of one and the plural form
// otherwise (1 topic, 2 topics).
func pluralizeFor(noun string, count int) string {
if count == 1 {
return noun
}
return pluralize(noun)
}

// pluralize applies simple English pluralization sufficient for AWS resource
// nouns (bucket→buckets, policy→policies, queue→queues).
func pluralize(noun string) string {
if len(noun) < 2 {
return noun
}
switch {
case strings.HasSuffix(noun, "s"), strings.HasSuffix(noun, "x"), strings.HasSuffix(noun, "z"),
strings.HasSuffix(noun, "ch"), strings.HasSuffix(noun, "sh"):
return noun + "es"
case strings.HasSuffix(noun, "y") && !isVowel(noun[len(noun)-2]):
return noun[:len(noun)-1] + "ies"
default:
return noun + "s"
}
}

func isVowel(b byte) bool {
switch b {
case 'a', 'e', 'i', 'o', 'u':
return true
default:
return false
}
}
Loading
Loading