From 7f9da6004ddc2b7eb81144e65294e7fbd11e17f7 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Fri, 7 Aug 2026 14:12:58 +0530 Subject: [PATCH 1/2] Add state persistence: snapshot on stop, restore on start (#335 P0-b) The standalone server was in-memory only, so `cloudemu stop`/`start` (and any restart) lost every emulated resource. This adds opt-in persistence for the data-bearing services that share a cross-provider driver interface. - New persist package: schema-versioned Snapshot/ProviderState with Export and Restore that read/write through the storage/database/secrets/compute driver interfaces (Go has no generic serializer for the unexported, mutex/func-laden in-memory value types, so this goes through the same interfaces seed uses). Covers S3/Blob/GCS, DynamoDB/Firestore/Cosmos, Secrets, and compute instances across all three providers in one human-readable, git-diffable JSON file. - serve: --persist (restore after providers are built, snapshot after graceful shutdown so no in-flight request races the read), --state-file, and --persist-metadata-only to omit object bodies for a smaller snapshot. - lifecycle CLI: `start` manages the snapshot path in the run dir; `delete` removes it. Object bodies, secret values, and table items are saved by default; compute instances are recreated via RunInstances (image/type/tags preserved, fresh IDs/IPs). Services without a unified driver interface still start empty and are tracked as follow-ups under #107. --- cmd/cloudemu/lifecycle.go | 36 +++- cmd/cloudemu/serve.go | 119 ++++++++-- docs/standalone-server.md | 51 ++++- persist/persist.go | 444 ++++++++++++++++++++++++++++++++++++++ persist/persist_test.go | 181 ++++++++++++++++ 5 files changed, 805 insertions(+), 26 deletions(-) create mode 100644 persist/persist.go create mode 100644 persist/persist_test.go diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go index 48d846f1..14d1bc0b 100644 --- a/cmd/cloudemu/lifecycle.go +++ b/cmd/cloudemu/lifecycle.go @@ -22,6 +22,8 @@ const ( stateFileName = "state.json" logFileName = "cloudemu.log" endpointsFileName = "endpoints.json" + persistFileName = "snapshot.json" + assetsDirName = "assets" startupTimeout = 15 * time.Second stopTimeout = 12 * time.Second @@ -68,6 +70,20 @@ func runDir(home string) (string, error) { func statePath(dir string) string { return filepath.Join(dir, stateFileName) } func logPath(dir string) string { return filepath.Join(dir, logFileName) } func endpointsPath(dir string) string { return filepath.Join(dir, endpointsFileName) } +func persistPath(dir string) string { return filepath.Join(dir, persistFileName) } +func assetsDir(dir string) string { return filepath.Join(dir, assetsDirName) } + +// hasFlag reports whether args contains --name / -name (bare or =value form). +func hasFlag(args []string, name string) bool { + for _, a := range args { + if a == "--"+name || a == "-"+name || + strings.HasPrefix(a, "--"+name+"=") || strings.HasPrefix(a, "-"+name+"=") { + return true + } + } + + return false +} func writeState(dir string, s daemonState) error { if err := os.MkdirAll(dir, dirPerm); err != nil { @@ -364,12 +380,26 @@ func runStart(args []string) error { // so last-wins flag parsing can't redirect the file or duplicate the flag. rest = stripFlag(rest, "endpoints-file", true) rest = stripFlag(rest, "quiet", false) + // start also manages the persistence snapshot path under the run dir; drop a + // user --state-file so it can't point elsewhere. + rest = stripFlag(rest, "state-file", true) dir, err := runDir(home) if err != nil { return err } + // Opt-in persistence: if the user asked to persist, point serve at a snapshot + // file in the run dir (and imply --persist when only --persist-metadata-only + // is given). + if hasFlag(rest, "persist") || hasFlag(rest, "persist-metadata-only") { + if !hasFlag(rest, "persist") { + rest = append(rest, "--persist") + } + + rest = append(rest, "--state-file", persistPath(dir)) + } + if s, rErr := readState(dir); rErr == nil && processAlive(s.PID) && daemonReachable(s.Endpoints) { fmt.Printf("cloudemu already running (pid %d)\n", s.PID) printEndpoints(s.Endpoints) @@ -574,12 +604,16 @@ func runDelete(args []string) error { return err } - for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir)} { + for _, p := range []string{statePath(dir), logPath(dir), endpointsPath(dir), persistPath(dir)} { if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) { return rmErr } } + if rmErr := os.RemoveAll(assetsDir(dir)); rmErr != nil { + return rmErr + } + // Remove the dir only if it's now empty (ignore "not empty" / "not exist"). _ = os.Remove(dir) diff --git a/cmd/cloudemu/serve.go b/cmd/cloudemu/serve.go index 81c083e5..86162053 100644 --- a/cmd/cloudemu/serve.go +++ b/cmd/cloudemu/serve.go @@ -6,7 +6,6 @@ import ( "errors" "flag" "fmt" - eksprov "github.com/stackshy/cloudemu/v2/providers/aws/eks" "net" "net/http" "os" @@ -18,6 +17,8 @@ import ( "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/persist" + eksprov "github.com/stackshy/cloudemu/v2/providers/aws/eks" "github.com/stackshy/cloudemu/v2/seed" "github.com/stackshy/cloudemu/v2/server/admin" awsserver "github.com/stackshy/cloudemu/v2/server/aws" @@ -26,26 +27,32 @@ import ( "github.com/stackshy/cloudemu/v2/services/kubernetes" ) +// errStateFileRequired is returned when --persist is set without --state-file. +var errStateFileRequired = errors.New("--persist requires --state-file") + // serveConfig holds the resolved serve flags. type serveConfig struct { - providers string - host string - awsPort string - azurePort string - gcpPort string - k8sPort string - accountID string - region string - projectID string - latency time.Duration - tlsCert string - tlsKey string - tlsHosts stringList - endpoints string - admin bool - logReqs bool - quiet bool - shutdownTO time.Duration + providers string + host string + awsPort string + azurePort string + gcpPort string + k8sPort string + accountID string + region string + projectID string + latency time.Duration + tlsCert string + tlsKey string + tlsHosts stringList + endpoints string + admin bool + logReqs bool + quiet bool + shutdownTO time.Duration + persist bool + stateFile string + persistMetaOnly bool } // stringList is a repeatable string flag (e.g. --tls-host a --tls-host b). @@ -78,6 +85,9 @@ func runServe(args []string) error { fs.BoolVar(&c.logReqs, "log-requests", false, "log every HTTP request (method, path, status, duration)") fs.BoolVar(&c.quiet, "quiet", false, "suppress the startup banner") fs.DurationVar(&c.shutdownTO, "shutdown-timeout", 10*time.Second, "grace period for in-flight requests on shutdown") + fs.BoolVar(&c.persist, "persist", false, "save state to --state-file on shutdown and restore it on startup (includes object bodies)") + fs.StringVar(&c.stateFile, "state-file", "", "path to the JSON state snapshot (required with --persist)") + fs.BoolVar(&c.persistMetaOnly, "persist-metadata-only", false, "persist resource structure but omit object bodies (smaller snapshot)") fs.Usage = func() { fmt.Fprintf(fs.Output(), "Usage: cloudemu serve [flags]\n\nStart the standalone emulator. Flags:\n") fs.PrintDefaults() @@ -89,6 +99,10 @@ func runServe(args []string) error { return errors.New("--tls-cert and --tls-key must be given together") } + if c.persist && c.stateFile == "" { + return errStateFileRequired + } + sel, err := parseProviders(c.providers) if err != nil { return err @@ -196,6 +210,14 @@ func runServe(args []string) error { } rebuild() // populate the backends before serving + // Restore persisted state into the freshly-built providers before serving, + // so the first request already sees the resources from the last run. + if c.persist { + if err := restoreState(context.Background(), c.stateFile, targets); err != nil { + return fmt.Errorf("restore persisted state: %w", err) + } + } + // seedFor applies a fixture body to a provider's current drivers. It shares // rebuildMu with reset so a seed and a reset can't run against each other's // half-built state. @@ -343,9 +365,68 @@ func runServe(args []string) error { shutErr = err } } + + // Snapshot after Shutdown so no in-flight request can mutate state mid-read. + if c.persist { + if err := snapshotState(context.Background(), c.stateFile, !c.persistMetaOnly, targets); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to save state to %s: %v\n", c.stateFile, err) + } else if !c.quiet { + fmt.Fprintf(os.Stdout, "state saved to %s\n", c.stateFile) + } + } + return shutErr } +// restoreState loads the snapshot at path (if any) into the freshly-built +// providers. A missing file is not an error — the server just starts empty, +// exactly as it does without --persist. Providers present in the snapshot but +// not running now are skipped. +func restoreState(ctx context.Context, path string, targets map[string]seed.Target) error { + snap, err := persist.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + return err + } + + for name := range snap.Providers { + t, ok := targets[name] + if !ok { + continue + } + + ps := snap.Providers[name] + if err := persist.Restore(ctx, t, &ps); err != nil { + return fmt.Errorf("restore %s: %w", name, err) + } + } + + return nil +} + +// snapshotState exports every running provider's state and writes the snapshot +// file. Called after Shutdown, so the providers are quiescent. +func snapshotState(ctx context.Context, path string, includeAssets bool, targets map[string]seed.Target) error { + snap := persist.Snapshot{ + SchemaVersion: persist.SchemaVersion, + Providers: make(map[string]persist.ProviderState, len(targets)), + } + + for name, t := range targets { + ps, err := persist.Export(ctx, t, persist.Options{IncludeAssets: includeAssets}) + if err != nil { + return fmt.Errorf("export %s: %w", name, err) + } + + snap.Providers[name] = ps + } + + return snap.WriteFile(path) +} + type namedServer struct { name string srv *http.Server diff --git a/docs/standalone-server.md b/docs/standalone-server.md index eacc26ed..8ee18c16 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -83,8 +83,44 @@ already-running instance). `--endpoints-file` and `--quiet` are managed by Run state (pid, log, resolved endpoints) lives under `~/.cloudemu/` by default; point it elsewhere with `--home ` (pass the same `--home` to the other -lifecycle commands). State is **not** yet persisted across `stop`/`start` — the -server still starts empty each time (see "Not yet included"). +lifecycle commands). + +### Persistence across restarts + +By default the emulator starts empty every time. Pass `--persist` to `start` and +your resources survive `stop`→`start`: + +```sh +cloudemu start --persist # save on stop, restore on start +# create buckets/tables/objects… +cloudemu stop # writes ~/.cloudemu//snapshot.json +cloudemu start --persist # your resources are back +cloudemu delete # also removes the snapshot + assets +``` + +`start` manages the snapshot path for you (in the run dir). Persistence is +**opt-in**; when on, it saves your resources *including* object bodies, so an S3 +object comes back with its contents intact. If you only care about the resource +structure and want a smaller snapshot, add `--persist-metadata-only` to skip +object bytes: + +```sh +cloudemu start --persist # full: structure + object bodies +cloudemu start --persist --persist-metadata-only # smaller: structure only +``` + +Coverage is currently the data-bearing services that share a cross-provider +driver interface — object storage (S3/Blob/GCS), NoSQL tables +(DynamoDB/Firestore/Cosmos), secrets (Secrets Manager/Key Vault/Secret Manager), +and compute instances (EC2/VMs/GCE); other services still start empty. The +snapshot is a single human-readable JSON file spanning all three providers, so +you can inspect or `git diff` it. + +Fidelity notes: object bodies, secret values, and table items are all saved by +default; pass `--persist-metadata-only` to drop object *bodies* (structure only) +for a smaller snapshot. Compute instances are recreated via `RunInstances`, so +image/type/tags are preserved but the emulator assigns fresh instance IDs and +IPs on restore. ## Ports @@ -169,6 +205,9 @@ cloudemu serve --tls-host myhost.local --tls-host 192.168.1.10 | `--tls-cert` / `--tls-key` | — | supply your own Azure cert (else self-signed) | | `--tls-host` | — | extra SAN for the generated cert (repeatable) | | `--endpoints-file` | — | write resolved endpoints as JSON | +| `--persist` | `false` | save state on shutdown and restore it on startup, including object bodies (requires `--state-file`) | +| `--state-file` | — | path to the JSON state snapshot (`start` manages this for you) | +| `--persist-metadata-only` | `false` | persist resource structure but omit object bodies (smaller snapshot) | | `--log-requests` | `false` | log every request | | `--quiet` | `false` | suppress the startup banner | | `--shutdown-timeout` | `10s` | grace period for in-flight requests on Ctrl-C | @@ -239,7 +278,7 @@ seed.Apply(ctx, f, seed.Target{Storage: aws.S3, Database: aws.DynamoDB}) ## Not yet included -**Persistence** across restarts and **snapshot/restore** aren't part of this -mode yet — snapshot/restore needs the state model tracked in #107. Docker -packaging (#247) and a Testcontainers module (#248) build directly on this -binary. +**Persistence** covers object storage and NoSQL tables today (see "Persistence +across restarts" above); the remaining services and full **snapshot/restore** +fidelity are tracked in #107. Docker packaging (#247) and a Testcontainers +module (#248) build directly on this binary. diff --git a/persist/persist.go b/persist/persist.go new file mode 100644 index 00000000..c4f8bf31 --- /dev/null +++ b/persist/persist.go @@ -0,0 +1,444 @@ +// Package persist snapshots cloudemu provider state to disk and restores it, so +// emulated resources survive a stop/start of the standalone server. +// +// State can't be serialized generically (Go has no pickle; the in-memory value +// types hold mutexes, funcs, and nested stores that no reflection codec can +// round-trip). Instead Export/Restore read and write through the same +// provider-agnostic driver interfaces the seed package uses — so one snapshot +// format spans AWS, Azure, and GCP, and the on-disk file is human-readable and +// diffable JSON rather than an opaque binary blob. +package persist + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/stackshy/cloudemu/v2/seed" + computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" + secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" + storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" +) + +// SchemaVersion is the on-disk snapshot format version. Bump it on a +// backward-incompatible change to the JSON shape. +const SchemaVersion = 1 + +const ( + defaultContentType = "application/octet-stream" + + dirPerm = 0o755 + filePerm = 0o600 +) + +// Sentinel errors so callers (and err113) get static, wrappable failures. +var ( + errNoDriver = errors.New("snapshot names a resource kind with no driver in the target") + errSchema = errors.New("unsupported snapshot schema version") +) + +// Snapshot is a multi-cloud, point-in-time capture of provider state, written +// as one JSON document. +type Snapshot struct { + SchemaVersion int `json:"schemaVersion"` + Providers map[string]ProviderState `json:"providers,omitempty"` +} + +// ProviderState is a single provider's persisted resources. +type ProviderState struct { + Buckets []Bucket `json:"buckets,omitempty"` + Tables []Table `json:"tables,omitempty"` + Secrets []Secret `json:"secrets,omitempty"` + Instances []Instance `json:"instances,omitempty"` +} + +// Bucket is an object-storage bucket and its objects. +type Bucket struct { + Name string `json:"name"` + Objects []Object `json:"objects,omitempty"` +} + +// Object is a stored object. Body is nil in a metadata-only snapshot; JSON +// encodes it as base64. +type Object struct { + Key string `json:"key"` + ContentType string `json:"contentType,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// Table is a NoSQL table and its items. +type Table struct { + Name string `json:"name"` + PartitionKey string `json:"partitionKey"` + SortKey string `json:"sortKey,omitempty"` + Items []map[string]any `json:"items,omitempty"` +} + +// Secret is a secret and its current value. The value is always captured (a +// secret without its value can't be restored usefully); metadata-only affects +// only bulk object bodies. +type Secret struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Value []byte `json:"value,omitempty"` +} + +// Instance is a compute instance's launch shape. Restore recreates it via +// RunInstances, so the emulator assigns a fresh instance ID/IP — image, type, +// and tags are preserved, identifiers are not. +type Instance struct { + ImageID string `json:"imageId"` + InstanceType string `json:"instanceType"` + Tags map[string]string `json:"tags,omitempty"` +} + +// Options controls what Export captures. +type Options struct { + // IncludeAssets includes object bodies. When false (the default) the + // snapshot is metadata-only — bucket/object/table structure without the + // object bytes — which keeps the file small. + IncludeAssets bool +} + +// Export reads a provider's current state through its drivers. A nil driver for +// a kind simply contributes nothing (that kind isn't persisted). +func Export(ctx context.Context, t seed.Target, opts Options) (ProviderState, error) { + buckets, err := exportBuckets(ctx, t.Storage, opts.IncludeAssets) + if err != nil { + return ProviderState{}, err + } + + tables, err := exportTables(ctx, t.Database) + if err != nil { + return ProviderState{}, err + } + + secrets, err := exportSecrets(ctx, t.Secrets) + if err != nil { + return ProviderState{}, err + } + + instances, err := exportInstances(ctx, t.Compute) + if err != nil { + return ProviderState{}, err + } + + return ProviderState{Buckets: buckets, Tables: tables, Secrets: secrets, Instances: instances}, nil +} + +// Restore writes a provider state back through its drivers, into what should be +// a freshly-built (empty) provider. +func Restore(ctx context.Context, t seed.Target, ps *ProviderState) error { + if err := restoreBuckets(ctx, t.Storage, ps.Buckets); err != nil { + return err + } + + if err := restoreTables(ctx, t.Database, ps.Tables); err != nil { + return err + } + + if err := restoreSecrets(ctx, t.Secrets, ps.Secrets); err != nil { + return err + } + + return restoreInstances(ctx, t.Compute, ps.Instances) +} + +func exportBuckets(ctx context.Context, d storagedriver.Bucket, includeAssets bool) ([]Bucket, error) { + if d == nil { + return nil, nil + } + + infos, err := d.ListBuckets(ctx) + if err != nil { + return nil, fmt.Errorf("list buckets: %w", err) + } + + out := make([]Bucket, 0, len(infos)) + + for _, bi := range infos { + objs, err := exportObjects(ctx, d, bi.Name, includeAssets) + if err != nil { + return nil, err + } + + out = append(out, Bucket{Name: bi.Name, Objects: objs}) + } + + return out, nil +} + +func exportObjects(ctx context.Context, d storagedriver.Bucket, bucket string, includeAssets bool) ([]Object, error) { + var ( + out []Object + token string + ) + + for { + res, err := d.ListObjects(ctx, bucket, storagedriver.ListOptions{PageToken: token}) + if err != nil { + return nil, fmt.Errorf("list objects in %q: %w", bucket, err) + } + + for _, oi := range res.Objects { + obj := Object{Key: oi.Key, ContentType: oi.ContentType} + + if includeAssets { + full, gErr := d.GetObject(ctx, bucket, oi.Key) + if gErr != nil { + return nil, fmt.Errorf("get object %s/%s: %w", bucket, oi.Key, gErr) + } + + obj.Body = full.Data + } + + out = append(out, obj) + } + + if !res.IsTruncated || res.NextPageToken == "" { + break + } + + token = res.NextPageToken + } + + return out, nil +} + +func exportTables(ctx context.Context, d dbdriver.Database) ([]Table, error) { + if d == nil { + return nil, nil + } + + names, err := d.ListTables(ctx) + if err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + + out := make([]Table, 0, len(names)) + + for _, name := range names { + cfg, err := d.DescribeTable(ctx, name) + if err != nil { + return nil, fmt.Errorf("describe table %q: %w", name, err) + } + + items, err := scanAll(ctx, d, name) + if err != nil { + return nil, err + } + + out = append(out, Table{Name: name, PartitionKey: cfg.PartitionKey, SortKey: cfg.SortKey, Items: items}) + } + + return out, nil +} + +func scanAll(ctx context.Context, d dbdriver.Database, table string) ([]map[string]any, error) { + var ( + items []map[string]any + token string + ) + + for { + res, err := d.Scan(ctx, dbdriver.ScanInput{Table: table, PageToken: token}) + if err != nil { + return nil, fmt.Errorf("scan table %q: %w", table, err) + } + + items = append(items, res.Items...) + + if res.NextPageToken == "" { + break + } + + token = res.NextPageToken + } + + return items, nil +} + +func exportSecrets(ctx context.Context, d secretsdriver.Secrets) ([]Secret, error) { + if d == nil { + return nil, nil + } + + infos, err := d.ListSecrets(ctx) + if err != nil { + return nil, fmt.Errorf("list secrets: %w", err) + } + + out := make([]Secret, 0, len(infos)) + + for _, si := range infos { + ver, err := d.GetSecretValue(ctx, si.Name, "") + if err != nil { + return nil, fmt.Errorf("get secret value %q: %w", si.Name, err) + } + + s := Secret{Name: si.Name, Description: si.Description, Tags: si.Tags} + if ver != nil { + s.Value = ver.Value + } + + out = append(out, s) + } + + return out, nil +} + +func exportInstances(ctx context.Context, d computedriver.Compute) ([]Instance, error) { + if d == nil { + return nil, nil + } + + insts, err := d.DescribeInstances(ctx, nil, nil) + if err != nil { + return nil, fmt.Errorf("describe instances: %w", err) + } + + out := make([]Instance, 0, len(insts)) + + for i := range insts { + in := &insts[i] + // Terminated instances are tombstones; recreating them would resurrect + // deleted resources on restore. + if in.State == "terminated" { + continue + } + + out = append(out, Instance{ImageID: in.ImageID, InstanceType: in.InstanceType, Tags: in.Tags}) + } + + return out, nil +} + +func restoreBuckets(ctx context.Context, d storagedriver.Bucket, buckets []Bucket) error { + if len(buckets) == 0 { + return nil + } + + if d == nil { + return fmt.Errorf("%w: buckets", errNoDriver) + } + + for _, b := range buckets { + if err := d.CreateBucket(ctx, b.Name); err != nil { + return fmt.Errorf("restore bucket %q: %w", b.Name, err) + } + + for _, o := range b.Objects { + ct := o.ContentType + if ct == "" { + ct = defaultContentType + } + + if err := d.PutObject(ctx, b.Name, o.Key, o.Body, ct, nil); err != nil { + return fmt.Errorf("restore object %s/%s: %w", b.Name, o.Key, err) + } + } + } + + return nil +} + +func restoreTables(ctx context.Context, d dbdriver.Database, tables []Table) error { + if len(tables) == 0 { + return nil + } + + if d == nil { + return fmt.Errorf("%w: tables", errNoDriver) + } + + for _, tb := range tables { + cfg := dbdriver.TableConfig{Name: tb.Name, PartitionKey: tb.PartitionKey, SortKey: tb.SortKey} + if err := d.CreateTable(ctx, cfg); err != nil { + return fmt.Errorf("restore table %q: %w", tb.Name, err) + } + + for i, item := range tb.Items { + if err := d.PutItem(ctx, tb.Name, item); err != nil { + return fmt.Errorf("restore table %q item %d: %w", tb.Name, i, err) + } + } + } + + return nil +} + +func restoreSecrets(ctx context.Context, d secretsdriver.Secrets, secrets []Secret) error { + if len(secrets) == 0 { + return nil + } + + if d == nil { + return fmt.Errorf("%w: secrets", errNoDriver) + } + + for _, s := range secrets { + cfg := secretsdriver.SecretConfig{Name: s.Name, Description: s.Description, Tags: s.Tags} + if _, err := d.CreateSecret(ctx, cfg, s.Value); err != nil { + return fmt.Errorf("restore secret %q: %w", s.Name, err) + } + } + + return nil +} + +func restoreInstances(ctx context.Context, d computedriver.Compute, instances []Instance) error { + if len(instances) == 0 { + return nil + } + + if d == nil { + return fmt.Errorf("%w: instances", errNoDriver) + } + + for _, in := range instances { + cfg := computedriver.InstanceConfig{ImageID: in.ImageID, InstanceType: in.InstanceType, Tags: in.Tags} + if _, err := d.RunInstances(ctx, cfg, 1); err != nil { + return fmt.Errorf("restore instance (%s): %w", in.ImageID, err) + } + } + + return nil +} + +// WriteFile writes the snapshot as indented JSON, creating parent directories. +func (s Snapshot) WriteFile(path string) error { + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return err + } + + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, b, filePerm) +} + +// ReadFile loads a snapshot from disk, rejecting an unknown schema version. +func ReadFile(path string) (Snapshot, error) { + b, err := os.ReadFile(path) + if err != nil { + return Snapshot{}, err + } + + var s Snapshot + if err := json.Unmarshal(b, &s); err != nil { + return Snapshot{}, fmt.Errorf("parse snapshot %q: %w", path, err) + } + + if s.SchemaVersion != SchemaVersion { + return Snapshot{}, fmt.Errorf("%w: got %d, want %d", errSchema, s.SchemaVersion, SchemaVersion) + } + + return s, nil +} diff --git a/persist/persist_test.go b/persist/persist_test.go new file mode 100644 index 00000000..e84cf8aa --- /dev/null +++ b/persist/persist_test.go @@ -0,0 +1,181 @@ +package persist_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/persist" + "github.com/stackshy/cloudemu/v2/seed" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +// TestExportRestoreRoundTrip is the core persistence guarantee: state exported +// from one provider, serialized to JSON, and restored into a fresh provider is +// intact — buckets/objects and tables/items survive a stop→start cycle. +func TestExportRestoreRoundTrip(t *testing.T) { + ctx := context.Background() + + src := cloudemu.NewAWS() + tSrc := seed.Target{Storage: src.S3, Database: src.DynamoDB, Secrets: src.SecretsManager, Compute: src.EC2} + + if err := seed.Apply(ctx, seed.Fixtures{ + Buckets: []seed.Bucket{{ + Name: "app-data", + Objects: []seed.Object{{Key: "config.yaml", Body: "port: 8080", ContentType: "text/yaml"}}, + }}, + Tables: []seed.Table{{ + Name: "users", + PartitionKey: "id", + Items: []map[string]any{{"id": "u1", "name": "Ada"}}, + }}, + Secrets: []seed.Secret{{Name: "db-password", Value: "s3cr3t", Description: "prod db"}}, + Instances: []seed.Instance{{ + ImageID: "ami-123", InstanceType: "t3.micro", Count: 2, Name: "web", + }}, + }, tSrc); err != nil { + t.Fatalf("seed source: %v", err) + } + + ps, err := persist.Export(ctx, tSrc, persist.Options{IncludeAssets: true}) + if err != nil { + t.Fatalf("export: %v", err) + } + + // Round-trip through JSON exactly as the on-disk snapshot would. + raw, err := json.Marshal(ps) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var restored persist.ProviderState + if err := json.Unmarshal(raw, &restored); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + dst := cloudemu.NewAWS() + tDst := seed.Target{Storage: dst.S3, Database: dst.DynamoDB, Secrets: dst.SecretsManager, Compute: dst.EC2} + if err := persist.Restore(ctx, tDst, &restored); err != nil { + t.Fatalf("restore: %v", err) + } + + obj, err := dst.S3.GetObject(ctx, "app-data", "config.yaml") + if err != nil { + t.Fatalf("get restored object: %v", err) + } + if string(obj.Data) != "port: 8080" { + t.Fatalf("restored object body = %q, want %q", obj.Data, "port: 8080") + } + if obj.Info.ContentType != "text/yaml" { + t.Fatalf("restored content-type = %q, want text/yaml", obj.Info.ContentType) + } + + res, err := dst.DynamoDB.Scan(ctx, dbdriver.ScanInput{Table: "users"}) + if err != nil { + t.Fatalf("scan restored table: %v", err) + } + if len(res.Items) != 1 || res.Items[0]["name"] != "Ada" { + t.Fatalf("restored items = %v, want one item {id:u1,name:Ada}", res.Items) + } + + sv, err := dst.SecretsManager.GetSecretValue(ctx, "db-password", "") + if err != nil { + t.Fatalf("get restored secret: %v", err) + } + if string(sv.Value) != "s3cr3t" { + t.Fatalf("restored secret value = %q, want s3cr3t", sv.Value) + } + + insts, err := dst.EC2.DescribeInstances(ctx, nil, nil) + if err != nil { + t.Fatalf("describe restored instances: %v", err) + } + if len(insts) != 2 { + t.Fatalf("restored instance count = %d, want 2", len(insts)) + } + if insts[0].ImageID != "ami-123" || insts[0].Tags["Name"] != "web" { + t.Fatalf("restored instance = %+v, want ami-123 / Name=web", insts[0]) + } +} + +// TestExportMetadataOnlyOmitsBodies verifies the default (metadata-only) export +// records object keys/metadata but drops the bytes, and that IncludeAssets keeps +// them — the flag that keeps the snapshot file KB-sized by default. +func TestExportMetadataOnlyOmitsBodies(t *testing.T) { + ctx := context.Background() + + src := cloudemu.NewAWS() + tSrc := seed.Target{Storage: src.S3} + if err := seed.Apply(ctx, seed.Fixtures{ + Buckets: []seed.Bucket{{Name: "b", Objects: []seed.Object{{Key: "k", Body: "secret-bytes"}}}}, + }, tSrc); err != nil { + t.Fatalf("seed source: %v", err) + } + + meta, err := persist.Export(ctx, tSrc, persist.Options{IncludeAssets: false}) + if err != nil { + t.Fatalf("export metadata-only: %v", err) + } + if len(meta.Buckets) != 1 || len(meta.Buckets[0].Objects) != 1 { + t.Fatalf("metadata-only dropped bucket/object metadata: %+v", meta) + } + if len(meta.Buckets[0].Objects[0].Body) != 0 { + t.Fatalf("metadata-only kept body: %q", meta.Buckets[0].Objects[0].Body) + } + + full, err := persist.Export(ctx, tSrc, persist.Options{IncludeAssets: true}) + if err != nil { + t.Fatalf("export with assets: %v", err) + } + if string(full.Buckets[0].Objects[0].Body) != "secret-bytes" { + t.Fatalf("asset export dropped body: %q", full.Buckets[0].Objects[0].Body) + } +} + +// TestRestoreEmptyIsNoError confirms an empty/zero snapshot restores cleanly — +// a missing state file (first ever start) must not error. +func TestRestoreEmptyIsNoError(t *testing.T) { + ctx := context.Background() + dst := cloudemu.NewAWS() + tDst := seed.Target{Storage: dst.S3, Database: dst.DynamoDB} + if err := persist.Restore(ctx, tDst, &persist.ProviderState{}); err != nil { + t.Fatalf("restore empty: %v", err) + } +} + +// TestSnapshotFileRoundTrip covers the on-disk layer: WriteFile then ReadFile +// preserves content, and an unknown schema version is rejected rather than +// silently mis-restored. +func TestSnapshotFileRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "snapshot.json") + + snap := persist.Snapshot{ + SchemaVersion: persist.SchemaVersion, + Providers: map[string]persist.ProviderState{ + "aws": {Secrets: []persist.Secret{{Name: "k", Value: []byte("v")}}}, + }, + } + if err := snap.WriteFile(path); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got, err := persist.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if got.SchemaVersion != persist.SchemaVersion || len(got.Providers["aws"].Secrets) != 1 { + t.Fatalf("round-trip mismatch: %+v", got) + } + + // A snapshot from a future/unknown schema must be rejected, not mis-parsed. + bad := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(bad, []byte(`{"schemaVersion":999}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := persist.ReadFile(bad); err == nil { + t.Fatal("ReadFile(unknown schema) = nil error, want rejection") + } +} From 493792f98e79aa94854812607858f621163052a8 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Fri, 7 Aug 2026 16:08:53 +0530 Subject: [PATCH 2/2] Harden persistence: atomic snapshot write, fail-open restore, capture GSIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on the state-persistence PR: - WriteFile now writes to a temp file and os.Rename onto the target (atomic on the same filesystem), so an interrupted write (disk-full, OOM, SIGKILL) can't leave a truncated snapshot. - restoreState fails open: a corrupt/unparseable/unknown-schema snapshot logs a warning and starts empty instead of aborting startup on the stop→start path. - Capture and restore table secondary indexes (GSIs) from the table config. - Remove dead assets/ cleanup code (object bodies are inlined in the snapshot; no assets directory is ever written). - Docs: note that --persist-metadata-only restores zero-byte objects. Tests: GSI export/restore round-trip; restoreState ignores a corrupt/missing snapshot. --- cmd/cloudemu/lifecycle.go | 6 --- cmd/cloudemu/persist_serve_test.go | 31 ++++++++++++++++ cmd/cloudemu/serve.go | 15 +++++--- docs/standalone-server.md | 9 +++-- persist/persist.go | 59 ++++++++++++++++++++++++------ persist/persist_test.go | 33 +++++++++++++++++ 6 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 cmd/cloudemu/persist_serve_test.go diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go index 14d1bc0b..6cde4763 100644 --- a/cmd/cloudemu/lifecycle.go +++ b/cmd/cloudemu/lifecycle.go @@ -23,7 +23,6 @@ const ( logFileName = "cloudemu.log" endpointsFileName = "endpoints.json" persistFileName = "snapshot.json" - assetsDirName = "assets" startupTimeout = 15 * time.Second stopTimeout = 12 * time.Second @@ -71,7 +70,6 @@ func statePath(dir string) string { return filepath.Join(dir, stateFileName) func logPath(dir string) string { return filepath.Join(dir, logFileName) } func endpointsPath(dir string) string { return filepath.Join(dir, endpointsFileName) } func persistPath(dir string) string { return filepath.Join(dir, persistFileName) } -func assetsDir(dir string) string { return filepath.Join(dir, assetsDirName) } // hasFlag reports whether args contains --name / -name (bare or =value form). func hasFlag(args []string, name string) bool { @@ -610,10 +608,6 @@ func runDelete(args []string) error { } } - if rmErr := os.RemoveAll(assetsDir(dir)); rmErr != nil { - return rmErr - } - // Remove the dir only if it's now empty (ignore "not empty" / "not exist"). _ = os.Remove(dir) diff --git a/cmd/cloudemu/persist_serve_test.go b/cmd/cloudemu/persist_serve_test.go new file mode 100644 index 00000000..bf3ebc07 --- /dev/null +++ b/cmd/cloudemu/persist_serve_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stackshy/cloudemu/v2/seed" +) + +// TestRestoreStateIgnoresUnreadableFile covers M1's fail-open half: a corrupt or +// missing snapshot must not wedge startup — restoreState warns and starts empty +// rather than returning an error that aborts runServe. +func TestRestoreStateIgnoresUnreadableFile(t *testing.T) { + dir := t.TempDir() + + corrupt := filepath.Join(dir, "snapshot.json") + if err := os.WriteFile(corrupt, []byte("{ not valid json"), 0o600); err != nil { + t.Fatal(err) + } + + if err := restoreState(context.Background(), corrupt, map[string]seed.Target{}); err != nil { + t.Fatalf("restoreState(corrupt) = %v, want nil (start empty)", err) + } + + missing := filepath.Join(dir, "does-not-exist.json") + if err := restoreState(context.Background(), missing, map[string]seed.Target{}); err != nil { + t.Fatalf("restoreState(missing) = %v, want nil", err) + } +} diff --git a/cmd/cloudemu/serve.go b/cmd/cloudemu/serve.go index 86162053..ba305de0 100644 --- a/cmd/cloudemu/serve.go +++ b/cmd/cloudemu/serve.go @@ -384,12 +384,17 @@ func runServe(args []string) error { // not running now are skipped. func restoreState(ctx context.Context, path string, targets map[string]seed.Target) error { snap, err := persist.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err + if errors.Is(err, os.ErrNotExist) { + return nil // first run — nothing to restore + } + + // A corrupt / truncated / unknown-schema snapshot must not wedge startup + // on the very stop→start path this feature serves: warn and start empty + // rather than aborting. + fmt.Fprintf(os.Stderr, "warning: ignoring unreadable state file %s: %v\n", path, err) + + return nil } for name := range snap.Providers { diff --git a/docs/standalone-server.md b/docs/standalone-server.md index 8ee18c16..81f02ffe 100644 --- a/docs/standalone-server.md +++ b/docs/standalone-server.md @@ -117,10 +117,11 @@ snapshot is a single human-readable JSON file spanning all three providers, so you can inspect or `git diff` it. Fidelity notes: object bodies, secret values, and table items are all saved by -default; pass `--persist-metadata-only` to drop object *bodies* (structure only) -for a smaller snapshot. Compute instances are recreated via `RunInstances`, so -image/type/tags are preserved but the emulator assigns fresh instance IDs and -IPs on restore. +default, along with any secondary indexes present in a table's configuration. +Pass `--persist-metadata-only` to drop object *bodies* for a smaller snapshot — +restored objects then come back as zero-byte keys until you re-upload them. +Compute instances are recreated via `RunInstances`, so image/type/tags are +preserved but the emulator assigns fresh instance IDs and IPs on restore. ## Ports diff --git a/persist/persist.go b/persist/persist.go index c4f8bf31..ecb8d1f4 100644 --- a/persist/persist.go +++ b/persist/persist.go @@ -31,8 +31,7 @@ const SchemaVersion = 1 const ( defaultContentType = "application/octet-stream" - dirPerm = 0o755 - filePerm = 0o600 + dirPerm = 0o755 ) // Sentinel errors so callers (and err113) get static, wrappable failures. @@ -70,12 +69,13 @@ type Object struct { Body []byte `json:"body,omitempty"` } -// Table is a NoSQL table and its items. +// Table is a NoSQL table, its secondary indexes, and its items. type Table struct { - Name string `json:"name"` - PartitionKey string `json:"partitionKey"` - SortKey string `json:"sortKey,omitempty"` - Items []map[string]any `json:"items,omitempty"` + Name string `json:"name"` + PartitionKey string `json:"partitionKey"` + SortKey string `json:"sortKey,omitempty"` + GSIs []dbdriver.GSIConfig `json:"gsis,omitempty"` + Items []map[string]any `json:"items,omitempty"` } // Secret is a secret and its current value. The value is always captured (a @@ -233,7 +233,13 @@ func exportTables(ctx context.Context, d dbdriver.Database) ([]Table, error) { return nil, err } - out = append(out, Table{Name: name, PartitionKey: cfg.PartitionKey, SortKey: cfg.SortKey, Items: items}) + out = append(out, Table{ + Name: name, + PartitionKey: cfg.PartitionKey, + SortKey: cfg.SortKey, + GSIs: cfg.GSIs, + Items: items, + }) } return out, nil @@ -357,7 +363,7 @@ func restoreTables(ctx context.Context, d dbdriver.Database, tables []Table) err } for _, tb := range tables { - cfg := dbdriver.TableConfig{Name: tb.Name, PartitionKey: tb.PartitionKey, SortKey: tb.SortKey} + cfg := dbdriver.TableConfig{Name: tb.Name, PartitionKey: tb.PartitionKey, SortKey: tb.SortKey, GSIs: tb.GSIs} if err := d.CreateTable(ctx, cfg); err != nil { return fmt.Errorf("restore table %q: %w", tb.Name, err) } @@ -412,7 +418,8 @@ func restoreInstances(ctx context.Context, d computedriver.Compute, instances [] // WriteFile writes the snapshot as indented JSON, creating parent directories. func (s Snapshot) WriteFile(path string) error { - if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, dirPerm); err != nil { return err } @@ -421,7 +428,37 @@ func (s Snapshot) WriteFile(path string) error { return err } - return os.WriteFile(path, b, filePerm) + // Write to a temp file in the same dir, then rename onto the target. Rename + // is atomic on the same filesystem, so an interrupted write (disk-full, OOM, + // SIGKILL) leaves the previous snapshot — or none — but never a truncated + // file that would fail the next start. + tmp, err := os.CreateTemp(dir, ".snapshot-*.tmp") + if err != nil { + return err + } + + tmpName := tmp.Name() + + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + + return err + } + + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + + return err + } + + return nil } // ReadFile loads a snapshot from disk, rejecting an unknown schema version. diff --git a/persist/persist_test.go b/persist/persist_test.go index e84cf8aa..2cdf3017 100644 --- a/persist/persist_test.go +++ b/persist/persist_test.go @@ -146,6 +146,39 @@ func TestRestoreEmptyIsNoError(t *testing.T) { } } +// TestExportRestorePreservesGSIs covers L2: a table's secondary indexes must +// survive snapshot/restore, or a Query against a GSI breaks after a restart. +func TestExportRestorePreservesGSIs(t *testing.T) { + ctx := context.Background() + + src := cloudemu.NewAWS() + if err := src.DynamoDB.CreateTable(ctx, dbdriver.TableConfig{ + Name: "orders", + PartitionKey: "id", + GSIs: []dbdriver.GSIConfig{{Name: "by-customer", PartitionKey: "customerId"}}, + }); err != nil { + t.Fatalf("create table with GSI: %v", err) + } + + ps, err := persist.Export(ctx, seed.Target{Database: src.DynamoDB}, persist.Options{}) + if err != nil { + t.Fatalf("export: %v", err) + } + + dst := cloudemu.NewAWS() + if err := persist.Restore(ctx, seed.Target{Database: dst.DynamoDB}, &ps); err != nil { + t.Fatalf("restore: %v", err) + } + + idxs, err := dst.DynamoDB.ListIndexes(ctx, "orders") + if err != nil { + t.Fatalf("list indexes: %v", err) + } + if len(idxs) != 1 || idxs[0].Name != "by-customer" || idxs[0].PartitionKey != "customerId" { + t.Fatalf("GSI not restored: %+v", idxs) + } +} + // TestSnapshotFileRoundTrip covers the on-disk layer: WriteFile then ReadFile // preserves content, and an unknown schema version is rejected rather than // silently mis-restored.