| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/activator" |
| 15 | "github.com/carlosframework/platform/internal/hostclaim" |
| 16 | "github.com/carlosframework/platform/internal/hostfiles" |
| 17 | "github.com/carlosframework/platform/internal/lease" |
| 18 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 19 | "github.com/carlosframework/platform/internal/provision" |
| 20 | "github.com/carlosframework/platform/internal/registry" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | func runFilesSeedRealPreparation(t *testing.T, p *servingPreparation, live ...filesSeedLiveWitness) { |
| 25 | t.Helper() |
| 26 | f := p.f |
| 27 | f.actor.Bucket = f.f.bucket |
| 28 | f.actor.Cloud.Region = "eu-west-1" |
| 29 | f.actor.HomeRegion = "ie" |
| 30 | f.actor.FleetHomeRegion = "ie" |
| 31 | p.local.Actor = f.actor |
| 32 | rt := registry.Route{Host: f.f.hostName(), Kind: "instance", Hibernate: true, Account: "bab", App: "jam", DBPath: f.db, Socket: provision.NestedSocket(f.f.hostName())} |
| 33 | reg, err := registry.Open(filepath.Join(t.TempDir(), "seed.db")) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | t.Cleanup(func() { reg.Close() }) |
| 38 | if err := reg.Upsert(rt); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | if err := reg.SetNextDue(rt.Host, 777); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | filesDir := filepath.Join(filepath.Dir(f.db), rt.Host) |
| 45 | if err := os.MkdirAll(filesDir, 0700); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | if err := os.WriteFile(filepath.Join(filesDir, "payload"), []byte("real seeded bytes"), 0600); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if err := os.WriteFile(filepath.Join(filepath.Dir(f.db), rt.Host+".env"), []byte("KEY=value\n"), 0600); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | source := &movesFilesSource{DataDir: f.f.guard.DataDir, Routes: reg, newFiles: func(context.Context, string, string) (release.Store, error) { |
| 55 | return casBucketStore{b: f.f.data.(*leasetest.VersionedBucket), bucket: f.f.bucket}, nil |
| 56 | }} |
| 57 | a := p.assembly |
| 58 | a.Data = f.f.data.(*leasetest.VersionedBucket) |
| 59 | a.Identity = handoverRecoveryIdentity(t, f.actor) |
| 60 | a.Wake.seedFactory = func(ctx context.Context, op *filesSeedOperation, token *activator.SeedExclusion) (*activator.ClaimRuntime, *ClaimWakeAssembly, error) { |
| 61 | runtime, wake, err := newFilesSeedRuntime(ctx, a, p.source, p.writer, op, token) |
| 62 | if err != nil { |
| 63 | return runtime, wake, err |
| 64 | } |
| 65 | if f.f.guard.DataDir != "/data" { |
| 66 | // Local fixture omits only installed /data launch-shape preflight. |
| 67 | runtime.Prerequisites = func(ctx context.Context, rt registry.Route, o hostclaim.Observed, policy activator.StartPolicy) error { |
| 68 | if err := wake.ServingPrerequisites(ctx, rt, o); err != nil { |
| 69 | return err |
| 70 | } |
| 71 | in, err := wake.Inputs.Select(ctx, rt, o, policy) |
| 72 | if err != nil { |
| 73 | return err |
| 74 | } |
| 75 | if err := wake.stopPredecessor(ctx, o); err != nil { |
| 76 | return err |
| 77 | } |
| 78 | held, err := lease.Acquire(ctx, f.f.bucket, o.Claim.Holder.Location, wake.LeaseRegion) |
| 79 | if err != nil { |
| 80 | return err |
| 81 | } |
| 82 | live, cancel := context.WithCancel(ctx) |
| 83 | done := make(chan struct{}) |
| 84 | go func() { <-live.Done(); close(done) }() |
| 85 | wake.pending[rt.Host] = &claimWakeAttempt{owner: wake, observed: o, inputs: in, held: held, ctx: live, cancel: cancel, done: done} |
| 86 | return nil |
| 87 | } |
| 88 | } |
| 89 | return runtime, wake, nil |
| 90 | } |
| 91 | openedSelection, err := source.Open(t.Context(), f.f.claims, f.f.data.(*leasetest.VersionedBucket), f.actor) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | classification, err := inspectFilesSeed(t.Context(), f.f.claims, openedSelection.(*movesFiles), f.f.read(), f.actor, &rt) |
| 96 | if err != nil { |
| 97 | t.Fatal("classification", err) |
| 98 | } |
| 99 | if _, err := selectFilesSeedInputs(t.Context(), p.owners, rt, classification); err != nil { |
| 100 | t.Fatal("input selection", err) |
| 101 | } |
| 102 | executor := &filesSeedExecutor{assembly: &a, service: &activator.Service{}, registry: reg, source: source} |
| 103 | for _, witness := range live { |
| 104 | if witness.seed != nil { |
| 105 | witness.seed(t, p, executor) |
| 106 | return |
| 107 | } |
| 108 | } |
| 109 | var output bytes.Buffer |
| 110 | if err := executor.run(t.Context(), rt.Host, &output); err != nil { |
| 111 | t.Fatalf("seed: %v\n%s", err, output.String()) |
| 112 | } |
| 113 | seeded := f.f.read() |
| 114 | if seeded.Claim.Origins == nil || seeded.Claim.Holder.WakeRetry.AttemptsReserved != 1 || seeded.Claim.Holder.WakeRetry.State != "open" { |
| 115 | t.Fatal("seed did not settle one real admission", seeded) |
| 116 | } |
| 117 | if len(executor.pending) != 0 { |
| 118 | t.Fatal("seed retained its exclusion") |
| 119 | } |
| 120 | before := seeded.ETag |
| 121 | if err := executor.run(t.Context(), rt.Host, &output); err != nil { |
| 122 | t.Fatal("established rerun", err) |
| 123 | } |
| 124 | if f.f.read().ETag != before { |
| 125 | t.Fatal("established rerun mutated claim") |
| 126 | } |
| 127 | opened, err := source.Open(t.Context(), f.f.claims, f.f.data.(*leasetest.VersionedBucket), f.actor) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | ordinary := opened.(*movesFiles) |
| 132 | for wake := 0; wake < 2; wake++ { |
| 133 | f.f.now = f.f.now.Add(time.Minute) |
| 134 | normal := newServingPreparation(t, f, "boot", "controller", func(w *ClaimWakeAssembly) { |
| 135 | w.Inputs.(*claimServingOwners).Sources.Files = ordinary |
| 136 | w.ServingPrerequisites = servingFilesPrerequisites(f.f.claims, ordinary) |
| 137 | for _, witness := range live { |
| 138 | witness.configure(w) |
| 139 | } |
| 140 | }) |
| 141 | for _, witness := range live { |
| 142 | if witness.prepare != nil { |
| 143 | witness.prepare(normal) |
| 144 | } |
| 145 | } |
| 146 | admitted, start := normal.prepare(t) |
| 147 | if err := start.Restore(t.Context()); err != nil { |
| 148 | t.Fatal("cold restore", wake, err) |
| 149 | } |
| 150 | if err := start.Reserve(t.Context(), 0); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if err := start.Materialize(t.Context()); err != nil { |
| 154 | t.Fatal("ordinary C8", wake, err) |
| 155 | } |
| 156 | prefix := movesFilesPrefix(admitted.Claim.Holder.Location, admitted.Claim.Holder.Generation) |
| 157 | manifest, found, err := hostfiles.Load(t.Context(), ordinary.Files, prefix) |
| 158 | if err != nil || !found { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | if _, ok := manifest.Entries["payload"]; !ok { |
| 162 | t.Fatal("files lost at cold wake") |
| 163 | } |
| 164 | b, err := os.ReadFile(filepath.Join(filesDir, "payload")) |
| 165 | if err != nil || string(b) != "real seeded bytes" { |
| 166 | t.Fatal("restored files", string(b), err) |
| 167 | } |
| 168 | for _, witness := range live { |
| 169 | witness.serve(t, normal, admitted, start) |
| 170 | } |
| 171 | healthy := false |
| 172 | for _, witness := range live { |
| 173 | healthy = healthy || witness.healthy |
| 174 | } |
| 175 | if !healthy { |
| 176 | if err := start.Failed(t.Context(), 0); err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | } |
| 180 | if err := normal.runtime.FailedAdmission(t.Context(), admitted, hostclaim.ErrWrongState); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if ordinary.Seed != nil { |
| 184 | t.Fatal("ordinary provider armed") |
| 185 | } |
| 186 | } |
| 187 | healthy := false |
| 188 | for _, witness := range live { |
| 189 | healthy = healthy || witness.healthy |
| 190 | } |
| 191 | if healthy { |
| 192 | if f.f.read().Claim.Holder.WakeRetry != nil { |
| 193 | t.Fatal("health did not clear retry debt") |
| 194 | } |
| 195 | } else if f.f.read().Claim.Holder.WakeRetry.AttemptsReserved != 3 { |
| 196 | t.Fatal("attempt budget changed") |
| 197 | } |
| 198 | t.Log(output.String()) |
| 199 | } |
| 200 | |
| 201 | type filesSeedLiveWitness struct { |
| 202 | host string |
| 203 | shared bool |
| 204 | start func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) func() |
| 205 | healthy bool |
| 206 | prepare func(*servingPreparation) |
| 207 | seed func(*testing.T, *servingPreparation, *filesSeedExecutor) |
| 208 | configure func(*ClaimWakeAssembly) |
| 209 | serve func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) |
| 210 | } |
| 211 | |
| 212 | func TestFilesSeedRealOriginPreparation(t *testing.T) { |
| 213 | runFilesSeedOrdinaryPreparation(t, "real-seed", "same-controller") |
| 214 | } |
| 215 | |
| 216 | func TestFilesSeedReportsSkippedPaths(t *testing.T) { |
| 217 | witness := filesSeedLiveWitness{seed: func(t *testing.T, p *servingPreparation, executor *filesSeedExecutor) { |
| 218 | host := p.f.f.hostName() |
| 219 | dir := filepath.Join(filepath.Dir(p.f.db), host) |
| 220 | large, err := os.Create(filepath.Join(dir, "large.bin")) |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | err = large.Truncate(hostfiles.MaxFileBytes + 1) |
| 225 | large.Close() |
| 226 | if err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | if err := os.Symlink("payload", filepath.Join(dir, "link\nname")); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | var output bytes.Buffer |
| 233 | if err := executor.run(t.Context(), host, &output); err != nil { |
| 234 | t.Fatalf("seed: %v\n%s", err, output.String()) |
| 235 | } |
| 236 | for _, want := range []string{`skipped file "large.bin": exceeds 64 MiB`, `skipped file "link\nname": not a regular file.`, `: seeded.`} { |
| 237 | if !strings.Contains(output.String(), want) { |
| 238 | t.Fatalf("missing %q in output:\n%s", want, output.String()) |
| 239 | } |
| 240 | } |
| 241 | }} |
| 242 | runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", nil, []filesSeedLiveWitness{witness}) |
| 243 | } |
| 244 | |
| 245 | func TestFilesSeedPreparationChangedController(t *testing.T) { |
| 246 | witness := filesSeedLiveWitness{configure: func(*ClaimWakeAssembly) {}, serve: func(*testing.T, *servingPreparation, hostclaim.Observed, activator.ClaimStart) {}} |
| 247 | witness.prepare = func(p *servingPreparation) { |
| 248 | p.local.ControllerID, p.local.PID, p.local.ProcessStart = "new-controller", 88, "new-start" |
| 249 | p.f.f.peer = StopPeer{UID: 2001, PID: 88, ProcessStart: "new-start", BootID: p.local.BootID, ControllerID: "new-controller"} |
| 250 | p.f.p.root.Stop.Coordinator.CarlosUID = 2001 |
| 251 | p.wake.Retries.BootID, p.wake.Retries.ControllerID = p.local.BootID, "new-controller" |
| 252 | if len(p.wake.pending) != 0 { |
| 253 | t.Fatal("rebuilding active fixture") |
| 254 | } |
| 255 | p.wake.pending = nil |
| 256 | var err error |
| 257 | p.runtime, err = p.wake.Build(t.Context(), p.source, p.writer) |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | } |
| 262 | runFilesSeedPreparationWithLive(t, "real-seed", "same-controller", nil, []filesSeedLiveWitness{witness}) |
| 263 | } |
| 264 | |