carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10
11 "github.com/carlosframework/platform/internal/registry"
12)
13
14// writeGenFiles writes the intent file and the generations file by hand, as
15// a crashed or failed publication would leave them. An empty body leaves
16// that file absent.
17func writeGenFiles(t *testing.T, path, intent, body string) {
18 t.Helper()
19 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
20 t.Fatal(err)
21 }
22 if intent != "" {
23 if err := os.WriteFile(GenerationsSeqPath(path), []byte(intent), 0o644); err != nil {
24 t.Fatal(err)
25 }
26 }
27 if body != "" {
28 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
29 t.Fatal(err)
30 }
31 }
32}
33
34func noRows() ([]registry.Route, error) { return nil, nil }
35
36// Ruling R19 item 4: a successful publication writes the intent, then the
37// file carrying the same sequence and the complete marker, both 0644, no temp
38// file left behind; each publication advances the sequence; and the reader
39// returns what was written.
40func TestGenerationsPublicationRoundTrips(t *testing.T) {
41 path := GenerationsPath(t.TempDir())
42 first := Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}}
43 if err := WriteGenerations(path, first); err != nil {
44 t.Fatalf("WriteGenerations: %v", err)
45 }
46 want := Generations{
47 Hosts: map[string][]string{"a.example.com": {"v2"}},
48 Paths: map[string][]string{"a.example.com": {"/opt/carlos/versions/v2/km"}, "b.example.com": {"/srv/operator/app"}},
49 }
50 if err := WriteGenerations(path, want); err != nil {
51 t.Fatalf("WriteGenerations: %v", err)
52 }
53 got, err := ReadGenerations(path)
54 if err != nil || got == nil {
55 t.Fatalf("ReadGenerations = %+v, %v", got, err)
56 }
57 if got.Schema != 1 || got.Seq != 2 || !got.Complete || !reflect.DeepEqual(got.Hosts, want.Hosts) || !reflect.DeepEqual(got.Paths, want.Paths) {
58 t.Fatalf("read back %+v; want schema 1, seq 2, complete, and the hosts and paths written", got)
59 }
60 intent, err := os.ReadFile(GenerationsSeqPath(path))
61 if err != nil || strings.TrimSpace(string(intent)) != "2" {
62 t.Fatalf("intent file = %q, %v; want 2", intent, err)
63 }
64 for _, p := range []string{path, GenerationsSeqPath(path)} {
65 if info, err := os.Stat(p); err != nil || info.Mode().Perm() != 0o644 {
66 t.Fatalf("%s mode = %v, %v; want 0644", p, info, err)
67 }
68 }
69 entries, err := os.ReadDir(filepath.Dir(path))
70 if err != nil || len(entries) != 2 {
71 t.Fatalf("zdd dir holds %v, %v; want generations.json and generations.seq only", entries, err)
72 }
73 if GenerationsSeqPath(path) != filepath.Join(filepath.Dir(path), "generations.seq") {
74 t.Fatalf("GenerationsSeqPath = %s, want <data dir>/zdd/generations.seq", GenerationsSeqPath(path))
75 }
76}
77
78// The sequence is monotonic across writers (an agent restart): the next
79// publication reads the intent on disk and adds one.
80func TestGenerationsSequenceContinuesFromTheIntentOnDisk(t *testing.T) {
81 path := GenerationsPath(t.TempDir())
82 writeGenFiles(t, path, "41\n", `{"schema":1,"seq":41,"complete":true,"hosts":{}}`)
83 if err := WriteGenerations(path, Generations{}); err != nil {
84 t.Fatalf("WriteGenerations: %v", err)
85 }
86 if g, err := ReadGenerations(path); err != nil || g.Seq != 42 {
87 t.Fatalf("ReadGenerations = %+v, %v; want seq 42", g, err)
88 }
89}
90
91// A publication whose file write fails leaves the intent ahead of the file
92// on disk, so the older file, still readable, is no longer trusted.
93func TestAFailedGenerationsWriteLeavesTheIntentAhead(t *testing.T) {
94 path := GenerationsPath(t.TempDir())
95 if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v1"}}}); err != nil {
96 t.Fatal(err)
97 }
98 // The file's rename fails: a non-empty directory sits at its path.
99 if err := os.Remove(path); err != nil {
100 t.Fatal(err)
101 }
102 if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o755); err != nil {
103 t.Fatal(err)
104 }
105 if err := WriteGenerations(path, Generations{Hosts: map[string][]string{"a.example.com": {"v2"}}}); err == nil {
106 t.Fatal("WriteGenerations succeeded with a directory at the file's path")
107 }
108 if intent, err := os.ReadFile(GenerationsSeqPath(path)); err != nil || strings.TrimSpace(string(intent)) != "2" {
109 t.Fatalf("intent after a failed file write = %q, %v; want 2, written before the file", intent, err)
110 }
111}
112
113// Ruling R19 item 4, the reader: with the intent present, anything but a
114// complete file carrying the intent's sequence is incomplete, for the pin set
115// as for the reader.
116func TestGenerationsFileIncompleteUnlessItMatchesTheIntent(t *testing.T) {
117 for name, files := range map[string][2]string{
118 "intent ahead of the file": {"2\n", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`},
119 "stale seq, no paths": {"3", `{"schema":1,"seq":2,"complete":true,"hosts":{"a.example.com":["v1"]}}`},
120 "file without complete": {"1", `{"schema":1,"seq":1,"hosts":{}}`},
121 "file with complete false": {"1", `{"schema":1,"seq":1,"complete":false,"hosts":{}}`},
122 "file without seq": {"1", `{"schema":1,"complete":true,"hosts":{}}`},
123 "intent and no file": {"1", ""},
124 "unreadable intent": {"one", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`},
125 "zero intent": {"0", `{"schema":1,"complete":true,"hosts":{}}`},
126 "file with seq and no intent": {"", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`},
127 "relative path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":["versions/v1/km"]}}`},
128 "empty path": {"1", `{"schema":1,"seq":1,"complete":true,"paths":{"a.example.com":[""]}}`},
129 "legacy file with relative path": {"", `{"schema":1,"paths":{"a.example.com":["km"]}}`},
130 } {
131 t.Run(name, func(t *testing.T) {
132 path := GenerationsPath(t.TempDir())
133 writeGenFiles(t, path, files[0], files[1])
134 if g, err := ReadGenerations(path); err == nil {
135 t.Fatalf("ReadGenerations = %+v, nil; want an error (incomplete)", g)
136 }
137 if pins, err := GenerationPins(path, noRows).StagedPins(context.Background(), t.TempDir()); err == nil {
138 t.Fatalf("StagedPins = %v, nil; want an error (incomplete pin set)", pins)
139 }
140 })
141 }
142}
143
144// Neither file present is no generations: every box before the agent first
145// published. A file with neither seq nor intent (schema 1 as first written)
146// still reads, with hosts, paths, or both.
147func TestGenerationsFileWithoutIntent(t *testing.T) {
148 path := GenerationsPath(t.TempDir())
149 if g, err := ReadGenerations(path); err != nil || g != nil {
150 t.Fatalf("neither file: ReadGenerations = %+v, %v; want nil, nil", g, err)
151 }
152 writeGenFiles(t, path, "", `{"schema":1,"paths":{"a.example.com":["/opt/carlos/versions/v1/km"]}}`)
153 g, err := ReadGenerations(path)
154 if err != nil || g == nil || !reflect.DeepEqual(g.Paths, map[string][]string{"a.example.com": {"/opt/carlos/versions/v1/km"}}) {
155 t.Fatalf("paths-only legacy file: ReadGenerations = %+v, %v", g, err)
156 }
157}
158
159// Ruling R19 item 2: a path entry is pinned as written (cleaned) and needs no
160// registry row; version entries still do.
161func TestGenerationPinsPinPathsWithoutARow(t *testing.T) {
162 path := GenerationsPath(t.TempDir())
163 if err := WriteGenerations(path, Generations{Paths: map[string][]string{"gone.example.com": {"/opt/carlos/versions/v1/./km"}}}); err != nil {
164 t.Fatal(err)
165 }
166 rowsAsked := false
167 rows := func() ([]registry.Route, error) { rowsAsked = true; return nil, nil }
168 pins, err := GenerationPins(path, rows).StagedPins(context.Background(), t.TempDir())
169 if err != nil || !pins.Has("/opt/carlos/versions/v1/km") {
170 t.Fatalf("StagedPins = %v, %v; want the path pinned", pins, err)
171 }
172 if rowsAsked {
173 t.Fatal("a paths-only file asked for registry rows")
174 }
175}
176
177// WriteGenerations refuses a relative or empty path before it touches either
178// file, so a refused publication never leaves the intent ahead.
179func TestWriteGenerationsRefusesARelativePath(t *testing.T) {
180 path := GenerationsPath(t.TempDir())
181 for _, bad := range []string{"versions/v1/km", ""} {
182 if err := WriteGenerations(path, Generations{Paths: map[string][]string{"a.example.com": {bad}}}); err == nil {
183 t.Fatalf("WriteGenerations wrote path %q", bad)
184 }
185 }
186 if _, err := os.Stat(GenerationsSeqPath(path)); !os.IsNotExist(err) {
187 t.Fatalf("a refused write left an intent file: %v", err)
188 }
189}
190
191// Ruling R19 item 2, Astra r3 F1(b): a worker exec'd through a chained live
192// link (live/h2 -> operator-current -> the staged v2) has Version "", because
193// the link's own target is outside the version store, yet it runs v2's staged
194// bytes. The agent publishes it by path. Once live/h2 has moved on and no row
195// names v2, that path entry alone keeps a re-shipped v2 from being restaged
196// over it.
197func TestAPathEntryProtectsAChainedLinksStagedCopy(t *testing.T) {
198 f := newReuseFixture(t)
199 current := filepath.Join(t.TempDir(), "operator-current")
200 if err := os.Symlink(VersionPath(f.opt, "bab", "v2", "myapp"), current); err != nil {
201 t.Fatal(err)
202 }
203 if err := os.Symlink(current, LiveLink(f.opt, "h2.example.com")); err != nil {
204 t.Fatal(err)
205 }
206 id, err := ResolveLive(f.opt, "h2.example.com")
207 if err != nil {
208 t.Fatalf("ResolveLive: %v", err)
209 }
210 if id.Version != "" || id.StagedPath != VersionPath(f.opt, "bab", "v2", "myapp") {
211 t.Fatalf("captured %+v; want no Version and v2's staged path", id)
212 }
213 dataDir := t.TempDir()
214 if err := WriteGenerations(GenerationsPath(dataDir), Generations{Paths: map[string][]string{"h2.example.com": {id.StagedPath}}}); err != nil {
215 t.Fatal(err)
216 }
217 // The live link moves on; only the generations file still names the copy.
218 if err := os.Remove(LiveLink(f.opt, "h2.example.com")); err != nil {
219 t.Fatal(err)
220 }
221 f.assertRefused(t, f.pass(t, BakeDir(dataDir)), ErrVersionReused.Error())
222}
223
224// Ruling R19 item 4 through the pass: a file left behind by a publication
225// whose intent moved on is not trusted, so nothing is restaged.
226func TestAStaleGenerationsFileNeverPermitsARestage(t *testing.T) {
227 f := newReuseFixture(t)
228 dataDir := t.TempDir()
229 path := GenerationsPath(dataDir)
230 writeGenFiles(t, path, "2", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`)
231 f.assertRefused(t, f.pass(t, BakeDir(dataDir)), "what refers to it is unknown")
232}
233
234// Ruling R19 item 1: the version sweep keys pins by version directory. Every
235// listed version is one, and every listed path inside the version store, as
236// written or by its real path under a symlinked opt dir, names its version
237// directory. A path outside the store names none. An incomplete file is an
238// error, and neither file is no versions.
239func TestGenerationVersionsNameTheVersionDirectories(t *testing.T) {
240 real := t.TempDir()
241 opt := filepath.Join(t.TempDir(), "opt")
242 if err := os.Symlink(real, opt); err != nil {
243 t.Fatal(err)
244 }
245 for _, v := range []string{"v2", "v3"} {
246 if err := StageArtifact(real, "kqmv", v, "km", []byte(v)); err != nil {
247 t.Fatal(err)
248 }
249 }
250 path := GenerationsPath(t.TempDir())
251 if got, err := GenerationVersions(path, opt); err != nil || len(got) != 0 {
252 t.Fatalf("neither file: GenerationVersions = %v, %v; want none", got, err)
253 }
254 g := Generations{
255 Hosts: map[string][]string{"a.example.com": {"v1"}},
256 Paths: map[string][]string{
257 "b.example.com": {VersionPath(opt, "kqmv", "v2", "km")},
258 "c.example.com": {VersionPath(real, "kqmv", "v3", "km"), "/srv/operator/app"},
259 },
260 }
261 if err := WriteGenerations(path, g); err != nil {
262 t.Fatal(err)
263 }
264 got, err := GenerationVersions(path, opt)
265 if err != nil || !reflect.DeepEqual(got, map[string]bool{"v1": true, "v2": true, "v3": true}) {
266 t.Fatalf("GenerationVersions = %v, %v; want v1, v2 and v3", got, err)
267 }
268 writeGenFiles(t, path, "9", "")
269 if got, err := GenerationVersions(path, opt); err == nil {
270 t.Fatalf("GenerationVersions on an incomplete file = %v, nil; want an error", got)
271 }
272}
273