| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/registry" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | func TestParseShowReadsActiveEnterTimestamp(t *testing.T) { |
| 18 | p := parseShow("ActiveState=active\nActiveEnterTimestamp=Mon 2026-10-06 09:30:30 UTC\n") |
| 19 | if want := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC); !p.ActiveEnterTimestamp.Equal(want) { |
| 20 | t.Fatalf("ActiveEnterTimestamp = %v, want %v", p.ActiveEnterTimestamp, want) |
| 21 | } |
| 22 | if !parseShow("ActiveEnterTimestamp=\n").ActiveEnterTimestamp.IsZero() { |
| 23 | t.Fatal("an empty timestamp is not zero") |
| 24 | } |
| 25 | if !parseShow("ActiveEnterTimestamp=n/a\n").ActiveEnterTimestamp.IsZero() { |
| 26 | t.Fatal("n/a is not zero") |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | const observedHost = "platform.example.com" |
| 31 | |
| 32 | // observeFixture: the route's live link names versions/v1/console, and by |
| 33 | // default the unit runs that very file, bound, active for 20 minutes, ready. |
| 34 | func observeFixture(t *testing.T) (proofObserver, registry.Route, string, time.Time) { |
| 35 | t.Helper() |
| 36 | opt := t.TempDir() |
| 37 | liveTo(t, opt, observedHost, "v1") |
| 38 | staged := filepath.Join(opt, "versions", "v1", "console") |
| 39 | now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| 40 | o := proofObserver{ |
| 41 | sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7, |
| 42 | ExecPath: release.LiveLink(opt, observedHost), ActiveEnterTimestamp: now.Add(-20 * time.Minute)}}}, |
| 43 | procExe: func(int) string { return staged }, |
| 44 | optDir: opt, |
| 45 | now: func() time.Time { return now }, |
| 46 | ready: func(registry.Route) func() error { return func() error { return nil } }, |
| 47 | } |
| 48 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 49 | o.resolve = proofFixtureResolver(rt) |
| 50 | return o, rt, staged, now |
| 51 | } |
| 52 | |
| 53 | func TestProofObserveAcceptsABakedBuild(t *testing.T) { |
| 54 | o, rt, staged, now := observeFixture(t) |
| 55 | b, err := o.observe(rt, 15*time.Minute) |
| 56 | if err != nil { |
| 57 | t.Fatalf("observe: %v", err) |
| 58 | } |
| 59 | want, _ := release.FileSHA256(staged) |
| 60 | if b.SHA256 != want || b.Version != "v1" || !b.ActiveSince.Equal(now.Add(-20*time.Minute)) { |
| 61 | t.Fatalf("build = %+v", b) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // Spec §7: the mint conditions, each false in turn. |
| 66 | func TestProofObserveConditionsEachFalse(t *testing.T) { |
| 67 | for name, c := range map[string]struct { |
| 68 | edit func(t *testing.T, o *proofObserver, now time.Time) |
| 69 | want string |
| 70 | }{ |
| 71 | "binding": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 72 | o.sys.(*fakeUnits).shows[0].ExecPath = release.LiveLink(o.optDir, "console.example.com") |
| 73 | }, "binding"}, |
| 74 | "not active": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 75 | o.sys.(*fakeUnits).shows[0].ActiveState = "failed" |
| 76 | }, "failed"}, |
| 77 | "too young": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 78 | o.sys.(*fakeUnits).shows[0].ActiveEnterTimestamp = now.Add(-5 * time.Minute) |
| 79 | }, "needs 15m"}, |
| 80 | "restarted during the check": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 81 | f := o.sys.(*fakeUnits) |
| 82 | second := f.shows[0] |
| 83 | second.InvocationID = "i2" |
| 84 | f.shows = append(f.shows, second) |
| 85 | }, "restarted"}, |
| 86 | "exe and live link differ": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 87 | other := filepath.Join(t.TempDir(), "console") |
| 88 | if err := os.WriteFile(other, []byte("a different build"), 0o755); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | o.procExe = func(int) string { return other } |
| 92 | }, "is not the live link's build"}, |
| 93 | "not ready": {func(t *testing.T, o *proofObserver, now time.Time) { |
| 94 | o.ready = func(registry.Route) func() error { return func() error { return errors.New("status 503") } } |
| 95 | }, "not ready"}, |
| 96 | } { |
| 97 | t.Run(name, func(t *testing.T) { |
| 98 | o, rt, _, now := observeFixture(t) |
| 99 | c.edit(t, &o, now) |
| 100 | if _, err := o.observe(rt, 15*time.Minute); err == nil || !strings.Contains(err.Error(), c.want) { |
| 101 | t.Fatalf("observe = %v, want an error containing %q", err, c.want) |
| 102 | } |
| 103 | }) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // D1's settle passing is the hook that records a healthy build, hashed from |
| 108 | // the executable that passed. |
| 109 | func TestSettledRestartRecordsTheRunningExe(t *testing.T) { |
| 110 | opt, bake := t.TempDir(), t.TempDir() |
| 111 | liveTo(t, opt, observedHost, "new") |
| 112 | staged, err := filepath.EvalSymlinks(filepath.Join(opt, "versions", "new", "console")) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 117 | if err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | t.Cleanup(func() { ln.Close() }) |
| 121 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service", Addr: ln.Addr().String()} |
| 122 | f := &fakeUnits{shows: []unitProps{ |
| 123 | {ActiveState: "active", InvocationID: "old", ExecPath: filepath.Join(opt, "live", observedHost)}, |
| 124 | {ActiveState: "active", InvocationID: "new", MainPID: 7, ExecPath: filepath.Join(opt, "live", observedHost)}, |
| 125 | }, exe: map[int]string{7: staged}} |
| 126 | settled := proofFixtureSettled(f, func(int) string { return staged }, opt, bake, time.Now, rt) |
| 127 | if err := agentCheckedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| 128 | t.Fatalf("restart: %v", err) |
| 129 | } |
| 130 | sha, _ := release.FileSHA256(staged) |
| 131 | h := release.ReadHealthy(bake, "bab", "console") |
| 132 | if len(h) != 1 || h[0].SHA256 != sha || h[0].How != "settle" || h[0].Version != "new" { |
| 133 | t.Fatalf("history = %+v, want the settled build", h) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // Ruling 8: the settled hook records only what observe would. Its own Show |
| 138 | // must name the live link as the unit's executable, and the bytes it hashes |
| 139 | // must be the live link's; otherwise a process restarted, or not yet exec'd, |
| 140 | // in the moment after the settle could enter the history. |
| 141 | func TestRecordSettledRecordsNothingForAnotherExe(t *testing.T) { |
| 142 | for name, c := range map[string]struct { |
| 143 | execPath func(opt string) string |
| 144 | procExe func(t *testing.T, opt string) string |
| 145 | }{ |
| 146 | "Show names another executable": { |
| 147 | execPath: func(opt string) string { return release.LiveLink(opt, "console.example.com") }, |
| 148 | procExe: func(t *testing.T, opt string) string { return filepath.Join(opt, "versions", "new", "console") }, |
| 149 | }, |
| 150 | "the running bytes are not the live link's": { |
| 151 | execPath: func(opt string) string { return release.LiveLink(opt, observedHost) }, |
| 152 | procExe: func(t *testing.T, opt string) string { |
| 153 | other := filepath.Join(t.TempDir(), "console") |
| 154 | if err := os.WriteFile(other, []byte("a different build"), 0o755); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | return other |
| 158 | }, |
| 159 | }, |
| 160 | } { |
| 161 | t.Run(name, func(t *testing.T) { |
| 162 | opt, bake := t.TempDir(), t.TempDir() |
| 163 | liveTo(t, opt, observedHost, "new") |
| 164 | exe := c.procExe(t, opt) |
| 165 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 166 | f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7, |
| 167 | ExecPath: c.execPath(opt)}}} |
| 168 | proofFixtureSettled(f, func(int) string { return exe }, opt, bake, time.Now, rt)(rt) |
| 169 | if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| 170 | t.Fatalf("recorded %+v for a process that is not the live link's build", h) |
| 171 | } |
| 172 | }) |
| 173 | } |
| 174 | // The control: the same call with both equalities holding records. |
| 175 | opt, bake := t.TempDir(), t.TempDir() |
| 176 | liveTo(t, opt, observedHost, "new") |
| 177 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 178 | f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7, |
| 179 | ExecPath: release.LiveLink(opt, observedHost)}}} |
| 180 | staged := filepath.Join(opt, "versions", "new", "console") |
| 181 | proofFixtureSettled(f, func(int) string { return staged }, opt, bake, time.Now, rt)(rt) |
| 182 | if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 1 || h[0].How != "settle" { |
| 183 | t.Fatalf("history = %+v, want the settled build", h) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestUnsettledRestartRecordsNothing(t *testing.T) { |
| 188 | opt, bake := t.TempDir(), t.TempDir() |
| 189 | liveTo(t, opt, observedHost, "new") |
| 190 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 191 | f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "old", |
| 192 | ExecPath: filepath.Join(opt, "live", "console.example.com")}}} |
| 193 | settled := proofFixtureSettled(f, func(int) string { return "/nonexistent" }, opt, bake, time.Now, rt) |
| 194 | if err := agentCheckedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| 195 | t.Fatalf("restart: %v", err) |
| 196 | } |
| 197 | if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| 198 | t.Fatalf("a restart that was not checked recorded %+v", h) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // F2: a unit an operator stopped during the settle is not judged, and its |
| 203 | // build is not recorded as healthy, even though its executable is readable. |
| 204 | func TestStoppedDuringSettleRecordsNothing(t *testing.T) { |
| 205 | opt, bake := t.TempDir(), t.TempDir() |
| 206 | liveTo(t, opt, observedHost, "new") |
| 207 | staged := filepath.Join(opt, "versions", "new", "console") |
| 208 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 209 | f := &fakeUnits{shows: []unitProps{ |
| 210 | {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| 211 | ExecPath: filepath.Join(opt, "live", observedHost)}, |
| 212 | {ActiveState: "inactive", SubState: "dead", InvocationID: "old"}, |
| 213 | }} |
| 214 | settled := proofFixtureSettled(f, func(int) string { return staged }, opt, bake, time.Now, rt) |
| 215 | if err := agentCheckedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| 216 | t.Fatalf("restart: %v", err) |
| 217 | } |
| 218 | if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| 219 | t.Fatalf("a stopped unit recorded %+v", h) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // A settle that fails records nothing either. |
| 224 | func TestFailedSettleRecordsNothing(t *testing.T) { |
| 225 | opt, bake := t.TempDir(), t.TempDir() |
| 226 | liveTo(t, opt, observedHost, "new") |
| 227 | staged := filepath.Join(opt, "versions", "new", "console") |
| 228 | rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| 229 | f := &fakeUnits{shows: []unitProps{ |
| 230 | {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| 231 | ExecPath: filepath.Join(opt, "live", observedHost)}, |
| 232 | {ActiveState: "failed", SubState: "failed", InvocationID: "new"}, |
| 233 | }} |
| 234 | settled := proofFixtureSettled(f, func(int) string { return staged }, opt, bake, time.Now, rt) |
| 235 | if err := agentCheckedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err == nil { |
| 236 | t.Fatal("a failed unit settled") |
| 237 | } |
| 238 | if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| 239 | t.Fatalf("a failed build recorded %+v", h) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | func proofFixtureResolver(rt registry.Route) proofBindingResolver { |
| 244 | rt.Account = release.RouteAccount(rt) |
| 245 | return func(_ context.Context, candidate registry.Route) (proofBinding, error) { |
| 246 | if candidate.Host != rt.Host || candidate.App != rt.App || candidate.Unit != rt.Unit { |
| 247 | return proofBinding{}, os.ErrPermission |
| 248 | } |
| 249 | return proofBinding{Route: rt, Source: "test-installed-binding", Network: true}, nil |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func proofFixtureSettled(sys unitSystem, procExe func(int) string, opt, history string, now func() time.Time, rt registry.Route) func(registry.Route) error { |
| 254 | if f, ok := sys.(*fakeUnits); ok { |
| 255 | for i := range f.shows { |
| 256 | if f.shows[i].ActiveEnterTimestamp.IsZero() { |
| 257 | f.shows[i].ActiveEnterTimestamp = now().Add(-time.Minute) |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | o := proofObserver{sys: sys, procExe: procExe, optDir: opt, now: now, resolve: proofFixtureResolver(rt), |
| 262 | ready: func(registry.Route) func() error { return func() error { return nil } }} |
| 263 | return o.recordSettled(history) |
| 264 | } |
| 265 | |