| index b4e684992..c8e3614a6 100644 |
| --- a/internal/host/e2e_bridge.go |
| +++ b/internal/host/e2e_bridge.go |
| @@ -21,21 +21,25 @@ import ( |
| "crypto/ecdh" |
| "crypto/ed25519" |
| "database/sql" |
| + "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "net" |
| "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -44,6 +48,7 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| |
| // offHost is the one instance every offline world moves. |
| @@ -93,6 +98,21 @@ type offBox struct { |
| w *movesWorker |
| nested bool |
| last []byte |
| + // The bridge's identity hooks (E2E): the cloud instance id the box |
| + // reports, read on every use as moveCfg.InstanceIDOf, and the stage |
| + // path's answer. Both unset in this package's own worlds, which then |
| + // arm exactly as before: an empty id, and armOffline's stagePathActive. |
| + instanceID atomic.Pointer[string] |
| + stagePath func() *bool |
| +} |
| + |
| +// instanceIDOf is moveCfg.InstanceIDOf for the box: "" until the bridge |
| +// names an instance id. |
| +func (b *offBox) instanceIDOf() string { |
| + if id := b.instanceID.Load(); id != nil { |
| + return *id |
| + } |
| + return "" |
| } |
| |
| func (wd *offWorld) box(name, region string, env map[string]string) *offBox { |
| @@ -122,7 +142,8 @@ func (b *offBox) getenv(k string) string { |
| // worker over the same registry and data directory. |
| func (b *offBox) restart() { |
| b.svc = activator.New(activator.Config{Bucket: "data", Idle: time.Hour}, &fakeWakeBackend{}, b.reg) |
| - cfg := moveCfg{Judge: b.wd.judge, Region: b.region, Box: b.name, DataDir: b.dd, OptDir: b.opt, Quiesced: b.svc.Quiesced, Route: b.reg.Get, Now: b.wd.clock} |
| + cfg := moveCfg{Judge: b.wd.judge, Region: b.region, Box: b.name, DataDir: b.dd, OptDir: b.opt, Quiesced: b.svc.Quiesced, Route: b.reg.Get, Now: b.wd.clock, |
| + InstanceIDOf: b.instanceIDOf, StagePathArmed: b.stagePath} |
| armOffline(&cfg, b.dd, b.reg, b.svc, nil, hostwrite.Config{}, b.getenv) |
| // The stage-requested marker is root's business, and its throttled |
| // timer would outlive the test's directories. |
| @@ -135,15 +156,21 @@ func (b *offBox) restart() { |
| |
| func (b *offBox) reconcile() { |
| b.wd.t.Helper() |
| + if err := b.reconcileErr(context.Background()); err != nil { |
| + b.wd.t.Fatal(err) |
| + } |
| +} |
| + |
| +// reconcileErr is the box's reconcile pass (reconcileSerializedWith over |
| +// its own worker), returning the pass's error. |
| +func (b *offBox) reconcileErr(ctx context.Context) error { |
| cfg := provision.ReconcileConfig{Region: b.region, DataDir: b.dd, Judge: b.wd.judge} |
| if b.nested { |
| cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil } |
| } else { |
| cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeFlat, nil } |
| } |
| - if err := reconcileSerializedWith(context.Background(), b.wd.s, b.reg, cfg, b.w); err != nil { |
| - b.wd.t.Fatal(err) |
| - } |
| + return reconcileSerializedWith(ctx, b.wd.s, b.reg, cfg, b.w) |
| } |
| |
| func (b *offBox) edges() { |
| @@ -388,6 +415,9 @@ type landWorld struct { |
| opts landOpts |
| // The source's files directory, env file and config file. |
| srcFiles, srcEnv, srcConfig string |
| + // rearm, set by newTwoBoxWorld, is an agent restart of a box that arms |
| + // it exactly as the world first armed it. |
| + rearm map[*offBox]func() |
| } |
| |
| // arm wires the target box as armMovesPass would, over its own data bucket. |
| @@ -395,6 +425,7 @@ func (lw *landWorld) arm(b *offBox) { |
| bucket, region := b.getenv("CARLOS_HIBERNATE_BUCKET"), b.getenv("CARLOS_HIBERNATE_REGION") |
| b.svc = activator.New(activator.Config{Bucket: bucket, Idle: time.Hour}, &fakeWakeBackend{}, b.reg) |
| cfg := moveCfg{Judge: b.wd.judge, Region: b.region, Box: b.name, DataDir: b.dd, OptDir: b.opt, Quiesced: b.svc.Quiesced, Route: b.reg.Get, Now: b.wd.clock, |
| + InstanceIDOf: b.instanceIDOf, StagePathArmed: b.stagePath, |
| OwnBucket: bucket, OwnBucketRegion: region, OwnPrefix: sealPrefix, |
| Mirror: func(ctx context.Context, dst string) (lease.MirrorStore, error) { |
| return lease.NewS3Mirror(ctx, region, dst) |
| @@ -658,3 +689,233 @@ func mustJSON(t testing.TB, v any) []byte { |
| } |
| return b |
| } |
| + |
| +// --- the two-box world's construction ---------------------------------------- |
| + |
| +// e2eRows is how many rows the source's database holds once the world is |
| +// built: 0..24 replicated in two sessions, 25..29 a tail the stop never |
| +// checkpointed. |
| +const e2eRows = 30 |
| + |
| +// newE2EEnv sets the process environment both boxes share and serves the |
| +// data buckets data-ie and data-uk over the S3 wire protocol on loopback, |
| +// returning the buckets' model. |
| +func newE2EEnv(t testing.TB) *leasetest.VersionedBucket { |
| + t.Helper() |
| + t.Setenv("CARLOS_HOST_CLAIM", "") |
| + // The test process's own values of the agent variables, so each box's |
| + // swap is visibly its own and the process value is restored after. |
| + for k := range e2eIEAgentEnv { |
| + t.Setenv(k, "test-process") |
| + } |
| + fake := leasetest.NewVersionedBucket() |
| + fake.AddBucket(sealIE) |
| + fake.AddBucket(sealUK) |
| + h, _ := leasetest.NewS3Server(fake) |
| + s3srv := httptest.NewServer(h) // 127.0.0.1 only |
| + t.Cleanup(s3srv.Close) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "absent")) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "absent")) |
| + t.Setenv("CARLOS_S3_ENDPOINT", s3srv.URL) |
| + return fake |
| +} |
| + |
| +// e2eKeys are the deployment's keys: the secrets key (each box holds the |
| +// private half, the console seals to the public half) and the release key |
| +// (the console signs, the target box verifies). |
| +type e2eKeys struct { |
| + keyB64 string |
| + priv *ecdh.PrivateKey |
| + relPriv ed25519.PrivateKey |
| + relPub ed25519.PublicKey |
| + pubB64 string // relPub as CARLOS_RELEASE_PUBKEY carries it |
| +} |
| + |
| +func newE2EKeys(t testing.TB) e2eKeys { |
| + t.Helper() |
| + var k e2eKeys |
| + var err error |
| + if k.keyB64, _, err = appconfig.GenerateKey(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if k.priv, err = appconfig.ParsePrivateKey(k.keyB64, "test key"); err != nil { |
| + t.Fatal(err) |
| + } |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if k.relPriv, err = release.ParseSigningKey(seed, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if k.relPub, err = release.ParsePublicKey(pubB64, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + k.pubB64 = pubB64 |
| + return k |
| +} |
| + |
| +// secretsPub is the secrets key's public half as console.Config.SecretsPub |
| +// takes it. |
| +func (k e2eKeys) secretsPub() string { |
| + return base64.StdEncoding.EncodeToString(k.priv.PublicKey().Bytes()) |
| +} |
| + |
| +// e2eAccount is the account that owns app: its sqid, its console id and |
| +// name, as its manifest in the deployment bucket carries them. |
| +type e2eAccount struct { |
| + sqid string |
| + id int64 |
| + name string |
| +} |
| + |
| +// newTwoBoxWorld is offHost at home on ie-box (data-ie) with uk-box |
| +// (data-uk) in uk, over the deployment store s: the account's manifest |
| +// (written only when absent), app's manifest and the instance's record at |
| +// home as opts says; both boxes armed as armMovesPass arms them, the |
| +// source's production build-state carry and each box's launch composer |
| +// under its own agent environment; two shipped versions, the first |
| +// promoted, and the app's config bundle; and the source at rest, as a box |
| +// that has served it. prepare, when set, runs once the source is at rest |
| +// and before both boxes publish their edges files and status snapshots. |
| +func newTwoBoxWorld(t testing.TB, s release.Store, fake *leasetest.VersionedBucket, keys e2eKeys, acct e2eAccount, now time.Time, opts landOpts, prepare func(*landWorld)) *landWorld { |
| + t.Helper() |
| + ctx := context.Background() |
| + wd := &offWorld{t: t, s: s, acct: acct.sqid, now: now} |
| + if opts.judged || opts.lifetime { |
| + wd.judge = provision.NewLifetimeJudge() |
| + } |
| + if _, _, err := s.Get(ctx, domains.AccountManifestKey(wd.acct)); errors.Is(err, os.ErrNotExist) { |
| + manifest, _ := json.Marshal(map[string]any{"id": acct.id, "sqid": wd.acct, "name": acct.name, "created_at": now}) |
| + if _, err := s.Put(ctx, domains.AccountManifestKey(wd.acct), manifest, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := provision.PutManifest(ctx, s, wd.acct, "app", provision.Manifest{Schema: 1, App: "app", Domain: "example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch { |
| + case opts.lifetime: |
| + wd.createLifetime(offHost, 1) |
| + case opts.incarnation > 0: |
| + wd.createAt(offHost, opts.incarnation) |
| + default: |
| + wd.create(offHost) |
| + } |
| + |
| + // The boxes. ie-box is the home's one box; uk-box is uk's. |
| + sw := &sealWorld{offWorld: wd, fake: fake, keyB64: keys.keyB64, priv: keys.priv} |
| + sw.ie = sw.sourceBox("ie-box") |
| + sw.ie.env["CARLOS_HIBERNATE_REGION"] = sealIERegion |
| + sw.uk = sw.box("uk-box", "uk", map[string]string{"CARLOS_HIBERNATE_BUCKET": sealUK, "CARLOS_SECRETS_KEY": keys.keyB64, |
| + "CARLOS_HIBERNATE_REGION": sealUKRegion, "CARLOS_RELEASE_PUBKEY": keys.pubB64}) |
| + lw := &landWorld{t: t, sw: sw, wd: wd, s3: &landS3{f: fake}, ie: sw.ie, uk: sw.uk, opts: opts, relPriv: keys.relPriv} |
| + shipVersion(t, s, wd.acct, "app", sealVersion) |
| + shipVersion(t, s, wd.acct, "app", "v2") |
| + lw.promote(sealVersion) |
| + lw.bundle(landStore) |
| + lw.ukEB = &activator.ExecBackend{DataDir: lw.uk.dd, OptDir: lw.uk.opt, Region: "uk"} |
| + lw.arm(lw.uk) |
| + |
| + // The source box's production build-state carry, and each box's launch |
| + // composer under its own agent environment. rearm is an agent restart |
| + // that arms the box exactly so again. |
| + ie := lw.ie |
| + ieAgent := func() { |
| + bs, bsErr := newBuildStates(&activator.Config{Bucket: sealIE, Region: sealIERegion, Prefix: sealPrefix}, ie.reg, ie.opt) |
| + ie.w.cfg.CarryRequest = moveCarry(bs, bsErr) |
| + ie.w.cfg.LaunchLayers = withAgentEnv(e2eIEAgentEnv, ie.w.cfg.LaunchLayers) |
| + } |
| + ukAgent := func() { lw.uk.w.cfg.LaunchLayers = withAgentEnv(e2eUKAgentEnv, lw.ukEB.LaunchLayers) } |
| + ieAgent() |
| + ukAgent() |
| + lw.rearm = map[*offBox]func(){ |
| + ie: func() { ie.restart(); sw.arm(ie); ieAgent() }, |
| + lw.uk: func() { lw.arm(lw.uk); ukAgent() }, |
| + } |
| + |
| + // The source at rest, as a box that has served it: rows replicated in |
| + // two sessions, then a tail the stop never checkpointed. |
| + sw.instance(ie) // rows 0..19, replicated |
| + db := sw.dbPath(ie) |
| + sw.writeRows(ie, db, 20, 25, true) |
| + sw.writeRows(ie, db, 25, e2eRows, false) |
| + lw.srcFiles, lw.srcEnv, lw.srcConfig = filepath.Join(ie.dd, offHost), filepath.Join(ie.dd, offHost+".env"), filepath.Join(ie.dd, offHost+".config.env") |
| + if prepare != nil { |
| + prepare(lw) |
| + } |
| + |
| + // Both boxes published as their loops publish them, and their status |
| + // snapshots. |
| + lw.tickAll() |
| + for _, b := range []*offBox{ie, lw.uk} { |
| + if err := status.WriteBox(ctx, s, status.Box{Label: b.name, GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return lw |
| +} |
| + |
| +// publish is one pass of edgesFileLoop on b: the identity it builds at arm |
| +// from the box's own environment, the capability from the box's armed |
| +// worker, through the production writer, with the box's lifetimes switch |
| +// read as runAgent reads it (lifetimes := boxJudge != nil): a box judging |
| +// lifetimes publishes each row's (incarnation, serving_seq). |
| +func (lw *landWorld) publish(b *offBox) { |
| + lw.t.Helper() |
| + if err := lw.publishErr(context.Background(), b); err != nil { |
| + lw.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (lw *landWorld) publishErr(ctx context.Context, b *offBox) error { |
| + sharedPool, inbound := edgesFileEligibility(b.getenv) |
| + aws := sealIERegion |
| + if b.region == "uk" { |
| + aws = sealUKRegion |
| + } |
| + ident := edgesFile{ |
| + Box: b.name, BoxID: b.name, Region: aws, Placement: b.region, |
| + DataBucket: strings.TrimSpace(b.getenv("CARLOS_HIBERNATE_BUCKET")), |
| + DataBucketRegion: strings.TrimSpace(b.getenv("CARLOS_HIBERNATE_REGION")), |
| + HibernationConfigured: strings.TrimSpace(b.getenv("CARLOS_HIBERNATE_BUCKET")) != "", |
| + ServesSharedPool: sharedPool, |
| + AcceptsInbound: inbound, |
| + SecretsKeyFP: edgesFileSecretsFP(b.getenv), |
| + Capabilities: withOfflineCapability(edgesFileCapabilities(b.getenv), b.w.cfg.OfflineCapable && b.w.cfg.Offline != nil), |
| + } |
| + return edgesFileWriteClaims(ctx, lw.wd.s, b.reg, ident, "192.0.2.1", &b.last, false, nil, b.w.cfg.Judge != nil) |
| +} |
| + |
| +// tick is the agent's order on one box: reconcile, the edges file, the |
| +// moves pass. The boxes' clock is the wall clock, which the doors judge |
| +// report freshness by. |
| +func (lw *landWorld) tick(b *offBox) { |
| + lw.t.Helper() |
| + lw.tickAt(b, time.Now()) |
| +} |
| + |
| +// tickAt is tick with the boxes' clock reading at. |
| +func (lw *landWorld) tickAt(b *offBox, at time.Time) { |
| + lw.t.Helper() |
| + lw.setBoxClock(at) |
| + b.reconcile() |
| + lw.publish(b) |
| + b.pass() |
| +} |
| + |
| +func (lw *landWorld) setBoxClock(at time.Time) { |
| + lw.wd.mu.Lock() |
| + lw.wd.now = at |
| + lw.wd.mu.Unlock() |
| +} |
| + |
| +func (lw *landWorld) tickAll() { |
| + lw.t.Helper() |
| + lw.tick(lw.ie) |
| + lw.tick(lw.uk) |
| +} |