package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "testing" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" ) // Version skew of the env report and pin (home part 2, Task 2b fix round // 3). A v1 report carries no instance layer names, so it cannot say // whether a box variable was the instance's or the box's: an older // producer and a newer one with no instance-set box variables look the // same. A v2 participant therefore refuses a v1 report at T3 and a v1 pin // at the probe, and a v2 pin is in a form no v1 participant's digest can // equal. The older participant is emulated here by its own rule: f4f51ba6's // digest, every variable but the agent-owned and box variables, in the // "sha256:" form. // v1EnvDigest is a v1 participant's digest of a report's variables. func v1EnvDigest(r moves.EnvReport) string { var names []string for k := range r.Vars { if !moves.AgentOwnedVars[k] && !moves.BoxVars[k] { names = append(names, k) } } sort.Strings(names) h := sha256.New() for _, k := range names { fmt.Fprintf(h, "%s=%s\n", k, r.Vars[k]) } return "sha256:" + hex.EncodeToString(h.Sum(nil)) } // asV1Source rewrites the source's published report as a v1 source writes // it: version 1, no instance layer names, the v1 digest. func asV1Source(t *testing.T, h *phase1Harness) { t.Helper() ctx := context.Background() rec := h.read(t, p1Host) r := h.sourceReport(t) r.Env.V, r.Env.Instance = 1, nil r.Env.Digest = v1EnvDigest(r.Env) body, err := json.Marshal(r) if err != nil { t.Fatal(err) } if err := moves.PutReplace(ctx, h.s, moves.SourceReportKey(moves.ReportPrefix(rec.Account, rec.App, rec.Host, rec.Move.ID)), body); err != nil { t.Fatal(err) } } const t3OldReportWant = "the source reported its launch environment in an older format" // TestT3RefusesAnOlderSourceReport: a v1 source with a v2 target. The // target refuses the source's v1 report at T3, before comparing, named to // named and named to home. func TestT3RefusesAnOlderSourceReport(t *testing.T) { t.Parallel() for name, opts := range map[string][]p1Option{ "named to named": {realLaunchEnv}, "named to home": {intoHome, realLaunchEnv}, } { t.Run(name, func(t *testing.T) { h := newPhase1Harness(t, opts...) h.start(t, "manual") h.pass(t) asV1Source(t, h) h.targetPass(t, h.targetCfg()) r := h.targetReport(t) if t3 := r.Checks[moves.CheckEnv]; t3.OK || t3.Reason != t3OldReportWant { t.Fatalf("T3 on a v1 source report: %+v", t3) } if r.Inputs.EnvReport != "" { t.Fatalf("a refused T3 bound the source's env report: %q", r.Inputs.EnvReport) } }) } } // TestProbeRefusesAnOlderPin: a v1 source with a v2 target, and the // instance's HOSTNAME removed after the pin. The source's .env set // HOSTNAME=a, which a v1 pin leaves out; after the pin the target's .env // loses it and the target inherits HOSTNAME=b from its box, which the v2 // rule also leaves out. The two digests would agree on a changed env; the // v2 target refuses the v1 pin instead, named to named and named to home. func TestProbeRefusesAnOlderPin(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, append([]p1Option{sourceEnvFile("APP_MODE=prod\nHOSTNAME=a\n")}, opts...)...) p := h.productionProbe(t) v1 := v1EnvDigest(h.sourceReport(t).Env) if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { rec.Move.Inputs.EnvReport = v1 return nil }); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte("APP_MODE=prod\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("HOSTNAME", "b") h.land(t) if len(p.admissions) != 0 { t.Fatalf("a v1 pin admitted a changed env: %+v", p.admissions) } if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || m.State == provision.MoveCommitted { t.Fatalf("the landing on a v1 pin: state %s reason %q", m.State, m.Reason) } }) } } // TestOlderTargetCannotMatchANewerPin: a v2 source with a v1 target. A v1 // target judges its own launch by the v1 digest; whatever its env, that // digest never equals a v2 pin, so the older target refuses, fail closed. // Both directions of a box variable changing after the pin: // - the source's HOSTNAME is inherited (left out of the pin) and the // target's .env adds HOSTNAME=b; // - the source's .env sets HOSTNAME=a (pinned) and the target's .env // loses it, inheriting HOSTNAME=b. func TestOlderTargetCannotMatchANewerPin(t *testing.T) { for name, c := range map[string]struct { srcFile, tgtFile string }{ "the target adds an instance HOSTNAME": {"APP_MODE=prod\n", "APP_MODE=prod\nHOSTNAME=b\n"}, "the target loses the instance HOSTNAME": {"APP_MODE=prod\nHOSTNAME=a\n", "APP_MODE=prod\n"}, } { t.Run(name, func(t *testing.T) { t.Setenv("HOSTNAME", "a") h := newCloseHarness(t, sourceEnvFile(c.srcFile), intoHome, realLaunchEnv) pin := h.read(t, p1Host).Move.Inputs.EnvReport if !moves.CurrentEnvPin(pin) { t.Fatalf("the source's pin is not v2: %q", pin) } if err := os.WriteFile(filepath.Join(h.tdir, p1Host+".env"), []byte(c.tgtFile), 0o600); err != nil { t.Fatal(err) } t.Setenv("HOSTNAME", "b") env, _, err := (&activator.ExecBackend{DataDir: h.tdir, Region: h.dst}).LaunchEnvironment(h.route) if err != nil { t.Fatal(err) } key, err := moves.ReportKeyFromSecrets(h.cfg.SecretsKey) if err != nil { t.Fatal(err) } if got := v1EnvDigest(moves.ReportEnv(key, env)); got == pin { t.Fatalf("a v1 target's digest of a changed env matches the v2 pin %q", pin) } }) } }