| 1 | package activator |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/instancectl" |
| 14 | "github.com/carlosframework/platform/internal/instancekey" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | ) |
| 18 | |
| 19 | // envlessProc is a generation handle that kept no launch environment. |
| 20 | type envlessProc struct{ Proc } |
| 21 | |
| 22 | // A config swap is queued: config sync has delivered a new revision, and |
| 23 | // the serving generation still runs with the one it launched with. A run |
| 24 | // gets the serving generation's environment, not the delivered files |
| 25 | // (merge ruling M3). |
| 26 | func TestARunGetsTheServingGenerationsEnvironment(t *testing.T) { |
| 27 | ctl := &fakeControl{inv: "inv1", cmdInv: "inv2"} |
| 28 | unit := &fakeUnit{} |
| 29 | b, rt := nestedBackend(t, ctl, unit) |
| 30 | b.layoutFn = func(registry.Route) (Layout, string) { return LayoutNested, "" } |
| 31 | paths := b.pathsFor(rt) |
| 32 | if err := os.MkdirAll(filepath.Dir(paths.configEnv), 0o700); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | deliver := func(rev string) { |
| 36 | t.Helper() |
| 37 | if err := os.WriteFile(paths.configEnv, []byte("APP_KEY="+rev+"\n"), 0o600); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | } |
| 41 | deliver("old") |
| 42 | var stopServing func() |
| 43 | ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) } |
| 44 | defer func() { |
| 45 | if stopServing != nil { |
| 46 | stopServing() |
| 47 | } |
| 48 | }() |
| 49 | unit.set("active", "inv1", true, true) |
| 50 | p, _, err := b.Start(context.Background(), rt) |
| 51 | if err != nil { |
| 52 | t.Fatalf("Start: %v", err) |
| 53 | } |
| 54 | lp, ok := p.(launchEnvProc) |
| 55 | if !ok || lp.LaunchEnv()["APP_KEY"] != "old" { |
| 56 | t.Fatalf("the generation's handle kept %v, want its launch environment", p) |
| 57 | } |
| 58 | deliver("new") // delivered, not activated: the config swap is queued |
| 59 | |
| 60 | dir := filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), CmdRunDirName, "run_a") |
| 61 | spec := CmdSpec{RunID: "run_a", RunDir: dir, Stdout: &syncBuffer{}, Stderr: &syncBuffer{}, genEnv: lp.LaunchEnv()} |
| 62 | u, err := b.StartCmd(context.Background(), rt, spec) |
| 63 | if err != nil { |
| 64 | t.Fatalf("StartCmd: %v", err) |
| 65 | } |
| 66 | u.(*cmdUnit).out.Close() |
| 67 | u.(*cmdUnit).errs.Close() |
| 68 | raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.cmd.json")) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | var l instancectl.Launch |
| 73 | if err := json.Unmarshal(raw, &l); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if !slices.Contains(l.Env, "APP_KEY=old") || slices.Contains(l.Env, "APP_KEY=new") { |
| 77 | t.Fatalf("the run's env %q, want the serving generation's APP_KEY=old", l.Env) |
| 78 | } |
| 79 | token := lp.LaunchEnv()["CARLOS_ADMIN_TOKEN"] |
| 80 | if token == "" || !slices.Contains(l.Env, "CARLOS_ADMIN_TOKEN="+token) { |
| 81 | t.Fatal("the run did not get the serving generation's admin token") |
| 82 | } |
| 83 | // The instance key the launch minted is in the generation's snapshot, |
| 84 | // and the command gets exactly it: the command opens the instance's |
| 85 | // sealed data and must never be left to mint a key of its own. |
| 86 | key := lp.LaunchEnv()[instancekey.Var] |
| 87 | if key == "" || !slices.Contains(l.Env, instancekey.Var+"="+key) { |
| 88 | t.Fatalf("the run's env lacks the serving generation's %s", instancekey.Var) |
| 89 | } |
| 90 | if kl, ok := p.(launchKeyLayerProc); !ok || kl.LaunchKeyLayer() != instancekey.LayerOwn { |
| 91 | t.Fatalf("the generation's handle did not keep its key's layer") |
| 92 | } |
| 93 | for _, kv := range l.Env { |
| 94 | if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") || strings.HasPrefix(kv, "CARLOS_PURGE=") { |
| 95 | t.Errorf("a run's env carries %s", kv) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // A run with no generation environment is refused before anything is |
| 100 | // laid out or started: the delivered files are never its fallback. |
| 101 | before := ctl.joined() |
| 102 | if _, err := b.StartCmd(context.Background(), rt, CmdSpec{RunID: "run_b", RunDir: filepath.Join(filepath.Dir(dir), "run_b")}); !errors.Is(err, errNoGenEnv) { |
| 103 | t.Fatalf("StartCmd without a generation environment = %v, want errNoGenEnv", err) |
| 104 | } |
| 105 | if ctl.joined() != before { |
| 106 | t.Fatalf("the refused run reached the control service:\n%s", ctl.joined()) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // RunCmd hands StartCmd the environment the current generation's handle |
| 111 | // kept, and nothing for a handle that kept none, which StartCmd refuses |
| 112 | // (merge ruling M3). |
| 113 | func TestRunCmdHandsOverTheGenerationsLaunchEnvironment(t *testing.T) { |
| 114 | a, cb, _ := testCmdActivator(t, Config{}) |
| 115 | rt := testRoute("c.example.com") |
| 116 | var got []map[string]string |
| 117 | cb.startCmd = func(_ context.Context, _ registry.Route, spec CmdSpec) (CmdUnit, error) { |
| 118 | got = append(got, spec.genEnv) |
| 119 | if spec.genEnv == nil { |
| 120 | return nil, errNoGenEnv |
| 121 | } |
| 122 | return cb.unit, nil |
| 123 | } |
| 124 | arrangeAwake(t, a, rt) |
| 125 | a.mu.Lock() |
| 126 | e := a.entries[rt.Host] |
| 127 | e.proc.(*fakeProc).launchEnv = map[string]string{"APP_KEY": "serving"} |
| 128 | a.mu.Unlock() |
| 129 | h, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_a"}) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := h.Stop(context.Background()); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | h.Release() |
| 137 | a.mu.Lock() |
| 138 | e.proc = envlessProc{e.proc} |
| 139 | a.mu.Unlock() |
| 140 | if _, err := a.RunCmd(context.Background(), rt, CmdSpec{RunID: "run_b"}); !errors.Is(err, errNoGenEnv) { |
| 141 | t.Fatalf("a run on a handle with no launch environment = %v, want errNoGenEnv", err) |
| 142 | } |
| 143 | if len(got) != 2 || got[0]["APP_KEY"] != "serving" || got[1] != nil { |
| 144 | t.Fatalf("StartCmd got %v, want the handle's environment, then none", got) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // A run's command on a replica row passes the replica gate like every |
| 149 | // other launch (instance-key rulings 26 to 29), on the environment it is |
| 150 | // given: no key and no control socket is refused, a key that is not the |
| 151 | // home's is refused, and the home's own key starts. A refusal reaches |
| 152 | // neither the control service nor the instance's directory. |
| 153 | func TestStartCmdPassesTheReplicaKeyGate(t *testing.T) { |
| 154 | const key = "aG9tZS1rZXktaG9tZS1rZXktaG9tZS1rZXktaG9tZSE=" |
| 155 | for _, c := range []struct { |
| 156 | name string |
| 157 | env map[string]string |
| 158 | layer instancekey.Layer |
| 159 | listed string |
| 160 | ok bool |
| 161 | }{ |
| 162 | {"no key", map[string]string{}, instancekey.LayerNone, instancekey.Digest(key), false}, |
| 163 | {"not the home's key", map[string]string{instancekey.Var: key}, instancekey.LayerOwn, instancekey.Digest("another"), false}, |
| 164 | {"the home's key", map[string]string{instancekey.Var: key}, instancekey.LayerOwn, instancekey.Digest(key), true}, |
| 165 | } { |
| 166 | t.Run(c.name, func(t *testing.T) { |
| 167 | ctl := &fakeControl{cmdInv: "inv1"} |
| 168 | b, rt := nestedBackend(t, ctl, &fakeUnit{}) |
| 169 | b.layoutFn = func(registry.Route) (Layout, string) { return LayoutNested, "" } |
| 170 | rt.Replica = true |
| 171 | b.ReplicaHomeKeys = func(string) (string, bool) { return c.listed, true } |
| 172 | dir := filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), CmdRunDirName, "run_a") |
| 173 | spec := CmdSpec{RunID: "run_a", RunDir: dir, Stdout: &syncBuffer{}, Stderr: &syncBuffer{}, genEnv: c.env, genKeyLayer: c.layer} |
| 174 | u, err := b.StartCmd(context.Background(), rt, spec) |
| 175 | if c.ok { |
| 176 | if err != nil { |
| 177 | t.Fatalf("StartCmd: %v", err) |
| 178 | } |
| 179 | u.(*cmdUnit).out.Close() |
| 180 | u.(*cmdUnit).errs.Close() |
| 181 | return |
| 182 | } |
| 183 | if !errors.Is(err, ErrReplicaKey) || u != nil { |
| 184 | t.Fatalf("StartCmd = %v, %v; want ErrReplicaKey and no unit", u, err) |
| 185 | } |
| 186 | if strings.Contains(ctl.joined(), "cmd ") { |
| 187 | t.Fatalf("a refused replica command reached the control service:\n%s", ctl.joined()) |
| 188 | } |
| 189 | if _, err := os.Stat(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.cmd.json")); !os.IsNotExist(err) { |
| 190 | t.Fatalf("a refused replica command left a launch file (%v)", err) |
| 191 | } |
| 192 | }) |
| 193 | } |
| 194 | } |
| 195 | |