diff --git a/CLAUDE.md b/CLAUDE.md index e07bdf5f..de6b5214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/cmd/snapshot.go b/cmd/snapshot.go index f0426fa9..d4e0e5e2 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -27,6 +27,14 @@ 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. + +Only cloud snapshots (pod: prefix) can be shown. Requires authentication. + + lstk snapshot show pod:my-baseline # prints name, created date, size, version, services, and resource counts + +Per-service resource counts are shown only when the snapshot includes that information.` + const snapshotRemoveLong = `Delete a cloud snapshot from the LocalStack platform. Only cloud snapshots (pod: prefix) can be removed. This operation cannot be undone. @@ -74,6 +82,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 } @@ -265,6 +274,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]", diff --git a/internal/api/client.go b/internal/api/client.go index 3ced9a66..10e18016 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -5,10 +5,12 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "sort" "strings" "time" @@ -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 } @@ -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 @@ -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 { @@ -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 noun is kept singular here and + // pluralized at emit time based on the 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 noun == "" { + 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 + } +} diff --git a/internal/api/cloudpod_test.go b/internal/api/cloudpod_test.go new file mode 100644 index 00000000..9777f1c7 --- /dev/null +++ b/internal/api/cloudpod_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/localstack/lstk/internal/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetCloudPod_FullMetadata(t *testing.T) { + var gotAuth, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "pod_name": "my-baseline", + "max_version": 2, + "versions": [ + {"version": 1, "localstack_version": "2026.02", "services": ["s3"], "size": 100}, + {"version": 2, "localstack_version": "2026.03", "size": 49597645, + "description": "Pre-refactor baseline", "created_at": 1776263520, + "services": ["s3", "lambda", "dynamodb"], + "cloud_control_resources": "{\"AWS::S3::Bucket\":[{\"id\":\"a\"},{\"id\":\"b\"},{\"id\":\"c\"}],\"AWS::Lambda::Function\":[{\"id\":\"f1\"}],\"AWS::DynamoDB::Table\":[{\"id\":\"t1\"},{\"id\":\"t2\"}]}"} + ] + }`)) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + details, err := client.GetCloudPod(context.Background(), "test-token", "my-baseline") + require.NoError(t, err) + + assert.Equal(t, "/v1/cloudpods/my-baseline", gotPath) + assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte(":test-token")), gotAuth) + + assert.Equal(t, "my-baseline", details.Name) + assert.Equal(t, 2, details.Version) + assert.Equal(t, int64(49597645), details.Size) + assert.Equal(t, "2026.03", details.LocalStackVersion) + assert.Equal(t, "Pre-refactor baseline", details.Message) + assert.Equal(t, []string{"s3", "lambda", "dynamodb"}, details.Services) + require.NotNil(t, details.Created) + assert.Equal(t, "2026-04-15 14:32 UTC", details.Created.UTC().Format("2006-01-02 15:04 UTC")) + + // Resources are grouped by service (sorted), with pluralized nouns. + require.Len(t, details.Resources, 3) + assert.Equal(t, CloudPodResource{Service: "dynamodb", Counts: []CloudPodResourceCount{{Noun: "tables", Count: 2}}}, details.Resources[0]) + assert.Equal(t, CloudPodResource{Service: "lambda", Counts: []CloudPodResourceCount{{Noun: "function", Count: 1}}}, details.Resources[1]) + assert.Equal(t, CloudPodResource{Service: "s3", Counts: []CloudPodResourceCount{{Noun: "buckets", Count: 3}}}, details.Resources[2]) +} + +func TestGetCloudPod_NoResources(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"pod_name": "bare", "max_version": 1, + "versions": [{"version": 1, "localstack_version": "2026.03", "services": ["s3", "sqs"], "size": 2048}]}`)) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + details, err := client.GetCloudPod(context.Background(), "tok", "bare") + require.NoError(t, err) + assert.Equal(t, []string{"s3", "sqs"}, details.Services) + assert.Empty(t, details.Resources, "no cloud_control_resources should yield empty Resources, not an error") +} + +func TestGetCloudPod_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + _, err := client.GetCloudPod(context.Background(), "tok", "missing") + assert.ErrorIs(t, err, ErrCloudPodNotFound) +} + +func TestGetCloudPod_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + _, err := client.GetCloudPod(context.Background(), "tok", "x") + require.Error(t, err) + assert.NotErrorIs(t, err, ErrCloudPodNotFound) +} + +func TestGetCloudPod_RFC3339Timestamp(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"pod_name": "iso", "max_version": 1, + "versions": [{"version": 1, "created_at": "2026-04-15T14:32:00Z"}]}`)) + })) + defer srv.Close() + + client := NewPlatformClient(srv.URL, log.Nop()) + details, err := client.GetCloudPod(context.Background(), "tok", "iso") + require.NoError(t, err) + require.NotNil(t, details.Created) + assert.Equal(t, "2026-04-15 14:32 UTC", details.Created.UTC().Format("2006-01-02 15:04 UTC")) +} + +func TestPluralize(t *testing.T) { + cases := map[string]string{ + "bucket": "buckets", + "function": "functions", + "table": "tables", + "queue": "queues", + "topic": "topics", + "policy": "policies", + "distribution": "distributions", + "address": "addresses", + "key": "keys", + } + for in, want := range cases { + assert.Equal(t, want, pluralize(in), "pluralize(%q)", in) + } +} diff --git a/internal/output/events.go b/internal/output/events.go index 66a9015d..c4455f93 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -99,28 +99,54 @@ type PodSnapshotRemovedEvent struct { PodName string } +// SnapshotResourceCount is a count of one resource kind, e.g. {Count: 3, Noun: "buckets"}. +type SnapshotResourceCount struct { + Count int + Noun string +} + +// SnapshotResourceLine groups the resource counts of a single service. +type SnapshotResourceLine struct { + Service string + Counts []SnapshotResourceCount +} + +// SnapshotShownEvent reports the metadata of a single cloud snapshot for the +// `snapshot show` command. Created is nil and Resources is empty when the +// platform has no value for them; the formatter omits those sections. +type SnapshotShownEvent struct { + Name string + Created *time.Time + Size int64 + LocalStackVersion string + Message string + Services []string + Resources []SnapshotResourceLine +} + type AuthCompleteEvent struct{} // Event is a sealed marker — only event types in this package implement it, // so Sink.Emit rejects unknown types at compile time. type Event interface{ sealedEvent() } -func (MessageEvent) sealedEvent() {} -func (SpinnerEvent) sealedEvent() {} -func (ErrorEvent) sealedEvent() {} -func (AuthEvent) sealedEvent() {} -func (AuthCompleteEvent) sealedEvent() {} -func (InstanceInfoEvent) sealedEvent() {} -func (TableEvent) sealedEvent() {} -func (ResourceSummaryEvent) sealedEvent() {} +func (MessageEvent) sealedEvent() {} +func (SpinnerEvent) sealedEvent() {} +func (ErrorEvent) sealedEvent() {} +func (AuthEvent) sealedEvent() {} +func (AuthCompleteEvent) sealedEvent() {} +func (InstanceInfoEvent) sealedEvent() {} +func (TableEvent) sealedEvent() {} +func (ResourceSummaryEvent) sealedEvent() {} func (PodSnapshotSavedEvent) sealedEvent() {} func (DeferredEvent) sealedEvent() {} func (SnapshotLoadedEvent) sealedEvent() {} func (PodSnapshotRemovedEvent) sealedEvent() {} -func (ContainerStatusEvent) sealedEvent() {} -func (ProgressEvent) sealedEvent() {} -func (UserInputRequestEvent) sealedEvent() {} -func (LogLineEvent) sealedEvent() {} +func (SnapshotShownEvent) sealedEvent() {} +func (ContainerStatusEvent) sealedEvent() {} +func (ProgressEvent) sealedEvent() {} +func (UserInputRequestEvent) sealedEvent() {} +func (LogLineEvent) sealedEvent() {} type Sink interface { Emit(event Event) diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 45ceeb17..1aff3adb 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -48,6 +48,8 @@ func FormatEventLine(event Event) (string, bool) { return FormatEventLine(e.Inner) case PodSnapshotRemovedEvent: return formatPodSnapshotRemoved(e), true + case SnapshotShownEvent: + return formatSnapshotShown(e), true case AuthCompleteEvent: return "", false default: @@ -232,6 +234,50 @@ func formatPodSnapshotRemoved(e PodSnapshotRemovedEvent) string { return SuccessMarker() + fmt.Sprintf(" Cloud snapshot 'pod:%s' deleted", e.PodName) } +// snapshotShowLabelWidth is the column at which values align in the show output. +const snapshotShowLabelWidth = 16 + +func formatSnapshotShown(e SnapshotShownEvent) string { + var sb strings.Builder + row := func(label, value string) { + if value == "" { + return + } + if sb.Len() > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("%-*s%s", snapshotShowLabelWidth, label, value)) + } + + row("Name", e.Name) + if e.Created != nil { + row("Created", e.Created.UTC().Format("2006-01-02 15:04 UTC")) + } + if e.Size > 0 { + row("Size", formatBytes(e.Size)) + } + row("LocalStack", e.LocalStackVersion) + row("Message", e.Message) + + if len(e.Services) > 0 { + sb.WriteString("\n\n") + sb.WriteString(fmt.Sprintf("%-*s%s", snapshotShowLabelWidth, "Services", strings.Join(e.Services, ", "))) + } + + if len(e.Resources) > 0 { + sb.WriteString("\n\nResources") + for _, r := range e.Resources { + parts := make([]string, len(r.Counts)) + for i, c := range r.Counts { + parts[i] = fmt.Sprintf("%d %s", c.Count, c.Noun) + } + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf(" %-*s%s", snapshotShowLabelWidth-2, r.Service, strings.Join(parts, ", "))) + } + } + return sb.String() +} + func formatBytes(b int64) string { switch { case b >= byteGB: diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 46c4eff3..fdd49e88 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -1,6 +1,7 @@ package output import ( + "fmt" "strings" "testing" "time" @@ -340,6 +341,104 @@ func TestFormatTableWidth(t *testing.T) { }) } +func TestFormatSnapshotShown(t *testing.T) { + t.Parallel() + + label := func(s string) string { return fmt.Sprintf("%-*s", snapshotShowLabelWidth, s) } + resLabel := func(s string) string { return " " + fmt.Sprintf("%-*s", snapshotShowLabelWidth-2, s) } + created := time.Date(2026, 4, 15, 14, 32, 0, 0, time.UTC) + + tests := []struct { + name string + event SnapshotShownEvent + want string + }{ + { + name: "full detail with resources", + event: SnapshotShownEvent{ + Name: "my-baseline", + Created: &created, + Size: 49597645, + LocalStackVersion: "2026.03", + Message: "Pre-refactor baseline", + Services: []string{"s3", "lambda", "dynamodb", "sqs"}, + Resources: []SnapshotResourceLine{ + {Service: "s3", Counts: []SnapshotResourceCount{{Count: 3, Noun: "buckets"}}}, + {Service: "lambda", Counts: []SnapshotResourceCount{{Count: 12, Noun: "functions"}}}, + {Service: "dynamodb", Counts: []SnapshotResourceCount{{Count: 2, Noun: "tables"}}}, + {Service: "sqs", Counts: []SnapshotResourceCount{{Count: 5, Noun: "queues"}}}, + }, + }, + want: strings.Join([]string{ + label("Name") + "my-baseline", + label("Created") + "2026-04-15 14:32 UTC", + label("Size") + "47.3 MB", + label("LocalStack") + "2026.03", + label("Message") + "Pre-refactor baseline", + "", + label("Services") + "s3, lambda, dynamodb, sqs", + "", + "Resources", + resLabel("s3") + "3 buckets", + resLabel("lambda") + "12 functions", + resLabel("dynamodb") + "2 tables", + resLabel("sqs") + "5 queues", + }, "\n"), + }, + { + name: "no resources renders services only", + event: SnapshotShownEvent{ + Name: "bare", + LocalStackVersion: "2026.03", + Services: []string{"s3", "sqs"}, + }, + want: strings.Join([]string{ + label("Name") + "bare", + label("LocalStack") + "2026.03", + "", + label("Services") + "s3, sqs", + }, "\n"), + }, + { + name: "multiple resource kinds per service joined", + event: SnapshotShownEvent{ + Name: "multi", + Services: []string{"lambda"}, + Resources: []SnapshotResourceLine{ + {Service: "lambda", Counts: []SnapshotResourceCount{{Count: 12, Noun: "functions"}, {Count: 3, Noun: "layers"}}}, + }, + }, + want: strings.Join([]string{ + label("Name") + "multi", + "", + label("Services") + "lambda", + "", + "Resources", + resLabel("lambda") + "12 functions, 3 layers", + }, "\n"), + }, + { + name: "minimal omits empty fields and sections", + event: SnapshotShownEvent{Name: "minimal"}, + want: label("Name") + "minimal", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := FormatEventLine(tt.event) + if !ok { + t.Fatalf("expected ok=true") + } + if got != tt.want { + t.Fatalf("expected:\n%q\ngot:\n%q", tt.want, got) + } + }) + } +} + func TestFormatBytes(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index c9835601..f7efd6e7 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -57,11 +57,24 @@ type Destination struct { // local file paths are rejected because the CLI cannot delete local files. // cwd and home are used to produce a human-readable path in error messages. func ParseRemovable(ref, cwd, home string) (Destination, error) { + return parseCloudOnly(ref, cwd, home, "delete local files") +} + +// ParseShowable parses a ref for snapshot show. Only cloud (pod:) refs are accepted; +// local file paths are rejected because show only inspects cloud snapshots. +// cwd and home are used to produce a human-readable path in error messages. +func ParseShowable(ref, cwd, home string) (Destination, error) { + return parseCloudOnly(ref, cwd, home, "show local snapshots") +} + +// parseCloudOnly validates that ref is a cloud (pod:) reference, rejecting local +// file paths with a message naming the unsupported action (e.g. "delete local files"). +func parseCloudOnly(ref, cwd, home, action string) (Destination, error) { lower := strings.ToLower(ref) if !strings.HasPrefix(lower, "pod:") && !strings.Contains(lower, "://") { abs, _ := filepath.Abs(ref) abs = withSnapshotExt(abs) - return Destination{}, fmt.Errorf("'%s' resolves to a local file (%s); CLI cannot delete local files", ref, displayPath(abs, cwd, home)) + return Destination{}, fmt.Errorf("'%s' resolves to a local file (%s); CLI cannot %s", ref, displayPath(abs, cwd, home), action) } return ParseSource(ref, home) } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index bba43f3a..258a70a4 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -16,6 +16,35 @@ import ( +func TestParseShowable(t *testing.T) { + t.Parallel() + home := t.TempDir() + cwd, err := os.Getwd() + require.NoError(t, err) + + t.Run("accepts pod ref", func(t *testing.T) { + t.Parallel() + dest, err := snapshot.ParseShowable("pod:my-baseline", cwd, home) + require.NoError(t, err) + assert.Equal(t, snapshot.KindPod, dest.Kind) + assert.Equal(t, "my-baseline", dest.Value) + }) + + t.Run("rejects local path", func(t *testing.T) { + t.Parallel() + _, err := snapshot.ParseShowable("./my-snapshot", cwd, home) + require.Error(t, err) + assert.Contains(t, err.Error(), "show local snapshots") + }) + + t.Run("rejects invalid pod name", func(t *testing.T) { + t.Parallel() + _, err := snapshot.ParseShowable("pod:bad name", cwd, home) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid pod name") + }) +} + func TestParseSource(t *testing.T) { t.Parallel() wd, err := os.Getwd() diff --git a/internal/snapshot/show.go b/internal/snapshot/show.go new file mode 100644 index 00000000..6774eae2 --- /dev/null +++ b/internal/snapshot/show.go @@ -0,0 +1,68 @@ +package snapshot + +import ( + "context" + "errors" + "fmt" + + "github.com/localstack/lstk/internal/api" + "github.com/localstack/lstk/internal/output" +) + +type CloudPodInspector interface { + GetCloudPod(ctx context.Context, authToken, podName string) (*api.CloudPodDetails, error) +} + +// Show fetches a single cloud snapshot's metadata from the platform and emits it +// as a SnapshotShownEvent. It is cloud-only and requires authentication. +func Show(ctx context.Context, inspector CloudPodInspector, authToken, podName string, sink output.Sink) error { + if authToken == "" { + sink.Emit(output.ErrorEvent{ + Title: "Authentication required to show snapshots", + Actions: []output.ErrorAction{ + {Label: "Log in:", Value: "lstk login"}, + {Label: "Or set a token:", Value: "export LOCALSTACK_AUTH_TOKEN="}, + }, + }) + return output.NewSilentError(fmt.Errorf("authentication required: no auth token")) + } + + sink.Emit(output.SpinnerStart("Fetching snapshot")) + details, err := inspector.GetCloudPod(ctx, authToken, podName) + sink.Emit(output.SpinnerStop()) + if err != nil { + if errors.Is(err, api.ErrCloudPodNotFound) { + sink.Emit(output.ErrorEvent{ + Title: fmt.Sprintf("Snapshot 'pod:%s' not found", podName), + Actions: []output.ErrorAction{ + {Label: "List your snapshots:", Value: "lstk snapshot list"}, + }, + }) + return output.NewSilentError(err) + } + return fmt.Errorf("show snapshot: %w", err) + } + + sink.Emit(output.DeferredEvent{Inner: toShownEvent(details)}) + return nil +} + +func toShownEvent(d *api.CloudPodDetails) output.SnapshotShownEvent { + resources := make([]output.SnapshotResourceLine, len(d.Resources)) + for i, r := range d.Resources { + counts := make([]output.SnapshotResourceCount, len(r.Counts)) + for j, c := range r.Counts { + counts[j] = output.SnapshotResourceCount{Count: c.Count, Noun: c.Noun} + } + resources[i] = output.SnapshotResourceLine{Service: r.Service, Counts: counts} + } + return output.SnapshotShownEvent{ + Name: d.Name, + Created: d.Created, + Size: d.Size, + LocalStackVersion: d.LocalStackVersion, + Message: d.Message, + Services: d.Services, + Resources: resources, + } +} diff --git a/internal/ui/run_snapshot_show.go b/internal/ui/run_snapshot_show.go new file mode 100644 index 00000000..3faaa4f1 --- /dev/null +++ b/internal/ui/run_snapshot_show.go @@ -0,0 +1,14 @@ +package ui + +import ( + "context" + + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/snapshot" +) + +func RunSnapshotShow(parentCtx context.Context, inspector snapshot.CloudPodInspector, authToken, podName string) error { + return runWithTUI(parentCtx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return snapshot.Show(ctx, inspector, authToken, podName, sink) + }) +} diff --git a/test/integration/snapshot_show_test.go b/test/integration/snapshot_show_test.go new file mode 100644 index 00000000..5ef62cfa --- /dev/null +++ b/test/integration/snapshot_show_test.go @@ -0,0 +1,184 @@ +package integration_test + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// showCapture records what the single-pod platform endpoint received. +type showCapture struct { + mu sync.Mutex + called bool + path string + auth string +} + +func (c *showCapture) record(r *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + c.called = true + c.path = r.URL.Path + c.auth = r.Header.Get("Authorization") +} + +func (c *showCapture) get() (called bool, path, auth string) { + c.mu.Lock() + defer c.mu.Unlock() + return c.called, c.path, c.auth +} + +// mockCloudPodServer serves GET /v1/cloudpods/ with the given JSON body. +func mockCloudPodServer(t *testing.T, name, body string, cap *showCapture) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/cloudpods/"+name && r.Method == http.MethodGet { + if cap != nil { + cap.record(r) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestSnapshotShowSuccessWithoutDocker(t *testing.T) { + t.Parallel() + + var cap showCapture + body := `{ + "pod_name": "my-baseline", + "max_version": 1, + "versions": [{ + "version": 1, + "localstack_version": "2026.03", + "size": 49597645, + "description": "Pre-refactor baseline", + "created_at": 1776263520, + "services": ["s3", "lambda", "dynamodb", "sqs"], + "cloud_control_resources": "{\"AWS::S3::Bucket\":[{\"id\":\"a\"},{\"id\":\"b\"},{\"id\":\"c\"}],\"AWS::Lambda::Function\":[{\"id\":\"f1\"}]}" + }] + }` + srv := mockCloudPodServer(t, "my-baseline", body, &cap) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), + listEnv(t, srv, "test-token"), + "--non-interactive", "snapshot", "show", "pod:my-baseline", + ) + require.NoError(t, err, "lstk snapshot show failed: %s", stderr) + + called, path, _ := cap.get() + require.True(t, called, "the single-pod endpoint should have been called") + assert.Equal(t, "/v1/cloudpods/my-baseline", path) + + assert.Contains(t, stdout, "my-baseline") + assert.Contains(t, stdout, "2026-04-15 14:32 UTC") + assert.Contains(t, stdout, "47.3 MB") + assert.Contains(t, stdout, "2026.03") + assert.Contains(t, stdout, "Pre-refactor baseline") + assert.Contains(t, stdout, "s3, lambda, dynamodb, sqs") + assert.Contains(t, stdout, "Resources") + assert.Contains(t, stdout, "3 buckets") + assert.Contains(t, stdout, "1 function\n", "count of one should use the singular noun") +} + +func TestSnapshotShowWithoutResources(t *testing.T) { + t.Parallel() + + body := `{"pod_name": "bare", "max_version": 1, + "versions": [{"version": 1, "localstack_version": "2026.03", "services": ["s3", "sqs"], "size": 2048}]}` + srv := mockCloudPodServer(t, "bare", body, nil) + + stdout, stderr, err := runLstk(t, testContext(t), t.TempDir(), + listEnv(t, srv, "test-token"), + "--non-interactive", "snapshot", "show", "pod:bare", + ) + require.NoError(t, err, "lstk snapshot show failed: %s", stderr) + assert.Contains(t, stdout, "s3, sqs") + assert.NotContains(t, stdout, "Resources", "Resources section must be omitted when no counts are available") +} + +func TestSnapshotShowSendsBasicAuthHeader(t *testing.T) { + t.Parallel() + + var cap showCapture + body := `{"pod_name": "p", "max_version": 1, "versions": [{"version": 1}]}` + srv := mockCloudPodServer(t, "p", body, &cap) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), + listEnv(t, srv, "test-token"), + "--non-interactive", "snapshot", "show", "pod:p", + ) + require.NoError(t, err, "lstk snapshot show failed: %s", stderr) + _, _, auth := cap.get() + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte(":test-token")) + assert.Equal(t, expected, auth) +} + +func TestSnapshotShowRejectsLocalPath(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("platform must not be called for a local path; got %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + _, stderr, err := runLstk(t, testContext(t), t.TempDir(), + listEnv(t, srv, "test-token"), + "--non-interactive", "snapshot", "show", "./my-snapshot", + ) + requireExitCode(t, 1, err) + assert.Contains(t, strings.ToLower(stderr), "local") +} + +func TestSnapshotShowNotFound(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), + listEnv(t, srv, "test-token"), + "--non-interactive", "snapshot", "show", "pod:missing", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "not found") + assert.Contains(t, stdout, "lstk snapshot list") +} + +func TestSnapshotShowRequiresAuthToken(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("platform must not be called without an auth token; got %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + environ := env.Environ(testEnvWithHome(t.TempDir(), "")). + With(env.APIEndpoint, srv.URL). + Without(env.AuthToken) + + stdout, _, err := runLstk(t, testContext(t), t.TempDir(), + environ, + "--non-interactive", "snapshot", "show", "pod:my-baseline", + ) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Authentication required") + assert.Contains(t, stdout, "lstk login") +}