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
96 changes: 96 additions & 0 deletions cmd/cloudemu/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"context"
"os"
"path/filepath"
"testing"

cloudemu "github.com/stackshy/cloudemu/v2"
"github.com/stackshy/cloudemu/v2/seed"
)

func TestApplyInitDirAppliesFixtures(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()

// Files apply in lexical order; content is provider-agnostic seed fixtures.
if err := os.WriteFile(filepath.Join(dir, "01-buckets.json"), []byte(`{"buckets":[{"name":"b"}]}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "02-tables.json"), []byte(`{"tables":[{"name":"t","partitionKey":"id"}]}`), 0o600); err != nil {
t.Fatal(err)
}
// A non-json file must be ignored.
if err := os.WriteFile(filepath.Join(dir, "README.txt"), []byte("ignore me"), 0o600); err != nil {
t.Fatal(err)
}

aws := cloudemu.NewAWS()
targets := map[string]seed.Target{"aws": {Storage: aws.S3, Database: aws.DynamoDB}}
if err := applyInitDir(ctx, dir, targets); err != nil {
t.Fatalf("applyInitDir: %v", err)
}

buckets, err := aws.S3.ListBuckets(ctx)
if err != nil || len(buckets) != 1 || buckets[0].Name != "b" {
t.Fatalf("bucket not created from init dir: %v %v", buckets, err)
}

tables, err := aws.DynamoDB.ListTables(ctx)
if err != nil || len(tables) != 1 || tables[0] != "t" {
t.Fatalf("table not created from init dir: %v %v", tables, err)
}
}

func TestApplyInitDirMissingIsNoOp(t *testing.T) {
aws := cloudemu.NewAWS()
targets := map[string]seed.Target{"aws": {Storage: aws.S3}}
if err := applyInitDir(context.Background(), filepath.Join(t.TempDir(), "absent"), targets); err != nil {
t.Fatalf("applyInitDir(missing) = %v, want nil", err)
}
}

func TestApplyInitDirParseErrorFailsBoot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("{ not valid"), 0o600); err != nil {
t.Fatal(err)
}

aws := cloudemu.NewAWS()
targets := map[string]seed.Target{"aws": {Storage: aws.S3}}
if err := applyInitDir(context.Background(), dir, targets); err == nil {
t.Fatal("applyInitDir(bad json) = nil, want parse error")
}
}

func TestApplyInitDirDuplicateWarnsNotFails(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()

// A fixture whose FIRST resource (bucket "dup") already exists, followed by a
// NEW resource (table "fresh"). The collision must not truncate the rest of
// the fixture — "fresh" must still be created.
fixture := `{"buckets":[{"name":"dup"}],"tables":[{"name":"fresh","partitionKey":"id"}]}`
if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte(fixture), 0o600); err != nil {
t.Fatal(err)
}

aws := cloudemu.NewAWS()
targets := map[string]seed.Target{"aws": {Storage: aws.S3, Database: aws.DynamoDB}}
// Pre-create the bucket so the init apply hits AlreadyExists on the first item.
if err := aws.S3.CreateBucket(ctx, "dup"); err != nil {
t.Fatal(err)
}

// The collision must warn-and-continue, not fail boot.
if err := applyInitDir(ctx, dir, targets); err != nil {
t.Fatalf("applyInitDir(duplicate) = %v, want nil (warn+continue)", err)
}

// The resource AFTER the collision must still have been created.
tables, err := aws.DynamoDB.ListTables(ctx)
if err != nil || len(tables) != 1 || tables[0] != "fresh" {
t.Fatalf("post-collision table not created: %v %v", tables, err)
}
}
16 changes: 16 additions & 0 deletions cmd/cloudemu/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const (
logFileName = "cloudemu.log"
endpointsFileName = "endpoints.json"
persistFileName = "snapshot.json"
initDirName = "init.d"

startupTimeout = 15 * time.Second
stopTimeout = 12 * time.Second
Expand Down Expand Up @@ -71,6 +72,13 @@ 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) }

// isDir reports whether path exists and is a directory.
func isDir(path string) bool {
fi, err := os.Stat(path)

return err == nil && fi.IsDir()
}

// hasFlag reports whether args contains --name / -name (bare or =value form).
func hasFlag(args []string, name string) bool {
for _, a := range args {
Expand Down Expand Up @@ -398,6 +406,14 @@ func runStart(args []string) error {
rest = append(rest, "--state-file", persistPath(dir))
}

// Auto-load a drop-in init.d under the run dir (docker-entrypoint.d style)
// unless the user pointed --init-dir elsewhere.
if !hasFlag(rest, "init-dir") {
if d := filepath.Join(dir, initDirName); isDir(d) {
rest = append(rest, "--init-dir", d)
}
}

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)
Expand Down
71 changes: 71 additions & 0 deletions cmd/cloudemu/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
Expand Down Expand Up @@ -58,6 +60,7 @@
persist bool
stateFile string
persistMetaOnly bool
initDir string
}

// stringList is a repeatable string flag (e.g. --tls-host a --tls-host b).
Expand Down Expand Up @@ -93,6 +96,7 @@
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.StringVar(&c.initDir, "init-dir", "", "apply every *.json seed fixture in this directory on startup")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: cloudemu serve [flags]\n\nStart the standalone emulator. Flags:\n")
fs.PrintDefaults()
Expand All @@ -101,7 +105,7 @@
return err
}
if (c.tlsCert == "") != (c.tlsKey == "") {
return errors.New("--tls-cert and --tls-key must be given together")

Check failure on line 108 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 @@ -223,6 +227,14 @@
}
}

// Apply init fixtures on top of the built (and possibly restored) providers,
// before serving, so the first request already sees the boot state.
if c.initDir != "" {
if err := applyInitDir(context.Background(), c.initDir, targets); err != nil {
return fmt.Errorf("apply init dir: %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.
Expand Down Expand Up @@ -367,7 +379,7 @@

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

Check failure on line 382 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 @@ -446,6 +458,65 @@
return persist.RestoreAll(ctx, &snap, targets)
}

// applyInitDir applies every *.json fixture in dir (lexical order) to every
// running provider on boot, bringing the emulator up to a known state. A
// missing dir is a no-op. A parse error fails startup (clear misconfiguration);
// an apply error only warns and continues, so a fixture that collides with
// already-restored state can't wedge the boot.
func applyInitDir(ctx context.Context, dir string, targets map[string]seed.Target) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}

if err != nil {
return err
}

names := make([]string, 0, len(entries))

for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
names = append(names, e.Name())
}
}

sort.Strings(names)

for _, name := range names {
if err := applyInitFile(ctx, filepath.Join(dir, name), name, targets); err != nil {
return err
}
}

return nil
}

// applyInitFile loads one fixture file and applies it to every provider. A load
// (parse) error is returned; per-provider apply errors are warned and skipped.
func applyInitFile(ctx context.Context, path, name string, targets map[string]seed.Target) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}

f, err := seed.Load(data)
if err != nil {
return fmt.Errorf("init fixture %s: %w", name, err)
}

for prov, t := range targets {
// IgnoreExisting so a resource that already exists (from restored state or
// an earlier init file) is skipped rather than aborting the rest of the
// fixture; other errors still warn.
if err := seed.Apply(ctx, f, t, seed.IgnoreExisting()); err != nil {
fmt.Fprintf(os.Stderr, "warning: init %s on %s: %v\n", name, prov, 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 {
Expand Down Expand Up @@ -484,7 +555,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 558 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 @@ -492,7 +563,7 @@
}
}
if len(out) == 0 {
return nil, errors.New("no providers selected")

Check failure on line 566 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
}
24 changes: 24 additions & 0 deletions docs/standalone-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,30 @@ discarded. If a restore fails partway the running state is already cleared —
fine for a local emulator, but don't point `load` at a server whose current
state you haven't snapshotted.

### Init hooks (auto-seed on boot)

Drop `*.json` seed fixtures in an init directory and they're applied on every
startup, so the emulator comes up in a known state without manual seeding:

```sh
mkdir -p ~/.cloudemu/init.d
echo '{"buckets":[{"name":"app-data"}],"tables":[{"name":"users","partitionKey":"id"}]}' \
> ~/.cloudemu/init.d/01-baseline.json
cloudemu start # applies init.d automatically
```

`start` auto-loads `<run-dir>/init.d` when it exists (point `--home` elsewhere to
change the run dir). For the foreground server, pass the directory explicitly:
`cloudemu serve --init-dir ./fixtures`.

Files are applied in lexical order (`01-…`, `02-…`) to **every** running provider
— the fixtures are provider-agnostic, so one file seeds S3, Blob, and GCS alike.
A malformed fixture fails startup; an apply error (e.g. a resource that already
exists from restored persistence) logs a warning and boot continues. Fixtures use
the same schema as [`/_cloudemu/seed`](#resetting-state-between-tests-_cloudemu)
(buckets, tables, secrets, instances). Running setup **scripts** on boot is a
planned follow-up.

## Ports

| Provider | Default | Protocol | Notes |
Expand Down
Loading
Loading