| 1 | package hostfiles |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | ) |
| 11 | |
| 12 | func wakeReg(t *testing.T) *registry.Registry { |
| 13 | t.Helper() |
| 14 | r, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 15 | if err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | t.Cleanup(func() { r.Close() }) |
| 19 | return r |
| 20 | } |
| 21 | |
| 22 | func seedHost(t *testing.T, r *registry.Registry, host string) { |
| 23 | t.Helper() |
| 24 | if err := r.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestWakeStateRoundTripsThroughTheManifest(t *testing.T) { |
| 30 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 31 | src, dst := wakeReg(t), wakeReg(t) |
| 32 | seedHost(t, src, "shop.test") |
| 33 | seedHost(t, dst, "shop.test") |
| 34 | if err := src.ReplaceTimers("shop.test", []registry.Timer{ |
| 35 | {Host: "shop.test", Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000, |
| 36 | LastStatus: "ok", Attempts: 1, CreatedAt: 10, DueAt: 4900, Registration: "reg-n"}, |
| 37 | {Host: "shop.test", Name: "remind", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/r", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-r"}, |
| 38 | {Host: "shop.test", Name: "done", Kind: "once", Spec: "2026-10-01T00:00:00Z", Path: "/d", NextAt: 0, LastAt: 3000, CreatedAt: 9, Registration: "reg-d"}, |
| 39 | }); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if err := src.SetNextDue("shop.test", 7777); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if err := src.SetReplication("shop.test", registry.ReplicationBatched); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | |
| 49 | ws, err := ExportWake(src, "shop.test") |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if len(ws.Timers) != 2 { |
| 54 | t.Fatalf("exported %d timers, want 2: a finished once-timer is history, not wake state", len(ws.Timers)) |
| 55 | } |
| 56 | p := open(t, s) |
| 57 | if err := StageWake(ctx, p, ws); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | commit(t, p) |
| 61 | |
| 62 | m, _, err := Load(ctx, s, pfx) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | got, found, err := LoadWake(ctx, s, pfx, m, nil) |
| 67 | if err != nil || !found { |
| 68 | t.Fatalf("LoadWake found=%v err=%v", found, err) |
| 69 | } |
| 70 | if err := ImportWake(dst, "shop.test", got); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | rows, _ := dst.TimersFor("shop.test") |
| 74 | if len(rows) != 2 || rows[0].Name != "nightly" || rows[0].Registration != "reg-n" || rows[0].NextAt != 5000 || rows[0].Attempts != 1 { |
| 75 | t.Fatalf("imported timers: %+v", rows) |
| 76 | } |
| 77 | rt, err := dst.Get("shop.test") |
| 78 | if err != nil || rt == nil { |
| 79 | t.Fatalf("route: %v %v", rt, err) |
| 80 | } |
| 81 | if rt.NextDue != 7777 || rt.Replication != registry.ReplicationBatched { |
| 82 | t.Fatalf("NextDue=%d Replication=%q", rt.NextDue, rt.Replication) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestStageWakeOfUnchangedStateStagesNothing(t *testing.T) { |
| 87 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 88 | p := open(t, s) |
| 89 | ws := WakeState{V: 1, Host: "shop.test", NextDue: 5} |
| 90 | if err := StageWake(ctx, p, ws); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | commit(t, p) |
| 94 | if err := StageWake(ctx, p, ws); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if p.Dirty() { |
| 98 | t.Fatal("unchanged wake state was staged again") |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestLoadWakeWithNoneReportsNotFound(t *testing.T) { |
| 103 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 104 | m, _, _ := Load(ctx, s, pfx) |
| 105 | if _, found, err := LoadWake(ctx, s, pfx, m, nil); found || err != nil { |
| 106 | t.Fatalf("found=%v err=%v", found, err) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // A manifest that names wake state whose object has gone is lost data, |
| 111 | // not a host without timers. |
| 112 | func TestLoadWakeWithItsObjectMissingIsAnError(t *testing.T) { |
| 113 | ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| 114 | p := open(t, s) |
| 115 | if err := StageWake(ctx, p, WakeState{V: 1, Host: "shop.test", NextDue: 5}); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | commit(t, p) |
| 119 | m, _, err := Load(ctx, s, pfx) |
| 120 | if err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | if err := s.Delete(ctx, m.Platform[WakeItem].Object); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if _, found, err := LoadWake(ctx, s, pfx, m, nil); err == nil || found { |
| 127 | t.Fatalf("found = %v, err = %v; a missing wake object must be an error", found, err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestImportWakeRefusesAnotherHostsState(t *testing.T) { |
| 132 | r := wakeReg(t) |
| 133 | seedHost(t, r, "shop.test") |
| 134 | if err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "other.test"}); err == nil { |
| 135 | t.Fatal("imported another host's wake state") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestImportWakeRefusesAnUnknownReplicationClassAndChangesNothing(t *testing.T) { |
| 140 | r := wakeReg(t) |
| 141 | seedHost(t, r, "shop.test") |
| 142 | if err := r.ReplaceTimers("shop.test", []registry.Timer{{Host: "shop.test", Name: "keep", Kind: "schedule", Spec: "@every 1m", Path: "/k", NextAt: 1, CreatedAt: 1}}); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "shop.test", Replication: "hourly"}) |
| 146 | if err == nil { |
| 147 | t.Fatal("accepted replication class \"hourly\"") |
| 148 | } |
| 149 | if rows, _ := r.TimersFor("shop.test"); len(rows) != 1 { |
| 150 | t.Fatalf("a refused import changed the timers: %+v", rows) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestExportWakeOfAnUnknownHostIsAnError(t *testing.T) { |
| 155 | if _, err := ExportWake(wakeReg(t), "nobody.test"); err == nil { |
| 156 | t.Fatal("exported wake state for a host with no route") |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestImportWakeOfTheDefaultClassReplacesAnotherClass(t *testing.T) { |
| 161 | r := wakeReg(t) |
| 162 | seedHost(t, r, "shop.test") |
| 163 | if err := r.SetReplication("shop.test", registry.ReplicationDaily); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "shop.test", Replication: ""}); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | rt, err := r.Get("shop.test") |
| 170 | if err != nil || rt == nil { |
| 171 | t.Fatalf("route: %v %v", rt, err) |
| 172 | } |
| 173 | if rt.Replication != "" { |
| 174 | t.Fatalf("Replication = %q, want the default", rt.Replication) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func TestImportWakeForAHostWithNoRouteWritesNothing(t *testing.T) { |
| 179 | r := wakeReg(t) |
| 180 | if err := r.ReplaceTimers("ghost.test", []registry.Timer{{Host: "ghost.test", Name: "keep", Kind: "schedule", Spec: "@every 1m", Path: "/k", NextAt: 1, CreatedAt: 1}}); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | ws := WakeState{V: 1, Host: "ghost.test", Timers: []WakeTimer{{Name: "new", Kind: "schedule", Spec: "@every 2m", Path: "/n", NextAt: 2, CreatedAt: 2}}} |
| 184 | if err := ImportWake(r, "ghost.test", ws); err == nil { |
| 185 | t.Fatal("imported wake state for a host with no route") |
| 186 | } |
| 187 | rows, _ := r.TimersFor("ghost.test") |
| 188 | if len(rows) != 1 || rows[0].Name != "keep" { |
| 189 | t.Fatalf("a refused import changed the timers: %+v", rows) |
| 190 | } |
| 191 | } |
| 192 | |