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 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14
15 "github.com/carlosframework/platform/internal/hostguard"
16 "github.com/carlosframework/platform/internal/rootstate"
17)
18
19var (
20 ErrTypedInventory = errors.New("hostbox: invalid typed retirement inventory")
21 ErrTypedJournal = errors.New("hostbox: invalid typed retirement journal")
22)
23
24type ArtifactKind string
25
26const (
27 ArtifactPath ArtifactKind = "path"
28 ArtifactLiveLink ArtifactKind = "live-link"
29 ArtifactUnitState ArtifactKind = "unit-state"
30 ArtifactRoute ArtifactKind = "route"
31 ArtifactTimer ArtifactKind = "timer"
32)
33
34// Artifact binds a managed name to the installation identity which created it.
35// Root is a configured root identifier, never an arbitrary absolute pathname.
36type Artifact struct {
37 Identity rootstate.FileIdentity `json:",omitempty"`
38 AllowAbsent bool `json:"allow_absent,omitempty"`
39 Kind ArtifactKind `json:"kind"`
40 Root string `json:"root"`
41 Name string `json:"name"`
42 Owner string `json:"owner"`
43 RowID string `json:"row_id,omitempty"`
44 Dev uint64 `json:"dev,omitempty"`
45 Ino uint64 `json:"ino,omitempty"`
46 Type uint32 `json:"type,omitempty"`
47}
48
49type RetirementInventory struct {
50 Host string `json:"host"`
51 Captured hostguard.Captured `json:"captured"`
52 Ownership string `json:"ownership"`
53 Artifacts []Artifact `json:"artifacts"`
54}
55
56type RetirementStep struct {
57 Artifact Artifact `json:"artifact"`
58 Done bool `json:"done"`
59}
60type RetirementJournal struct {
61 Target *hostguard.FenceTarget `json:"target,omitempty"`
62 StopReceipt []byte `json:"stop_receipt,omitempty"`
63 Archives json.RawMessage `json:"archives,omitempty"`
64 V int `json:"v"`
65 ID string `json:"id"`
66 Inventory RetirementInventory `json:"inventory"`
67 Steps []RetirementStep `json:"steps"`
68 Complete bool `json:"complete"`
69}
70
71type FilesystemParker interface {
72 Park(context.Context, string, Artifact) error
73}
74type ConditionalRows interface {
75 RetireRows(context.Context, string, []Artifact) error
76}
77type RetirementAuthority interface {
78 WithCleanup(context.Context, hostguard.FenceTarget, func() error) error
79}
80
81type TypedRetirement struct {
82 JournalDir string
83 Authority RetirementAuthority
84 Filesystem FilesystemParker
85 Rows ConditionalRows
86 Ack func(context.Context, RetirementJournal) error
87}
88
89func component(s string) bool {
90 return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
91}
92func managedName(s string) bool {
93 if s == "" || filepath.IsAbs(s) || filepath.Clean(s) != s || strings.ContainsAny(s, "\\\x00") {
94 return false
95 }
96 for _, p := range strings.Split(s, "/") {
97 if !component(p) {
98 return false
99 }
100 }
101 return true
102}
103
104func validateInventory(in RetirementInventory) error {
105 if !component(in.Host) || in.Captured.Incarnation == "" || in.Captured.Stamp == 0 || !component(in.Ownership) || len(in.Artifacts) == 0 {
106 return ErrTypedInventory
107 }
108 seen := map[string]bool{}
109 for _, a := range in.Artifacts {
110 if !component(a.Root) || !managedName(a.Name) || a.Owner != in.Ownership {
111 return ErrTypedInventory
112 }
113 switch a.Kind {
114 case ArtifactPath, ArtifactLiveLink, ArtifactUnitState:
115 if a.RowID != "" || a.Dev == 0 || a.Ino == 0 || a.Type == 0 {
116 return ErrTypedInventory
117 }
118 case ArtifactRoute, ArtifactTimer:
119 if !component(a.RowID) || a.Dev != 0 || a.Ino != 0 || a.Type != 0 {
120 return ErrTypedInventory
121 }
122 default:
123 return ErrTypedInventory
124 }
125 if a.AllowAbsent && a.Kind != ArtifactTimer {
126 return ErrTypedInventory
127 }
128 key := string(a.Kind) + "\x00" + a.Root + "\x00" + a.Name + "\x00" + a.RowID
129 if seen[key] {
130 return ErrTypedInventory
131 }
132 seen[key] = true
133 }
134 return nil
135}
136
137func NewRetirementJournal(id string, in RetirementInventory) (RetirementJournal, error) {
138 if !component(id) || validateInventory(in) != nil {
139 return RetirementJournal{}, ErrTypedInventory
140 }
141 a := append([]Artifact(nil), in.Artifacts...)
142 sort.Slice(a, func(i, j int) bool { return fmt.Sprint(a[i]) < fmt.Sprint(a[j]) })
143 in.Artifacts = a
144 j := RetirementJournal{V: 1, ID: id, Inventory: in, Steps: make([]RetirementStep, len(a))}
145 for i := range a {
146 j.Steps[i].Artifact = a[i]
147 }
148 return j, nil
149}
150
151func (r TypedRetirement) Run(ctx context.Context, target hostguard.FenceTarget, intent RetirementJournal) error {
152 if ctx == nil || r.JournalDir == "" || r.Authority == nil || r.Filesystem == nil || r.Rows == nil || r.Ack == nil || intent.V != 1 || !component(intent.ID) || validateInventory(intent.Inventory) != nil || target.Host != intent.Inventory.Host || target.Incarnation != intent.Inventory.Captured.Incarnation || target.Stamp != intent.Inventory.Captured.Stamp {
153 return ErrTypedInventory
154 }
155 canonical, err := NewRetirementJournal(intent.ID, intent.Inventory)
156 if err != nil {
157 return err
158 }
159 // Callers provide immutable intent only. Progress is trusted exclusively
160 // from the durable journal this controller wrote itself.
161 if intent.Complete || len(intent.Steps) != len(canonical.Steps) {
162 return ErrTypedJournal
163 }
164 for i := range intent.Steps {
165 if intent.Steps[i].Done || intent.Steps[i].Artifact != canonical.Steps[i].Artifact {
166 return ErrTypedJournal
167 }
168 }
169
170 if intent.Target != nil {
171 if *intent.Target != target || len(intent.StopReceipt) == 0 {
172 return ErrTypedJournal
173 }
174 copied := *intent.Target
175 canonical.Target = &copied
176 canonical.StopReceipt = append([]byte(nil), intent.StopReceipt...)
177 }
178 intent = canonical
179 // Root's journal namespace is its own: no symlinked component, and no
180 // directory another principal can write.
181 if err := checkTrustedJournalDir(r.JournalDir, true); err != nil {
182 return err
183 }
184 path := filepath.Join(r.JournalDir, intent.ID+".json")
185 j, err := r.loadOrCreate(path, intent)
186 if err != nil {
187 return err
188 }
189 if !j.Complete {
190 err = r.Authority.WithCleanup(ctx, target, func() error {
191 var rows []Artifact
192 for i := range j.Steps {
193 if j.Steps[i].Done {
194 continue
195 }
196 a := j.Steps[i].Artifact
197 switch a.Kind {
198 case ArtifactRoute, ArtifactTimer:
199 rows = append(rows, a)
200 default:
201 if err := r.Filesystem.Park(ctx, j.ID, a); err != nil {
202 return err
203 }
204 j.Steps[i].Done = true
205 if err := writeJournal(path, j); err != nil {
206 return err
207 }
208 }
209 }
210 if len(rows) > 0 {
211 var rowErr error
212 if durable, ok := r.Rows.(interface {
213 RetireRowsJournal(context.Context, string, string, []Artifact, func(json.RawMessage) error) error
214 }); ok {
215 rowErr = durable.RetireRowsJournal(ctx, j.ID, j.Inventory.Ownership, rows, func(b json.RawMessage) error {
216 if len(j.Archives) > 0 && !bytes.Equal(j.Archives, b) {
217 return ErrTypedJournal
218 }
219 j.Archives = append(json.RawMessage(nil), b...)
220 return writeJournal(path, j)
221 })
222 } else {
223 rowErr = r.Rows.RetireRows(ctx, j.Inventory.Ownership, rows)
224 }
225 if err := rowErr; err != nil {
226 return err
227 }
228 for i := range j.Steps {
229 if j.Steps[i].Artifact.Kind == ArtifactRoute || j.Steps[i].Artifact.Kind == ArtifactTimer {
230 j.Steps[i].Done = true
231 }
232 }
233 if err := writeJournal(path, j); err != nil {
234 return err
235 }
236 }
237 for _, s := range j.Steps {
238 if !s.Done {
239 return ErrTypedJournal
240 }
241 }
242 j.Complete = true
243 return writeJournal(path, j)
244 })
245 if err != nil {
246 return err
247 }
248 }
249 // Acknowledgement is deliberately after the complete journal and outside
250 // the cleanup guard. A crash here replays only this idempotent call.
251 return r.Ack(ctx, j)
252}
253
254// SavedRetirementJournals lists root's resumable retirement journals. They are
255// read only from a trusted directory and decode strictly; a malformed entry
256// refuses the whole listing.
257func SavedRetirementJournals(dir string) ([]RetirementJournal, error) {
258 entries, err := readTrustedJournalDir(dir)
259 if err != nil {
260 return nil, err
261 }
262 var out []RetirementJournal
263 for _, entry := range entries {
264 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
265 continue
266 }
267 body, err := readTrustedJournal(filepath.Join(dir, entry.Name()))
268 if err != nil {
269 return nil, err
270 }
271 var j RetirementJournal
272 if decodeStrict(body, &j) != nil || j.Target == nil || j.ID+".json" != entry.Name() {
273 return nil, ErrTypedJournal
274 }
275 out = append(out, j)
276 }
277 return out, nil
278}
279
280func (r TypedRetirement) loadOrCreate(path string, want RetirementJournal) (RetirementJournal, error) {
281 b, err := readTrustedJournal(path)
282 if errors.Is(err, os.ErrNotExist) {
283 if err := writeJournal(path, want); err != nil {
284 return RetirementJournal{}, err
285 }
286 return want, nil
287 }
288 if err != nil {
289 return RetirementJournal{}, err
290 }
291 var got RetirementJournal
292 if err := decodeStrict(b, &got); err != nil {
293 return RetirementJournal{}, ErrTypedJournal
294 }
295
296 if (want.Target == nil) != (got.Target == nil) || (want.Target != nil && *want.Target != *got.Target) || !bytes.Equal(want.StopReceipt, got.StopReceipt) {
297 return RetirementJournal{}, ErrTypedJournal
298 }
299 a, _ := json.Marshal(want.Inventory)
300 c, _ := json.Marshal(got.Inventory)
301 if got.V != 1 || got.ID != want.ID || !bytes.Equal(a, c) || len(got.Steps) != len(want.Steps) {
302 return RetirementJournal{}, ErrTypedJournal
303 }
304 allDone := true
305 for i := range got.Steps {
306 if got.Steps[i].Artifact != want.Steps[i].Artifact {
307 return RetirementJournal{}, ErrTypedJournal
308 }
309 allDone = allDone && got.Steps[i].Done
310 }
311 if got.Complete && !allDone {
312 return RetirementJournal{}, ErrTypedJournal
313 }
314 return got, nil
315}
316
317func decodeStrict(b []byte, v any) error {
318 d := json.NewDecoder(bytes.NewReader(b))
319 d.DisallowUnknownFields()
320 if err := d.Decode(v); err != nil {
321 return err
322 }
323 if err := d.Decode(new(any)); err != io.EOF {
324 return ErrTypedJournal
325 }
326 return nil
327}
328func writeJournal(path string, j RetirementJournal) error {
329 b, err := json.Marshal(j)
330 if err != nil {
331 return err
332 }
333 if err := checkTrustedJournalDir(filepath.Dir(path), false); err != nil {
334 return err
335 }
336 tmp, err := os.CreateTemp(filepath.Dir(path), ".retire-*")
337 if err != nil {
338 return err
339 }
340 name := tmp.Name()
341 defer os.Remove(name)
342 if _, err = tmp.Write(b); err == nil {
343 err = tmp.Sync()
344 }
345 if ce := tmp.Close(); err == nil {
346 err = ce
347 }
348 if err != nil {
349 return err
350 }
351 if err = os.Rename(name, path); err != nil {
352 return err
353 }
354 d, err := os.Open(filepath.Dir(path))
355 if err != nil {
356 return err
357 }
358 defer d.Close()
359 return d.Sync()
360}
361