diff --git a/cmd/cloudemu/lifecycle.go b/cmd/cloudemu/lifecycle.go
index 48d846f1..6cde4763 100644
--- a/cmd/cloudemu/lifecycle.go
+++ b/cmd/cloudemu/lifecycle.go
@@ -22,6 +22,7 @@ const (
stateFileName = "state.json"
logFileName = "cloudemu.log"
endpointsFileName = "endpoints.json"
+ persistFileName = "snapshot.json"
startupTimeout = 15 * time.Second
stopTimeout = 12 * time.Second
@@ -68,6 +69,19 @@ 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) }
+
+// 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 +378,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,7 +602,7 @@ 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
}
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 81c083e5..ba305de0 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,73 @@ 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 err != nil {
+ 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 {
+ 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..81f02ffe 100644
--- a/docs/standalone-server.md
+++ b/docs/standalone-server.md
@@ -83,8 +83,45 @@ 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, 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
@@ -169,6 +206,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 +279,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..ecb8d1f4
--- /dev/null
+++ b/persist/persist.go
@@ -0,0 +1,481 @@
+// 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
+)
+
+// 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, its secondary indexes, and its items.
+type Table struct {
+ 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
+// 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,
+ GSIs: cfg.GSIs,
+ 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, GSIs: tb.GSIs}
+ 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 {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, dirPerm); err != nil {
+ return err
+ }
+
+ b, err := json.MarshalIndent(s, "", " ")
+ if err != nil {
+ return err
+ }
+
+ // 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.
+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..2cdf3017
--- /dev/null
+++ b/persist/persist_test.go
@@ -0,0 +1,214 @@
+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)
+ }
+}
+
+// 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.
+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")
+ }
+}