| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostclaim" |
| 13 | "github.com/carlosframework/platform/internal/hostfiles" |
| 14 | "github.com/carlosframework/platform/internal/instancekey" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // O5 (instance-key plan rev 3): the release flush publishes the adoption |
| 20 | // record in the same commit as the env item only when it binds exactly the |
| 21 | // releasing writer; a stale record, or none, publishes none. A record that |
| 22 | // does not parse refuses the flush (fix round 1, M9): it may be this |
| 23 | // writer's, and absence is never evidence. |
| 24 | func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) { |
| 25 | t.Parallel() |
| 26 | ctx := context.Background() |
| 27 | const host = "shop.example" |
| 28 | writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 6, Generation: "g6"} |
| 29 | for _, tc := range []struct { |
| 30 | name string |
| 31 | record []byte // nil is no file |
| 32 | want bool |
| 33 | refuses bool |
| 34 | }{ |
| 35 | {"the releasing writer's record", instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")), true, false}, |
| 36 | {"a stale record from session 5", instancekey.AdoptionRecord(instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 5, Generation: "g5"}, instancekey.SourcePlatform, instancekey.Digest("k1")), false, false}, |
| 37 | {"a malformed record", []byte(`{"v":1}`), false, true}, |
| 38 | {"no record", nil, false, false}, |
| 39 | } { |
| 40 | t.Run(tc.name, func(t *testing.T) { |
| 41 | data := release.NewDirStore(t.TempDir()) |
| 42 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | t.Cleanup(func() { reg.Close() }) |
| 47 | if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | dir, envDir := t.TempDir(), t.TempDir() |
| 51 | if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("CARLOS_INSTANCE_KEY=k1\n"), 0o600); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if tc.record != nil { |
| 55 | if err := os.WriteFile(filepath.Join(envDir, host+instancekey.AdoptionSuffix), tc.record, 0o600); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | } |
| 59 | f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }} |
| 60 | obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}} |
| 61 | seq, err := f.Flush(ctx, obs) |
| 62 | if tc.refuses { |
| 63 | if !errors.Is(err, ErrClaimStopUnavailable) { |
| 64 | t.Fatalf("a malformed record was flushed: %v", err) |
| 65 | } |
| 66 | if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g-g6/"); ok || err != nil { |
| 67 | t.Fatalf("a refused flush committed a manifest: %v %v", ok, err) |
| 68 | } |
| 69 | return |
| 70 | } |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | prefix := "instances/" + host + "/inc/files/g-g6/" |
| 75 | m, ok, err := hostfiles.Load(ctx, data, prefix) |
| 76 | if err != nil || !ok || m.Seq != seq { |
| 77 | t.Fatalf("manifest: %v %v", ok, err) |
| 78 | } |
| 79 | got, err := hostfiles.ReadPlatform(ctx, data, prefix, m, instancekey.AdoptionItem, nil) |
| 80 | if tc.want && (err != nil || string(got) != string(tc.record)) { |
| 81 | t.Fatalf("the writer's record was not published: %q %v", got, err) |
| 82 | } |
| 83 | if !tc.want && err == nil { |
| 84 | t.Fatalf("a record not the releasing writer's was published: %q", got) |
| 85 | } |
| 86 | }) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Fix round 1, M6: a host with no paths (no route, or a route that could |
| 91 | // not be read) is refused before anything is staged, never flushed with an |
| 92 | // empty env path read as "no env file". |
| 93 | func TestReleaseFlushRefusesAHostWithNoPaths(t *testing.T) { |
| 94 | ctx := context.Background() |
| 95 | const host = "shop.example" |
| 96 | data := release.NewDirStore(t.TempDir()) |
| 97 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | t.Cleanup(func() { reg.Close() }) |
| 102 | paths := relocationHostPaths(t.TempDir(), reg) |
| 103 | if p := paths(host); p.FilesDir != "" || p.EnvPath != "" { |
| 104 | t.Fatalf("paths without a route: %+v", p) |
| 105 | } |
| 106 | f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(h string) string { return paths(h).FilesDir }, EnvPath: func(h string) string { return paths(h).EnvPath }} |
| 107 | obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}} |
| 108 | if _, err := f.Flush(ctx, obs); !errors.Is(err, ErrClaimStopUnavailable) { |
| 109 | t.Fatalf("flushed with no paths: %v", err) |
| 110 | } |
| 111 | if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g-g6/"); ok || err != nil { |
| 112 | t.Fatalf("a refused flush committed a manifest: %v %v", ok, err) |
| 113 | } |
| 114 | } |
| 115 | |