package host import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfiles" "github.com/carlosframework/platform/internal/instancefs" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ReleaseFlushFiles is release step 3 (§5.8, §5.6 "Release flush"): one // complete publish of the host's files directory, its wake state (timer // rows, NextDue, Replication) and its env file into the session's // generation, as one manifest revision, whatever the app's // files_durability. hostfiles.Publish leaves platform items out, so this // is the Open, StagePlatform, SyncDir, Commit sequence the contracts note // §2 names. It runs after StopWriters, so nothing changes under it. type ReleaseFlushFiles struct { Data release.Store Registry *registry.Registry FilesDir func(host string) string EnvPath func(host string) string // Skip is the files publisher's own exclusion (nil publishes every // regular file). Skip func(rel string, isDir bool) bool } // Flush publishes and returns the committed manifest sequence. The env // file is staged when it exists; when it does not, its deletion is staged // (StagePlatformDelete), so a host whose env file was removed after an // earlier flush publishes none rather than the old bytes. "Does not // exist" is positive evidence only (readInstanceFile): a file that cannot // be read safely, or a directory that cannot be opened, refuses. A // manifest revision is committed even when nothing changed (Commit // returns the current sequence), so the release's manifest_seq is always // the sequence that holds this box's final state. func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (int64, error) { if f.Data == nil || f.Registry == nil || f.FilesDir == nil || f.EnvPath == nil || obs.Claim == nil || obs.Claim.Holder == nil { return 0, ErrClaimStopUnavailable } h := obs.Claim.Holder if h.Generation == "" || h.Location == "" || !controlname.ValidHostShape(obs.Host) { return 0, ErrClaimStopUnavailable } // No paths is no flush, refused before anything is staged: the host // has no route (relocationHostPaths), or its route could not be read. envPath, filesDir := f.EnvPath(obs.Host), f.FilesDir(obs.Host) if envPath == "" || filesDir == "" { return 0, fmt.Errorf("%w: no files directory or env file for %s", ErrClaimStopUnavailable, obs.Host) } p, err := hostfiles.Open(ctx, f.Data, moves.GenerationFilesPrefix(h.Location, h.Generation)) if err != nil { return 0, err } ws, err := hostfiles.ExportWake(f.Registry, obs.Host) if err != nil { return 0, err } root, err := instancefs.OpenDirBeneath(filepath.Dir(filesDir), filepath.Base(filesDir)) if err != nil { return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) } defer root.Close() tenantRootSeam("opened") st, err := hostfiles.Seed(ctx, p, root, ws, f.Skip) if err != nil { if len(st.Unreadable) > 0 || len(st.Retry) > 0 || len(st.Torn) > 0 { return 0, fmt.Errorf("hostfiles: release flush of %s: unreadable %q replaced %q torn %q", obs.Host, st.Unreadable, st.Retry, st.Torn) } return 0, err } // The env file and the adoption record live in the instance directory, // which the tenant can write: both are read through main's one safe // read for that ground (readInstanceFile), never os.ReadFile. envDir, envName := filepath.Dir(envPath), filepath.Base(envPath) env, absent, err := readInstanceFile(envDir, envName, instancekey.EnvReadLimit) switch { case err != nil: return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) case absent: // The env file is gone: the release's final state has none, so a // previously published one is deleted, never carried forward. if err := p.StagePlatformDelete(hostfiles.EnvItem); err != nil { return 0, err } default: if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil { return 0, err } } // O5 (instance-key plan rev 3): the adoption record beside the env // file is staged in the same commit when it binds exactly the // releasing writer. A record of another writer (an earlier session or // generation), or none, has its deletion staged, so it is never // published as this writer's. A record that is present but does not // parse refuses the flush: it may be this writer's, and absence is // never evidence (fix round 1, M9). writer := instancekey.Binding{Host: obs.Host, Incarnation: h.Incarnation, Session: h.Session, Generation: h.Generation} rec, absent, err := readInstanceFile(envDir, obs.Host+instancekey.AdoptionSuffix, instancekey.AdoptionReadLimit) switch { case err != nil: return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) case absent: if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil { return 0, err } default: a, err := instancekey.ParseAdoption(rec) if err != nil { return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) } if a.Binds(writer) { err = p.StagePlatform(ctx, instancekey.AdoptionItem, rec) } else { err = p.StagePlatformDelete(instancekey.AdoptionItem) } if err != nil { return 0, err } } return p.Commit(ctx) } // tenantRootSeam is a test seam on the tenant's ground: the install calls // it with "checked" after it has made the files directory and before it // opens it, and the flush and the install call it with "opened" after the // open and before they work from it. Those are the two points a path // swapped by the tenant would race a check-then-open. var tenantRootSeam = func(stage string) {} // syncTenantDir fsyncs dir, opened as OpenDirBeneath opens it: no symlink // at any component, never a FIFO. func syncTenantDir(dir string) error { f, err := instancefs.OpenDirBeneath(dir, ".") if err != nil { return err } defer f.Close() return f.Sync() } // readInstanceFile reads name in dir, an instance directory the tenant can // write, through main's one safe read for that ground: instancefs, the // package the activator's instance-key and adoption code and appconfig's // nested render read these very files through. It refuses a symlink at // any component of dir or name (openat2 RESOLVE_NO_SYMLINKS), opens // O_NONBLOCK so a FIFO cannot hang the agent, accepts only a regular file, // and never reads more than max bytes. Any of those refusals, and any other // failure to read, is an error. // // absent is true only on positive evidence, decided in one place // (instancefs.ReadBeneathLimitOrAbsent): dir opened once as a real // directory, and name was not found beneath that same descriptor. A // directory that is missing, or that vanishes between a check and a read, // is an error, never "no file". func readInstanceFile(dir, name string, max int64) (raw []byte, absent bool, err error) { return instancefs.ReadBeneathLimitOrAbsent(dir, name, max) } // releasePosition is one recorded release on disk: a versioned // ReleasePosition. type releasePosition struct { V int `json:"v"` ReleasePosition } // ReleasePositionsFile is ReleasePositions on disk: one file per operation // under Dir, named by the op id so no file under a managed root names a // host (region HA ruling 44), written whole and renamed into place, read // back against the exact stop request (whose host it names). The claim's ETag and the directory revision are not part // of the match: a target replacement moves both while the source's stop // stays the same stop. type ReleasePositionsFile struct { Dir string } func (f ReleasePositionsFile) path(r StopRequest) (string, error) { op := r.OperationID if f.Dir == "" || !controlname.ValidHostShape(r.Host) || !validOpID(op) { return "", ErrClaimStopUnavailable } return filepath.Join(f.Dir, op+".json"), nil } func (f ReleasePositionsFile) Record(_ context.Context, p ReleasePosition) error { path, err := f.path(p.Request) if err != nil { return err } if p.PID <= 0 || p.ProcessStart == "" { return ErrClaimStopUnavailable // a record that cannot name its process could not be told from a restart's } raw, err := json.Marshal(releasePosition{V: 1, ReleasePosition: p}) if err != nil { return err } // Moves' atomic write (moveslandinstall.go): a synced temporary in // the same directory, renamed into place, the directory synced, never // beside the journal sweep (writeRelocationJournal). return writeRelocationJournal(path, raw) } // sameStop is the installation and session, not the controller. func sameStop(a, b StopRequest) bool { return a.Host == b.Host && a.Incarnation == b.Incarnation && a.InstallationStamp == b.InstallationStamp && a.WriterSession == b.WriterSession && a.OperationID == b.OperationID && a.Phase == b.Phase && a.ObligationID == b.ObligationID && a.Box == b.Box && a.Enrollment == b.Enrollment } func (f ReleasePositionsFile) Recorded(_ context.Context, r StopRequest) (ReleasePosition, bool, error) { path, err := f.path(r) if err != nil { return ReleasePosition{}, false, err } raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return ReleasePosition{}, false, nil } if err != nil { return ReleasePosition{}, false, err } var p releasePosition if err := json.Unmarshal(raw, &p); err != nil || p.V != 1 || p.PID <= 0 || p.ProcessStart == "" { // A record without its process is damaged: it would read as this // process's own under claimLaunchOwner.local. return ReleasePosition{}, false, fmt.Errorf("%w: release positions for %s", ErrClaimStopReceipt, r.Host) } if !sameStop(p.Request, r) { return ReleasePosition{}, false, nil } return p.ReleasePosition, true, nil } // validOpID is the one check that an op id can name a file or directory // under the data root (region HA ruling 44): non-empty, not "." or "..", // no separator or NUL. Release positions and every relocation journal use // it. It is not moves.ValidOpID, which checks moves' own id shape // (mv_/rb_ prefixes) that relocation's hex ids (newClaimID) never have: // this is path safety, not identity. func validOpID(op string) bool { return op != "" && op != "." && op != ".." && !strings.ContainsAny(op, "/\\\x00") }