| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 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 | |
| 19 | var ( |
| 20 | ErrTypedInventory = errors.New("hostbox: invalid typed retirement inventory") |
| 21 | ErrTypedJournal = errors.New("hostbox: invalid typed retirement journal") |
| 22 | ) |
| 23 | |
| 24 | type ArtifactKind string |
| 25 | |
| 26 | const ( |
| 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. |
| 36 | type 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 | |
| 49 | type 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 | |
| 56 | type RetirementStep struct { |
| 57 | Artifact Artifact `json:"artifact"` |
| 58 | Done bool `json:"done"` |
| 59 | } |
| 60 | type 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 | |
| 71 | type FilesystemParker interface { |
| 72 | Park(context.Context, string, Artifact) error |
| 73 | } |
| 74 | type ConditionalRows interface { |
| 75 | RetireRows(context.Context, string, []Artifact) error |
| 76 | } |
| 77 | type RetirementAuthority interface { |
| 78 | WithCleanup(context.Context, hostguard.FenceTarget, func() error) error |
| 79 | } |
| 80 | |
| 81 | type TypedRetirement struct { |
| 82 | JournalDir string |
| 83 | Authority RetirementAuthority |
| 84 | Filesystem FilesystemParker |
| 85 | Rows ConditionalRows |
| 86 | Ack func(context.Context, RetirementJournal) error |
| 87 | } |
| 88 | |
| 89 | func component(s string) bool { |
| 90 | return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| 91 | } |
| 92 | func 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 | |
| 104 | func 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 | |
| 137 | func 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 | |
| 151 | func (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. |
| 257 | func 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 | |
| 280 | func (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 | |
| 317 | func 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 | } |
| 328 | func 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 | |