package hostbox import ( "context" "database/sql" "encoding/json" "net/url" "os" "path/filepath" "github.com/carlosframework/platform/internal/hostguard" _ "modernc.org/sqlite" ) type LaunchFile struct{ Device, Inode uint64 } type LaunchGroup struct { RelativePath string Device, Inode uint64 InvocationID string } type LaunchReservation struct { NeverIntended bool `json:",omitempty"` ID, Role, InvocationID, Inputs, State, ProcessStart string Target hostguard.FenceTarget Group LaunchGroup Parent LaunchGroup Executable LaunchFile PID int } type LaunchInventory struct { WritersClosed bool `json:"writers_closed,omitempty"` Completed ProducerCompletion `json:",omitzero"` ControllerPID int ProcessStart string Target hostguard.FenceTarget Groups []LaunchGroup Files []LaunchFile ProducerIDs []string NoState bool Reservations []LaunchReservation } type LaunchJournal struct { db *sql.DB remote JournalCall path string identity os.FileInfo } func OpenLaunchJournal(path string) (*LaunchJournal, error) { if !filepath.IsAbs(path) { return nil, ErrUnsupported } if err := checkJournalPath(path); err != nil { return nil, err } db, err := sql.Open("sqlite", "file:"+url.PathEscape(path)+"?_pragma=busy_timeout(5000)&_pragma=synchronous(FULL)") if err != nil { return nil, err } db.SetMaxOpenConns(1) for _, q := range []string{"PRAGMA busy_timeout=5000", "PRAGMA journal_mode=WAL", "PRAGMA synchronous=FULL", "CREATE TABLE IF NOT EXISTS launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL)"} { if _, err = db.Exec(q); err != nil { db.Close() return nil, err } } identity, err := os.Lstat(path) if err != nil { db.Close() return nil, err } return &LaunchJournal{db: db, path: path, identity: identity}, nil } func (j *LaunchJournal) Close() error { if j == nil { return nil } if j.remote != nil { return nil } return j.db.Close() } func launchKey(t hostguard.FenceTarget) string { raw, _ := json.Marshal([]any{t.Host, t.Incarnation, t.Stamp, t.Session, t.Box, t.Enrollment, t.BootID, t.ControllerID}) return string(raw) } func SameLaunchOwner(a, b hostguard.FenceTarget) bool { return a.Host == b.Host && a.Incarnation == b.Incarnation && a.Stamp == b.Stamp && a.Session == b.Session && a.Box == b.Box && a.Enrollment == b.Enrollment && a.BootID == b.BootID && a.ControllerID == b.ControllerID } func validLaunchOwner(t hostguard.FenceTarget) bool { return t.Host != "" && t.Incarnation != "" && t.Stamp > 0 && t.Session > 0 && t.Box != "" && t.Enrollment != "" && t.BootID != "" && t.ControllerID != "" } func (j *LaunchJournal) Create(ctx context.Context, in LaunchInventory) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "create", Launch: in}) _ = out return err } if err := journalIdentity(j.path, j.identity); err != nil { return err } if in.WritersClosed || in.Completed != (ProducerCompletion{}) || !validLaunchOwner(in.Target) || in.ControllerPID <= 0 || in.ProcessStart == "" || len(in.Groups) == 0 || (len(in.Files) == 0 && !in.NoState) || len(in.Reservations) != 0 { return ErrUnsupported } body, err := json.Marshal(in) if err != nil { return err } _, err = journalExecutorFor(ctx, j.db).ExecContext(ctx, "INSERT INTO launches(identity,body) VALUES(?,?)", launchKey(in.Target), body) return err } // Prelaunch persists an admitted session's recoverable record before restore // or materialization. It names this controller and the session's empty parent // group only. State directories are inventoried before restore; file identities // and producer reservations are added later. A failure or crash from here on // leaves the exact tuple root stops and settles. func (j *LaunchJournal) Prelaunch(ctx context.Context, in LaunchInventory) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "prelaunch", Launch: in}) _ = out return err } if len(in.Groups) != 1 || in.Groups[0].RelativePath == "" || in.Groups[0].Device == 0 || in.Groups[0].Inode == 0 || in.Groups[0].InvocationID != "" || len(in.Files) != 0 || !in.NoState || len(in.ProducerIDs) != 0 { return ErrUnsupported } in.ProducerIDs = []string{} return j.Create(ctx, in) } // SessionLaunches returns every record journaled for one writer session of // this box, whichever boot or controller wrote it. A restarted controller uses // it to name the original tuple. An empty result never proves emptiness. func (j *LaunchJournal) SessionLaunches(ctx context.Context, host, incarnation string, stamp, session uint64, box, enrollment string) ([]LaunchInventory, error) { if j == nil { return nil, ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "session", Target: hostguard.FenceTarget{Host: host, Incarnation: incarnation, Stamp: stamp, Session: session, Box: box, Enrollment: enrollment}}) return out.Launches, err } if j == nil || j.db == nil || !component(host) || incarnation == "" || stamp == 0 || session == 0 || box == "" || enrollment == "" { return nil, ErrUnsupported } rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT identity, body FROM launches") if err != nil { return nil, err } defer rows.Close() var out []LaunchInventory for rows.Next() { var key string var body []byte if err = rows.Scan(&key, &body); err != nil { return nil, err } var in LaunchInventory if err = json.Unmarshal(body, &in); err != nil { return nil, err } if !validLaunchOwner(in.Target) || launchKey(in.Target) != key { return nil, ErrTypedInventory } t := in.Target if t.Host == host && t.Incarnation == incarnation && t.Stamp == stamp && t.Session == session && t.Box == box && t.Enrollment == enrollment { out = append(out, in) } } return out, rows.Err() } func (j *LaunchJournal) Read(ctx context.Context, t hostguard.FenceTarget) (LaunchInventory, error) { if j == nil { return LaunchInventory{}, ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "read", Target: t}) return out.Launch, err } var in LaunchInventory var raw []byte if j == nil || j.db == nil || !validLaunchOwner(t) { return in, ErrUnsupported } if err := journalExecutorFor(ctx, j.db).QueryRowContext(ctx, "SELECT body FROM launches WHERE identity=?", launchKey(t)).Scan(&raw); err != nil { return in, err } if err := json.Unmarshal(raw, &in); err != nil { return in, err } if !SameLaunchOwner(in.Target, t) { return in, ErrChanged } return in, nil } func (j *LaunchJournal) mutate(ctx context.Context, t hostguard.FenceTarget, fn func(*LaunchInventory) error) error { if j == nil || j.db == nil || !validLaunchOwner(t) { return ErrUnsupported } tx, commit, rollback, err := journalTransaction(ctx, j.db) if err != nil { return err } defer rollback() var raw []byte if err = tx.QueryRowContext(ctx, "SELECT body FROM launches WHERE identity=?", launchKey(t)).Scan(&raw); err != nil { return err } var in LaunchInventory if err = json.Unmarshal(raw, &in); err != nil { return err } if !SameLaunchOwner(in.Target, t) { return ErrChanged } if err = fn(&in); err != nil { return err } next, err := json.Marshal(in) if err != nil { return err } res, err := tx.ExecContext(ctx, "UPDATE launches SET body=? WHERE identity=? AND body=?", next, launchKey(t), raw) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n != 1 { return ErrChanged } return commit() } func (j *LaunchJournal) Reserve(ctx context.Context, r LaunchReservation) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "reserve", Reservation: r}) _ = out return err } if r.NeverIntended || r.ID == "" || r.Role == "" || r.InvocationID == "" || r.Inputs == "" || r.Group.RelativePath == "" || r.Group.Device == 0 || r.Group.Inode == 0 || r.Executable.Device == 0 || r.Executable.Inode == 0 || r.State != "" || r.PID != 0 || r.Group.InvocationID != r.InvocationID { return ErrUnsupported } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { found := -1 for i, old := range in.Reservations { if old.ID == r.ID || old.Group.RelativePath == r.Group.RelativePath { expected := r expected.State = "intended" expected.Group.Device, expected.Group.Inode = 0, 0 if old != expected { return ErrChanged } found = i } } if r.Parent.RelativePath != "" && found < 0 { return ErrChanged } r.State = "reserved" if found >= 0 { in.Reservations[found] = r } else { in.Reservations = append(in.Reservations, r) } in.Groups = append(in.Groups, r.Group) return nil }) } func (j *LaunchJournal) Started(ctx context.Context, r LaunchReservation, pid int, processStart string) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "started", Reservation: r, PID: pid, ProcessStart: processStart}) _ = out return err } if pid <= 0 || processStart == "" { return ErrUnsupported } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { for i := range in.Reservations { old := &in.Reservations[i] expected := r expected.State = "reserved" if *old == expected { old.State = "started" old.PID = pid old.ProcessStart = processStart return nil } } return ErrChanged }) } func (j *LaunchJournal) Settled(ctx context.Context, r LaunchReservation) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "settled", Reservation: r}) _ = out return err } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { for i := range in.Reservations { old := &in.Reservations[i] expected := r expected.State = old.State expected.PID = old.PID expected.ProcessStart = old.ProcessStart if *old == expected && (old.State == "reserved" || old.State == "started" || old.State == "settled") { old.State = "settled" return nil } } return ErrChanged }) } func (j *LaunchJournal) RecordFile(ctx context.Context, t hostguard.FenceTarget, f LaunchFile) error { if j == nil { return ErrUnsupported } if j.remote != nil { out, err := journalCall(ctx, j.remote, JournalRequest{Op: "file", Target: t, File: f}) _ = out return err } if f.Device == 0 || f.Inode == 0 { return ErrUnsupported } return j.mutate(ctx, t, func(in *LaunchInventory) error { for _, old := range in.Files { if old == f { return nil } } in.NoState = false in.Files = append(in.Files, f) return nil }) } func (j *LaunchJournal) Remote() bool { return j != nil && j.remote != nil }