Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/cloudemu/lifecycle_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ import "errors"
func runLifecycle(_ string, _ []string) error {
return errors.New("start/stop/status/logs/delete are only supported on Unix/macOS; run `cloudemu serve` instead")
}

// runSnapshot is unavailable off Unix for the same reason as the lifecycle
// commands: it operates on the background daemon's run directory.
func runSnapshot(_ []string) error {
return errors.New("snapshot is only supported on Unix/macOS")
}
17 changes: 12 additions & 5 deletions cmd/cloudemu/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Usage:
cloudemu status Show whether the emulator is running and its endpoints
cloudemu logs [-f] Print (or follow) the background emulator's log
cloudemu delete Stop the emulator and remove its run directory
cloudemu snapshot ... Save/load/list/delete named state snapshots
cloudemu serve [flags] Run the server in the foreground (see: cloudemu serve -h)
cloudemu version Print the version
cloudemu help Show this message
Expand All @@ -31,11 +32,12 @@ default; override with --home <dir>. Run "cloudemu serve -h" for serve flags.
// Lifecycle subcommand names. Defined here (not in the Unix-tagged
// lifecycle.go) so the dispatch compiles on every platform.
const (
cmdStart = "start"
cmdStop = "stop"
cmdStatus = "status"
cmdLogs = "logs"
cmdDelete = "delete"
cmdStart = "start"
cmdStop = "stop"
cmdStatus = "status"
cmdLogs = "logs"
cmdDelete = "delete"
cmdSnapshot = "snapshot"
)

// version is overridable at build time with
Expand All @@ -59,6 +61,11 @@ func main() {
fmt.Fprintln(os.Stderr, "cloudemu:", err)
os.Exit(1)
}
case cmdSnapshot:
if err := runSnapshot(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, "cloudemu:", err)
os.Exit(1)
}
case "version", "-v", "--version":
fmt.Println("cloudemu", version)
case "help", "-h", "--help":
Expand Down
79 changes: 52 additions & 27 deletions cmd/cloudemu/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"flag"
"fmt"
Expand Down Expand Up @@ -30,6 +31,10 @@
// errStateFileRequired is returned when --persist is set without --state-file.
var errStateFileRequired = errors.New("--persist requires --state-file")

// errUnsupportedSnapshot is returned when a posted snapshot has an unknown
// schema version.
var errUnsupportedSnapshot = errors.New("unsupported snapshot schema version")

// serveConfig holds the resolved serve flags.
type serveConfig struct {
providers string
Expand Down Expand Up @@ -96,7 +101,7 @@
return err
}
if (c.tlsCert == "") != (c.tlsKey == "") {
return errors.New("--tls-cert and --tls-key must be given together")

Check failure on line 104 in cmd/cloudemu/serve.go

View workflow job for this annotation

GitHub Actions / Lint

do not define dynamic errors, use wrapped static errors instead: "errors.New(\"--tls-cert and --tls-key must be given together\")" (err113)
}

if c.persist && c.stateFile == "" {
Expand Down Expand Up @@ -245,17 +250,58 @@
// could POST /_cloudemu/reset.
if c.admin && !isLoopbackHost(c.host) {
fmt.Fprintf(os.Stderr,
"warning: --admin control plane (POST /_cloudemu/reset wipes all state) is reachable on non-loopback host %q; pass --admin=false to disable it\n",
"warning: --admin control plane is reachable on non-loopback host %q — "+
"POST /_cloudemu/reset wipes all state, and GET /_cloudemu/snapshot dumps "+
"all emulated state (including secret values) to any caller; "+
"pass --admin=false to disable it\n",
c.host)
}

// snapshotFn/restoreFn back /_cloudemu/snapshot; both act on the whole
// emulator like reset. snapshot captures current state as JSON; restore
// rebuilds to empty then loads the posted state.
snapshotFn := func() ([]byte, error) {
rebuildMu.Lock()
cur := targets
rebuildMu.Unlock()

snap, err := persist.ExportAll(context.Background(), cur, persist.Options{IncludeAssets: true})
if err != nil {
return nil, err
}

return json.MarshalIndent(snap, "", " ")
}
restoreFn := func(body []byte) error {
var snap persist.Snapshot
if err := json.Unmarshal(body, &snap); err != nil {
return fmt.Errorf("parse snapshot: %w", err)
}

if snap.SchemaVersion != persist.SchemaVersion {
return fmt.Errorf("%w: got %d, want %d", errUnsupportedSnapshot, snap.SchemaVersion, persist.SchemaVersion)
}

// Destructive load (reset semantics): wipe to empty, then repopulate. If
// RestoreAll fails partway the running state is already gone — acceptable
// for a local emulator, but a future hardening is to restore into a
// staging build and swap it in only on success.
rebuild() // wipe to empty before loading

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L2 (Low) — destructive load with no rollback. rebuild() wipes all state, then RestoreAll repopulates. If RestoreAll errors partway (a driver returns an error mid-restore), the emulator is left wiped + partially restored with no recovery of the prior state — the POST returns 400 but the data's already gone. Low probability (restore into freshly-empty providers rarely fails) and consistent with the documented reset-semantics, but worth a doc note now and, later, a "restore into a staging build and swap on success" hardening so a failed load can't destroy the running state.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented. Added a code comment on the rebuild() line and a note in docs/standalone-server.md making the destructive semantics explicit: load wipes then repopulates, and a mid-RestoreAll failure leaves the running state cleared. Called out your suggested hardening — restore into a staging build and swap in only on success — as the follow-up. 0f5c5a9


rebuildMu.Lock()
cur := targets
rebuildMu.Unlock()

return persist.RestoreAll(context.Background(), &snap, cur)
}

// handlerFor fronts a backend with the /_cloudemu control plane. With the
// admin API off the backend serves directly, so control paths fall through
// to the wire handlers (whatever they return for an unrouted path). seedFn
// may be nil (e.g. the Kubernetes port), which disables the seed endpoint.
handlerFor := func(b *admin.Backend, seedFn func([]byte) (int, error)) http.Handler {
if c.admin {
return admin.NewControl(b, rebuild, seedFn)
return admin.NewControl(b, rebuild, seedFn, snapshotFn, restoreFn)
}
return b
}
Expand Down Expand Up @@ -321,7 +367,7 @@

errCh := make(chan error, len(servers))
for i, s := range servers {
s := s

Check failure on line 370 in cmd/cloudemu/serve.go

View workflow job for this annotation

GitHub Actions / Lint

The copy of the 'for' variable "s" can be deleted (Go 1.22+) (copyloopvar)
ln := listeners[i]
go func() {
var err error
Expand Down Expand Up @@ -397,36 +443,15 @@
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
return persist.RestoreAll(ctx, &snap, targets)
}

// 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
snap, err := persist.ExportAll(ctx, targets, persist.Options{IncludeAssets: includeAssets})
if err != nil {
return err
}

return snap.WriteFile(path)
Expand Down Expand Up @@ -459,7 +484,7 @@
continue
}
if p != "aws" && p != "azure" && p != "gcp" {
return nil, fmt.Errorf("unknown provider %q (want aws, azure, or gcp)", p)

Check failure on line 487 in cmd/cloudemu/serve.go

View workflow job for this annotation

GitHub Actions / Lint

do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"unknown provider %q (want aws, azure, or gcp)\", p)" (err113)
}
if !seen[p] {
seen[p] = true
Expand All @@ -467,7 +492,7 @@
}
}
if len(out) == 0 {
return nil, errors.New("no providers selected")

Check failure on line 495 in cmd/cloudemu/serve.go

View workflow job for this annotation

GitHub Actions / Lint

do not define dynamic errors, use wrapped static errors instead: "errors.New(\"no providers selected\")" (err113)
}
return out, nil
}
Loading
Loading