carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostbox
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/carlosframework/platform/internal/hostguard"
11)
12
13type cleanupAuthority struct{ calls int }
14
15func (a *cleanupAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, fn func() error) error {
16 a.calls++
17 return fn()
18}
19
20// rootParker uses descriptor-relative operations and link-before-remove. Link
21// is the no-replace claim: an existing destination refuses without touching
22// the source. Retirement directories are provisioned before the run.
23type rootParker struct {
24 root *os.Root
25 calls int
26 crashAfter int
27}
28
29func (p *rootParker) Park(_ context.Context, id string, a Artifact) error {
30 p.calls++
31 dst := filepath.Join("parked", id, a.Root, a.Name)
32 src := filepath.Join(a.Root, a.Name)
33 if err := p.root.Link(src, dst); err != nil {
34 srcInfo, srcErr := p.root.Lstat(src)
35 dstInfo, dstErr := p.root.Lstat(dst)
36 if srcErr != nil || dstErr != nil || !os.SameFile(srcInfo, dstInfo) {
37 return err
38 }
39 }
40 if p.crashAfter == p.calls {
41 return errors.New("crash after durable destination")
42 }
43 return p.root.Remove(src)
44}
45
46type rowRetirer struct {
47 calls int
48 rows map[string]bool
49}
50
51func (r *rowRetirer) RetireRows(_ context.Context, owner string, a []Artifact) error {
52 r.calls++
53 for _, x := range a {
54 key := string(x.Kind) + ":" + x.RowID
55 if r.rows[key] != true {
56 return errors.New("conditional row identity changed")
57 }
58 delete(r.rows, key)
59 }
60 return nil
61}
62
63func TestTypedRetirementJournalsBeforeEffectsAndRetriesWithoutTouchingSuccessor(t *testing.T) {
64 rootPath := t.TempDir()
65 mustMkdir(t, filepath.Join(rootPath, "data"))
66 mustMkdir(t, filepath.Join(rootPath, "live"))
67 mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "data"))
68 mustMkdir(t, filepath.Join(rootPath, "parked", "ret-1", "live"))
69 mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "old-db")
70 mustWrite(t, filepath.Join(rootPath, "live", "app"), "old-link")
71 root, err := os.OpenRoot(rootPath)
72 if err != nil {
73 t.Fatal(err)
74 }
75 defer root.Close()
76 p := &rootParker{root: root, crashAfter: 1}
77 rows := &rowRetirer{rows: map[string]bool{"route:route-7": true, "timer:timer-9": true}}
78 auth := &cleanupAuthority{}
79 acks := 0
80 in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 7}, Ownership: "install-7", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "app.db", Owner: "install-7", Dev: 1, Ino: 1, Type: 1}, {Kind: ArtifactLiveLink, Root: "live", Name: "app", Owner: "install-7", Dev: 1, Ino: 2, Type: 1}, {Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "install-7", RowID: "route-7"}, {Kind: ArtifactTimer, Root: "registry", Name: "shop.example/daily", Owner: "install-7", RowID: "timer-9"}}}
81 j, err := NewRetirementJournal("ret-1", in)
82 if err != nil {
83 t.Fatal(err)
84 }
85 target := hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 7}
86 r := TypedRetirement{JournalDir: filepath.Join(rootPath, "journals"), Authority: auth, Filesystem: p, Rows: rows, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }}
87 if err := r.Run(context.Background(), target, j); err == nil {
88 t.Fatal("first run survived injected crash")
89 }
90 if _, err := os.Stat(filepath.Join(rootPath, "journals", "ret-1.json")); err != nil {
91 t.Fatalf("intent was not durable before effect: %v", err)
92 }
93 // Retry sees the no-replace destination. The parker accepts it only when it
94 // is the same inode, removes the old source name, then continues the journal.
95 p.crashAfter = 0
96 if err := r.Run(context.Background(), target, j); err != nil {
97 t.Fatal(err)
98 }
99 if acks != 1 || rows.calls != 1 {
100 t.Fatalf("acks=%d row txns=%d", acks, rows.calls)
101 }
102 // A successor at the old path appears after completion. Ack retry performs
103 // no filesystem or SQL effect and therefore preserves it.
104 mustWrite(t, filepath.Join(rootPath, "data", "app.db"), "successor")
105 calls := p.calls
106 if err := r.Run(context.Background(), target, j); err != nil {
107 t.Fatal(err)
108 }
109 if p.calls != calls || string(mustReadTyped(t, filepath.Join(rootPath, "data", "app.db"))) != "successor" {
110 t.Fatal("completed retry touched successor")
111 }
112}
113
114func TestTypedRetirementRefusesIncompleteOrUntypedInventory(t *testing.T) {
115 bad := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactRoute, Root: "registry", Name: "shop.example", Owner: "own"}}}
116 if _, err := NewRetirementJournal("r", bad); !errors.Is(err, ErrTypedInventory) {
117 t.Fatalf("error=%v", err)
118 }
119}
120
121func TestTypedRetirementRejectsForgedCallerProgress(t *testing.T) {
122 dir := t.TempDir()
123 in := RetirementInventory{Host: "shop.example", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 1}, Ownership: "own", Artifacts: []Artifact{{Kind: ArtifactPath, Root: "data", Name: "db", Owner: "own", Dev: 1, Ino: 2, Type: 3}}}
124 canonical, err := NewRetirementJournal("ret", in)
125 if err != nil {
126 t.Fatal(err)
127 }
128 acks := 0
129 r := TypedRetirement{JournalDir: dir, Authority: &cleanupAuthority{}, Filesystem: &rootParker{}, Rows: &rowRetirer{}, Ack: func(context.Context, RetirementJournal) error { acks++; return nil }}
130 for name, forged := range map[string]RetirementJournal{"complete": canonical, "empty-steps": canonical, "done": canonical} {
131 switch name {
132 case "complete":
133 forged.Complete = true
134 for i := range forged.Steps {
135 forged.Steps[i].Done = true
136 }
137 case "empty-steps":
138 forged.Steps = nil
139 case "done":
140 forged.Steps[0].Done = true
141 }
142 if err := r.Run(context.Background(), hostguard.FenceTarget{Host: in.Host, Incarnation: "inc", Stamp: 1}, forged); !errors.Is(err, ErrTypedJournal) {
143 t.Fatalf("%s error=%v", name, err)
144 }
145 }
146 if acks != 0 {
147 t.Fatalf("forged progress acked %d times", acks)
148 }
149 if entries, err := os.ReadDir(dir); err != nil || len(entries) != 0 {
150 t.Fatalf("forgery poisoned journal dir: entries=%v err=%v", entries, err)
151 }
152}
153
154func mustMkdir(t *testing.T, p string) {
155 t.Helper()
156 if err := os.MkdirAll(p, 0o700); err != nil {
157 t.Fatal(err)
158 }
159}
160func mustWrite(t *testing.T, p, s string) {
161 t.Helper()
162 if err := os.WriteFile(p, []byte(s), 0o600); err != nil {
163 t.Fatal(err)
164 }
165}
166func mustReadTyped(t *testing.T, p string) []byte {
167 t.Helper()
168 b, e := os.ReadFile(p)
169 if e != nil {
170 t.Fatal(e)
171 }
172 return b
173}
174