| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostfiles" |
| 16 | "github.com/carlosframework/platform/internal/hostwrite" |
| 17 | "github.com/carlosframework/platform/internal/lease" |
| 18 | "github.com/carlosframework/platform/internal/lease/leasetest" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | func filesSeedPrivateFixture(t *testing.T) (*filesSeedFixture, *movesFiles, *filesSeedOperation, ClaimLaunchInputs) { |
| 24 | f := newFilesSeedFixture(t) |
| 25 | selected, err := f.inspect() |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | t.Cleanup(func() { reg.Close() }) |
| 34 | if err := reg.Upsert(f.route); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if err := reg.SetNextDue(filesHost, 777); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | f.actor.Cloud.Region = "eu-west-1" |
| 41 | source := &movesFilesSource{DataDir: f.files.DataDir, Routes: testClaimRoutes{rows: map[string]registry.Route{filesHost: f.route}}, newFiles: func(context.Context, string, string) (release.Store, error) { return f.files.Files, nil }} |
| 42 | private, op, err := newFilesSeedProvider(t.Context(), source, f.claims, f.data, f.actor, reg, selected) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if strings.HasSuffix(t.Name(), "/origins-off") { |
| 47 | t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| 48 | } |
| 49 | f.admit("seed") |
| 50 | if err := op.admitted(t.Context(), f.observed); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | in := ClaimLaunchInputs{Identity: hostwrite.Identity{Config: "config", Release: "release"}, DBPath: f.route.DBPath, Admission: hostclaim.Admission{Box: f.actor.Box, Region: f.actor.Region, Mode: "cold", NewGeneration: "seed"}} |
| 54 | return f, private, op, in |
| 55 | } |
| 56 | |
| 57 | func TestFilesSeedMaterializeUsesRealAdmittedPrefix(t *testing.T) { |
| 58 | f, private, op, in := filesSeedPrivateFixture(t) |
| 59 | path := filepath.Join(filepath.Dir(f.route.DBPath), filesHost, "tenant.txt") |
| 60 | if err := os.WriteFile(path, []byte("tenant bytes"), 0600); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | owners := plan9D5Fixture(t).owners |
| 64 | owners.Sources.Files = private |
| 65 | work := &filesFixture{t: t} |
| 66 | if err := owners.Materialize(t.Context(), f.observed, in, work); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | prefix := movesFilesPrefix(f.observed.Claim.Holder.Location, "seed") |
| 70 | m, found, err := hostfiles.Load(t.Context(), f.files.Files, prefix) |
| 71 | if err != nil || !found || m.Entries["tenant.txt"].Object == "" || len(m.Origins) != 1 || m.Origins[0] != prefix || op.stats.Published != 1 { |
| 72 | t.Fatal(m, err, op.stats) |
| 73 | } |
| 74 | ws, found, err := hostfiles.LoadWake(t.Context(), f.files.Files, prefix, m, m.Origins) |
| 75 | if err != nil || !found || ws.NextDue != 777 { |
| 76 | t.Fatal(ws, err) |
| 77 | } |
| 78 | env, err := hostfiles.ReadPlatform(t.Context(), f.files.Files, prefix, m, hostfiles.EnvItem, m.Origins) |
| 79 | if err != nil || string(env) != "KEY=value\n" { |
| 80 | t.Fatal(string(env), err) |
| 81 | } |
| 82 | version := f.data.LatestID("data", prefix+"manifest.json") |
| 83 | if err := private.Establish(t.Context(), f.observed, in, work); !errors.Is(err, hostfiles.ErrManifestExists) { |
| 84 | t.Fatal("armed replay", err) |
| 85 | } |
| 86 | if f.data.LatestID("data", prefix+"manifest.json") != version || f.files.Seed != nil { |
| 87 | t.Fatal("replay or ordinary provider changed") |
| 88 | } |
| 89 | // An ordinary successor carries the committed manifest without a seed hook. |
| 90 | f.admit("next") |
| 91 | in.Admission.NewGeneration = "next" |
| 92 | if err := f.files.Establish(t.Context(), f.observed, in, work); err != nil { |
| 93 | t.Fatal("ordinary successor", err) |
| 94 | } |
| 95 | if f.files.Seed != nil { |
| 96 | t.Fatal("seed escaped into ordinary provider") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | type filesSeedReadCounter struct { |
| 101 | lease.VersionedStore |
| 102 | reads int |
| 103 | } |
| 104 | |
| 105 | func (s *filesSeedReadCounter) OpenVersion(ctx context.Context, bucket, key, version string) (io.ReadCloser, error) { |
| 106 | if strings.Contains(key, "/objects/") { |
| 107 | s.reads++ |
| 108 | } |
| 109 | return s.VersionedStore.OpenVersion(ctx, bucket, key, version) |
| 110 | } |
| 111 | |
| 112 | type filesSeedConflictStore struct { |
| 113 | release.Store |
| 114 | data *leasetest.VersionedBucket |
| 115 | poison bool |
| 116 | injected bool |
| 117 | } |
| 118 | |
| 119 | func (s *filesSeedConflictStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| 120 | if !s.injected && strings.Contains(key, "/objects/") { |
| 121 | s.injected = true |
| 122 | actual := body |
| 123 | if s.poison { |
| 124 | actual = []byte("poisoned bytes") |
| 125 | } |
| 126 | s.data.Put("data", key, actual) |
| 127 | } |
| 128 | return s.Store.Put(ctx, key, body, etag) |
| 129 | } |
| 130 | func TestFilesSeedCreateConflictChecksBytesBeforeCommit(t *testing.T) { |
| 131 | for _, kind := range []string{"created", "same", "poisoned"} { |
| 132 | t.Run(kind, func(t *testing.T) { |
| 133 | f, private, _, in := filesSeedPrivateFixture(t) |
| 134 | counter := &filesSeedReadCounter{VersionedStore: f.data} |
| 135 | private.Data = counter |
| 136 | if kind != "created" { |
| 137 | private.Files = &filesSeedConflictStore{Store: private.Files, data: f.data, poison: kind == "poisoned"} |
| 138 | } |
| 139 | err := private.Establish(t.Context(), f.observed, in, &filesFixture{t: t}) |
| 140 | prefix := movesFilesPrefix(f.observed.Claim.Holder.Location, "seed") |
| 141 | if kind == "poisoned" { |
| 142 | if !errors.Is(err, hostfiles.ErrSuperseded) || f.data.LatestID("data", prefix+"manifest.json") != "" { |
| 143 | t.Fatal("poison committed", err) |
| 144 | } |
| 145 | } else if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | want := 1 |
| 149 | if kind == "created" { |
| 150 | want = 0 |
| 151 | } |
| 152 | if counter.reads != want { |
| 153 | t.Fatal("object read count", counter.reads, want) |
| 154 | } |
| 155 | }) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestFilesSeedRefusesMissingOriginAndWrongTuple(t *testing.T) { |
| 160 | for _, fault := range []string{"generation", "empty", "prepared", "source-gone", "origins-off"} { |
| 161 | t.Run(fault, func(t *testing.T) { |
| 162 | f, private, _, in := filesSeedPrivateFixture(t) |
| 163 | switch fault { |
| 164 | case "generation": |
| 165 | in.Admission.NewGeneration = "foreign" |
| 166 | case "empty": |
| 167 | in.Empty = true |
| 168 | case "prepared": |
| 169 | in.Prepared = true |
| 170 | case "source-gone": |
| 171 | if err := os.Remove(filepath.Join(filepath.Dir(f.route.DBPath), filesHost)); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | case "origins-off": |
| 175 | if f.observed.Claim.Origins != nil { |
| 176 | t.Fatal("fixture recorded origins") |
| 177 | } |
| 178 | |
| 179 | } |
| 180 | if err := private.Establish(t.Context(), f.observed, in, &filesFixture{t: t}); err == nil { |
| 181 | t.Fatal("accepted", fault) |
| 182 | } |
| 183 | if f.data.LatestID("data", movesFilesPrefix(f.observed.Claim.Holder.Location, in.Admission.NewGeneration)+"manifest.json") != "" { |
| 184 | t.Fatal("refusal committed") |
| 185 | } |
| 186 | }) |
| 187 | } |
| 188 | } |
| 189 | |