package host import ( "errors" "os" "path/filepath" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" ) // The move's env pin (home part 2, Task 2b). The source pins the report // digest of the environment its ExecBackend would launch (sourceReport, // copied into the target's report as Inputs.EnvReport); the probe and the // committed wake on the target check the digest of the target's own // composed launch (claimMoveLaunchSource). Each box owns some variables of // that environment (agentOwnedVars: its region, its control socket, its // purge channel, the host row's name), so the two compositions differ in // those whatever the instance carries. Both sides hash the move-portable // env: the composition without the box-owned variables (moves.BoxOwned). // TestProbeEnvPinIsMovePortable: a landing whose source and target compose // their environments through the production composer, each with its own // region, passes the production probe and commits, named to named and // named to home. func TestProbeEnvPinIsMovePortable(t *testing.T) { for name, opts := range map[string][]p1Option{ "named to named": {realLaunchEnv}, "named to home": {intoHome, realLaunchEnv}, } { t.Run(name, func(t *testing.T) { h := newCloseHarness(t, opts...) p := h.productionProbe(t) h.land(t) if m := h.read(t, p1Host).Move; m.Reason != "" { t.Fatalf("the landing stopped for a rollback: %q", m.Reason) } if len(p.admissions) != 1 || p.admissions[0].Box != h.dstBox { t.Fatalf("the probe's admissions: %+v", p.admissions) } h.committedAndOpen(t) // The probe starts through root's launch check: the reserved // LaunchInputs is the hash of the launch root is asked to start, // an instance whose files set app variables included. if !slices.Contains(p.selected.Env, "APP_MODE=prod") { t.Fatalf("the launch carries no instance app variable: %v", p.selected.Env) } root, err := rootLaunchSpec(p.selected).Hash() if err != nil || p.attempt.LaunchInputs == "" || root != p.attempt.LaunchInputs { t.Fatalf("root's spec hash %s %v, the reserved LaunchInputs %s", root, err, p.attempt.LaunchInputs) } }) } } // TestProbeEnvPinRefusesAnInstanceChange: the pin still binds the // instance's own variables. An app variable that differs on the target // (its installed .env changed after the pin) refuses the probe, and the // landing stops for the configuration-changed rollback. func TestProbeEnvPinRefusesAnInstanceChange(t *testing.T) { for name, opts := range map[string][]p1Option{ "named to named": {realLaunchEnv}, "named to home": {intoHome, realLaunchEnv}, } { t.Run(name, func(t *testing.T) { h := newCloseHarness(t, opts...) p := h.productionProbe(t) if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=staging\n"), 0o600); err != nil { t.Fatal(err) } h.land(t) if len(p.admissions) != 0 { t.Fatalf("a changed instance env admitted: %+v", p.admissions) } if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted { t.Fatalf("the landing after a changed instance env: state %s reason %q", m.State, m.Reason) } }) } } // TestProbeEnvPinCoversAnInstanceBoxVariable (fix round 2): a box // variable such as HOSTNAME is the box's only while the box supplies it. // The agent never overwrites it, and childEnv lays the instance's files // over the inherited value, so one the instance's own .env sets is what // runs and is pinned like an app variable. Through the production // composition on both boxes: // - the target's .env gains HOSTNAME=b after the pin (the source's came // from its box): refused; // - the source's .env sets HOSTNAME=a and the target's still does after // the landing: the pin holds and the landing commits; // - the target's .env changes it to HOSTNAME=b after the pin: refused. func TestProbeEnvPinCoversAnInstanceBoxVariable(t *testing.T) { for name, opts := range map[string][]p1Option{ "named to named": {realLaunchEnv}, "named to home": {intoHome, realLaunchEnv}, } { t.Run(name, func(t *testing.T) { refused := func(t *testing.T, h *finalHarness, p *prodProbe) { t.Helper() h.land(t) if len(p.admissions) != 0 { t.Fatalf("a changed instance HOSTNAME admitted: %+v", p.admissions) } if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted { t.Fatalf("the landing after a changed instance HOSTNAME: state %s reason %q", m.State, m.Reason) } } t.Run("the target's env file adds HOSTNAME", func(t *testing.T) { h := newCloseHarness(t, opts...) p := h.productionProbe(t) if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\nHOSTNAME=b\n"), 0o600); err != nil { t.Fatal(err) } refused(t, h, p) }) withHostname := append([]p1Option{sourceEnvFile("APP_MODE=prod\nHOSTNAME=a\n")}, opts...) t.Run("the instance's HOSTNAME is carried and pinned", func(t *testing.T) { h := newCloseHarness(t, withHostname...) src := h.sourceReport(t).Env if len(src.Instance) != 1 || src.Instance[0] != "HOSTNAME" { t.Fatalf("the source's report does not name the instance's HOSTNAME: %v", src.Instance) } p := h.productionProbe(t) h.land(t) if len(p.admissions) != 1 { t.Fatalf("the probe's admissions: %+v (move %+v)", p.admissions, h.read(t, p1Host).Move) } h.committedAndOpen(t) root, err := rootLaunchSpec(p.selected).Hash() if err != nil || root != p.attempt.LaunchInputs || !slices.Contains(p.selected.Env, "HOSTNAME=a") { t.Fatalf("root's spec hash %s %v, the reserved LaunchInputs %s", root, err, p.attempt.LaunchInputs) } }) t.Run("a box-only HOSTNAME differs and is left out", func(t *testing.T) { t.Setenv("HOSTNAME", "source-box") h := newCloseHarness(t, opts...) if inst := h.sourceReport(t).Env.Instance; len(inst) != 0 { t.Fatalf("an inherited HOSTNAME is named the instance's: %v", inst) } p := h.productionProbe(t) t.Setenv("HOSTNAME", "target-box") h.land(t) if len(p.admissions) != 1 { t.Fatalf("a box-only HOSTNAME refused the probe: %+v (move %+v)", p.admissions, h.read(t, p1Host).Move) } h.committedAndOpen(t) root, err := rootLaunchSpec(p.selected).Hash() if err != nil || root != p.attempt.LaunchInputs || !slices.Contains(p.selected.Env, "HOSTNAME=target-box") { t.Fatalf("root's spec hash %s %v, the reserved LaunchInputs %s, env %v", root, err, p.attempt.LaunchInputs, p.selected.Env) } }) t.Run("the target's env file changes HOSTNAME", func(t *testing.T) { h := newCloseHarness(t, withHostname...) p := h.productionProbe(t) if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\nHOSTNAME=b\n"), 0o600); err != nil { t.Fatal(err) } refused(t, h, p) }) }) } } // TestRecoveryEnvCheckIsTheWholeLocalEnv (fix round 2): the recovery's // inputs are recorded on the recovering box just before the wake // (movesrecoveryprobe.go, recoveryInputs) and checked against that box's // launch, so the check keeps the whole composed env (localEnvDigest, every // variable but CARLOS_MOVE_PROBE). A box-owned variable that changed // between the record and the wake is refused, as is an instance variable. func TestRecoveryEnvCheckIsTheWholeLocalEnv(t *testing.T) { recorded := []string{"A=1", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost} for _, kind := range []string{rcClean, rcDirty, rcRevert} { t.Run(kind, func(t *testing.T) { f := newRCFixture(t, kind) f.inputs.EnvReport = localEnvDigest(poEnvKey, recorded) f.launch.Env = append(append([]string(nil), recorded...), "CARLOS_MOVE_PROBE=0") if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); err != nil { t.Fatalf("the recorded env, launched: %v", err) } for name, launched := range map[string][]string{ "a box-owned variable appears": append(append([]string(nil), recorded...), "CARLOS_PURGE=header"), "a box-owned variable changes": {"A=1", "CARLOS_REGION=uk", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost}, "a box variable appears": append(append([]string(nil), recorded...), "HOSTNAME=box"), "an instance variable changes": {"A=2", "CARLOS_REGION=ie", "CARLOS_CONTROL_SOCKET=/run/carlos/ie.sock.control", "CARLOS_HOST=" + poHost}, } { f.launch.Env = launched if _, err := f.owners.Select(t.Context(), f.route, f.read(), f.policy()); !errors.Is(err, hostbox.ErrChanged) || !strings.Contains(err.Error(), "launch environment") { t.Fatalf("%s: %v", name, err) } } }) } } // TestRecoveryAndCutInputsDigestTheirOwnEnv (fix round 2): on one box, over // the production composition, the cut's currentInputs pins the // move-portable digest the target's probe checks (envDigest with the // instance's layer names), and the recovery's recoveryInputs records the // whole local digest its own check uses (localEnvDigest). The two differ: // the box's own variables are in one and not the other. func TestRecoveryAndCutInputsDigestTheirOwnEnv(t *testing.T) { h := newCutHarness(t, realLaunchEnv) rec := h.start(t, "manual") ctx := t.Context() w := h.w() cur, err := w.currentInputs(ctx, h.s, rec) if err != nil { t.Fatal(err) } rin, err := w.recoveryInputs(ctx, h.s, rec) if err != nil { t.Fatal(err) } env, instance, err := h.cfg.LaunchEnv(p1Host) if err != nil { t.Fatal(err) } if env[moves.RegionVar] != "ie" || env["CARLOS_HOST"] != p1Host { t.Fatalf("the composition carries no box-owned variables: %v", env[moves.RegionVar]) } list := make([]string, 0, len(env)) for k, v := range env { list = append(list, k+"="+v) } var names []string for k := range instance { names = append(names, k) } key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey) if err != nil { t.Fatal(err) } if cur.EnvReport != envDigest(key, list, names...) { t.Fatal("currentInputs is not the probe's move-portable digest") } if rin.EnvReport != localEnvDigest(key, list) { t.Fatal("recoveryInputs is not the recovery check's local digest") } if cur.EnvReport == rin.EnvReport { t.Fatal("the recovery's digest left out the box's own variables") } if rin.Release != cur.Release || rin.OverlayETag != cur.OverlayETag || len(rin.BundleETags) != len(cur.BundleETags) { t.Fatalf("the recovery's other inputs differ: %+v %+v", rin, cur) } } // envDigest is a test's move-portable pin of a launch's K=V list // (CARLOS_MOVE_PROBE dropped), instance naming what the instance's own // layers supplied: what the production launch source's report carries // (claimMoveLaunchSource, moves.ReportLaunch). func envDigest(key []byte, env []string, instance ...string) string { supplied := map[string]bool{} for _, k := range instance { supplied[k] = true } return moves.PortableDigest(key, launchVars(env), supplied) }