| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/controlname" |
| 13 | "github.com/carlosframework/platform/internal/hostclaim" |
| 14 | "github.com/carlosframework/platform/internal/hostfiles" |
| 15 | "github.com/carlosframework/platform/internal/instancefs" |
| 16 | "github.com/carlosframework/platform/internal/instancekey" |
| 17 | "github.com/carlosframework/platform/internal/moves" |
| 18 | "github.com/carlosframework/platform/internal/registry" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | // ReleaseFlushFiles is release step 3 (§5.8, §5.6 "Release flush"): one |
| 23 | // complete publish of the host's files directory, its wake state (timer |
| 24 | // rows, NextDue, Replication) and its env file into the session's |
| 25 | // generation, as one manifest revision, whatever the app's |
| 26 | // files_durability. hostfiles.Publish leaves platform items out, so this |
| 27 | // is the Open, StagePlatform, SyncDir, Commit sequence the contracts note |
| 28 | // §2 names. It runs after StopWriters, so nothing changes under it. |
| 29 | type ReleaseFlushFiles struct { |
| 30 | Data release.Store |
| 31 | Registry *registry.Registry |
| 32 | FilesDir func(host string) string |
| 33 | EnvPath func(host string) string |
| 34 | // Skip is the files publisher's own exclusion (nil publishes every |
| 35 | // regular file). |
| 36 | Skip func(rel string, isDir bool) bool |
| 37 | } |
| 38 | |
| 39 | // Flush publishes and returns the committed manifest sequence. The env |
| 40 | // file is staged when it exists; when it does not, its deletion is staged |
| 41 | // (StagePlatformDelete), so a host whose env file was removed after an |
| 42 | // earlier flush publishes none rather than the old bytes. "Does not |
| 43 | // exist" is positive evidence only (readInstanceFile): a file that cannot |
| 44 | // be read safely, or a directory that cannot be opened, refuses. A |
| 45 | // manifest revision is committed even when nothing changed (Commit |
| 46 | // returns the current sequence), so the release's manifest_seq is always |
| 47 | // the sequence that holds this box's final state. |
| 48 | func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (int64, error) { |
| 49 | if f.Data == nil || f.Registry == nil || f.FilesDir == nil || f.EnvPath == nil || obs.Claim == nil || obs.Claim.Holder == nil { |
| 50 | return 0, ErrClaimStopUnavailable |
| 51 | } |
| 52 | h := obs.Claim.Holder |
| 53 | if h.Generation == "" || h.Location == "" || !controlname.ValidHostShape(obs.Host) { |
| 54 | return 0, ErrClaimStopUnavailable |
| 55 | } |
| 56 | // No paths is no flush, refused before anything is staged: the host |
| 57 | // has no route (relocationHostPaths), or its route could not be read. |
| 58 | envPath, filesDir := f.EnvPath(obs.Host), f.FilesDir(obs.Host) |
| 59 | if envPath == "" || filesDir == "" { |
| 60 | return 0, fmt.Errorf("%w: no files directory or env file for %s", ErrClaimStopUnavailable, obs.Host) |
| 61 | } |
| 62 | p, err := hostfiles.Open(ctx, f.Data, moves.GenerationFilesPrefix(h.Location, h.Generation)) |
| 63 | if err != nil { |
| 64 | return 0, err |
| 65 | } |
| 66 | ws, err := hostfiles.ExportWake(f.Registry, obs.Host) |
| 67 | if err != nil { |
| 68 | return 0, err |
| 69 | } |
| 70 | root, err := instancefs.OpenDirBeneath(filepath.Dir(filesDir), filepath.Base(filesDir)) |
| 71 | if err != nil { |
| 72 | return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) |
| 73 | } |
| 74 | defer root.Close() |
| 75 | tenantRootSeam("opened") |
| 76 | st, err := hostfiles.Seed(ctx, p, root, ws, f.Skip) |
| 77 | if err != nil { |
| 78 | if len(st.Unreadable) > 0 || len(st.Retry) > 0 || len(st.Torn) > 0 { |
| 79 | return 0, fmt.Errorf("hostfiles: release flush of %s: unreadable %q replaced %q torn %q", obs.Host, st.Unreadable, st.Retry, st.Torn) |
| 80 | } |
| 81 | return 0, err |
| 82 | } |
| 83 | |
| 84 | // The env file and the adoption record live in the instance directory, |
| 85 | // which the tenant can write: both are read through main's one safe |
| 86 | // read for that ground (readInstanceFile), never os.ReadFile. |
| 87 | envDir, envName := filepath.Dir(envPath), filepath.Base(envPath) |
| 88 | env, absent, err := readInstanceFile(envDir, envName, instancekey.EnvReadLimit) |
| 89 | switch { |
| 90 | case err != nil: |
| 91 | return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) |
| 92 | case absent: |
| 93 | // The env file is gone: the release's final state has none, so a |
| 94 | // previously published one is deleted, never carried forward. |
| 95 | if err := p.StagePlatformDelete(hostfiles.EnvItem); err != nil { |
| 96 | return 0, err |
| 97 | } |
| 98 | default: |
| 99 | if err := p.StagePlatform(ctx, hostfiles.EnvItem, env); err != nil { |
| 100 | return 0, err |
| 101 | } |
| 102 | } |
| 103 | // O5 (instance-key plan rev 3): the adoption record beside the env |
| 104 | // file is staged in the same commit when it binds exactly the |
| 105 | // releasing writer. A record of another writer (an earlier session or |
| 106 | // generation), or none, has its deletion staged, so it is never |
| 107 | // published as this writer's. A record that is present but does not |
| 108 | // parse refuses the flush: it may be this writer's, and absence is |
| 109 | // never evidence (fix round 1, M9). |
| 110 | writer := instancekey.Binding{Host: obs.Host, Incarnation: h.Incarnation, Session: h.Session, Generation: h.Generation} |
| 111 | rec, absent, err := readInstanceFile(envDir, obs.Host+instancekey.AdoptionSuffix, instancekey.AdoptionReadLimit) |
| 112 | switch { |
| 113 | case err != nil: |
| 114 | return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) |
| 115 | case absent: |
| 116 | if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil { |
| 117 | return 0, err |
| 118 | } |
| 119 | default: |
| 120 | a, err := instancekey.ParseAdoption(rec) |
| 121 | if err != nil { |
| 122 | return 0, fmt.Errorf("%w: release flush of %s: %w", ErrClaimStopUnavailable, obs.Host, err) |
| 123 | } |
| 124 | if a.Binds(writer) { |
| 125 | err = p.StagePlatform(ctx, instancekey.AdoptionItem, rec) |
| 126 | } else { |
| 127 | err = p.StagePlatformDelete(instancekey.AdoptionItem) |
| 128 | } |
| 129 | if err != nil { |
| 130 | return 0, err |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | return p.Commit(ctx) |
| 135 | } |
| 136 | |
| 137 | // tenantRootSeam is a test seam on the tenant's ground: the install calls |
| 138 | // it with "checked" after it has made the files directory and before it |
| 139 | // opens it, and the flush and the install call it with "opened" after the |
| 140 | // open and before they work from it. Those are the two points a path |
| 141 | // swapped by the tenant would race a check-then-open. |
| 142 | var tenantRootSeam = func(stage string) {} |
| 143 | |
| 144 | // syncTenantDir fsyncs dir, opened as OpenDirBeneath opens it: no symlink |
| 145 | // at any component, never a FIFO. |
| 146 | func syncTenantDir(dir string) error { |
| 147 | f, err := instancefs.OpenDirBeneath(dir, ".") |
| 148 | if err != nil { |
| 149 | return err |
| 150 | } |
| 151 | defer f.Close() |
| 152 | return f.Sync() |
| 153 | } |
| 154 | |
| 155 | // readInstanceFile reads name in dir, an instance directory the tenant can |
| 156 | // write, through main's one safe read for that ground: instancefs, the |
| 157 | // package the activator's instance-key and adoption code and appconfig's |
| 158 | // nested render read these very files through. It refuses a symlink at |
| 159 | // any component of dir or name (openat2 RESOLVE_NO_SYMLINKS), opens |
| 160 | // O_NONBLOCK so a FIFO cannot hang the agent, accepts only a regular file, |
| 161 | // and never reads more than max bytes. Any of those refusals, and any other |
| 162 | // failure to read, is an error. |
| 163 | // |
| 164 | // absent is true only on positive evidence, decided in one place |
| 165 | // (instancefs.ReadBeneathLimitOrAbsent): dir opened once as a real |
| 166 | // directory, and name was not found beneath that same descriptor. A |
| 167 | // directory that is missing, or that vanishes between a check and a read, |
| 168 | // is an error, never "no file". |
| 169 | func readInstanceFile(dir, name string, max int64) (raw []byte, absent bool, err error) { |
| 170 | return instancefs.ReadBeneathLimitOrAbsent(dir, name, max) |
| 171 | } |
| 172 | |
| 173 | // releasePosition is one recorded release on disk: a versioned |
| 174 | // ReleasePosition. |
| 175 | type releasePosition struct { |
| 176 | V int `json:"v"` |
| 177 | ReleasePosition |
| 178 | } |
| 179 | |
| 180 | // ReleasePositionsFile is ReleasePositions on disk: one file per operation |
| 181 | // under Dir, named by the op id so no file under a managed root names a |
| 182 | // host (region HA ruling 44), written whole and renamed into place, read |
| 183 | // back against the exact stop request (whose host it names). The claim's ETag and the directory revision are not part |
| 184 | // of the match: a target replacement moves both while the source's stop |
| 185 | // stays the same stop. |
| 186 | type ReleasePositionsFile struct { |
| 187 | Dir string |
| 188 | } |
| 189 | |
| 190 | func (f ReleasePositionsFile) path(r StopRequest) (string, error) { |
| 191 | op := r.OperationID |
| 192 | if f.Dir == "" || !controlname.ValidHostShape(r.Host) || !validOpID(op) { |
| 193 | return "", ErrClaimStopUnavailable |
| 194 | } |
| 195 | return filepath.Join(f.Dir, op+".json"), nil |
| 196 | } |
| 197 | |
| 198 | func (f ReleasePositionsFile) Record(_ context.Context, p ReleasePosition) error { |
| 199 | path, err := f.path(p.Request) |
| 200 | if err != nil { |
| 201 | return err |
| 202 | } |
| 203 | if p.PID <= 0 || p.ProcessStart == "" { |
| 204 | return ErrClaimStopUnavailable // a record that cannot name its process could not be told from a restart's |
| 205 | } |
| 206 | raw, err := json.Marshal(releasePosition{V: 1, ReleasePosition: p}) |
| 207 | if err != nil { |
| 208 | return err |
| 209 | } |
| 210 | // Moves' atomic write (moveslandinstall.go): a synced temporary in |
| 211 | // the same directory, renamed into place, the directory synced, never |
| 212 | // beside the journal sweep (writeRelocationJournal). |
| 213 | return writeRelocationJournal(path, raw) |
| 214 | } |
| 215 | |
| 216 | // sameStop is the installation and session, not the controller. |
| 217 | func sameStop(a, b StopRequest) bool { |
| 218 | return a.Host == b.Host && a.Incarnation == b.Incarnation && a.InstallationStamp == b.InstallationStamp && a.WriterSession == b.WriterSession && |
| 219 | a.OperationID == b.OperationID && a.Phase == b.Phase && a.ObligationID == b.ObligationID && a.Box == b.Box && a.Enrollment == b.Enrollment |
| 220 | } |
| 221 | |
| 222 | func (f ReleasePositionsFile) Recorded(_ context.Context, r StopRequest) (ReleasePosition, bool, error) { |
| 223 | path, err := f.path(r) |
| 224 | if err != nil { |
| 225 | return ReleasePosition{}, false, err |
| 226 | } |
| 227 | raw, err := os.ReadFile(path) |
| 228 | if errors.Is(err, os.ErrNotExist) { |
| 229 | return ReleasePosition{}, false, nil |
| 230 | } |
| 231 | if err != nil { |
| 232 | return ReleasePosition{}, false, err |
| 233 | } |
| 234 | var p releasePosition |
| 235 | if err := json.Unmarshal(raw, &p); err != nil || p.V != 1 || p.PID <= 0 || p.ProcessStart == "" { |
| 236 | // A record without its process is damaged: it would read as this |
| 237 | // process's own under claimLaunchOwner.local. |
| 238 | return ReleasePosition{}, false, fmt.Errorf("%w: release positions for %s", ErrClaimStopReceipt, r.Host) |
| 239 | } |
| 240 | if !sameStop(p.Request, r) { |
| 241 | return ReleasePosition{}, false, nil |
| 242 | } |
| 243 | return p.ReleasePosition, true, nil |
| 244 | } |
| 245 | |
| 246 | // validOpID is the one check that an op id can name a file or directory |
| 247 | // under the data root (region HA ruling 44): non-empty, not "." or "..", |
| 248 | // no separator or NUL. Release positions and every relocation journal use |
| 249 | // it. It is not moves.ValidOpID, which checks moves' own id shape |
| 250 | // (mv_/rb_ prefixes) that relocation's hex ids (newClaimID) never have: |
| 251 | // this is path safety, not identity. |
| 252 | func validOpID(op string) bool { |
| 253 | return op != "" && op != "." && op != ".." && !strings.ContainsAny(op, "/\\\x00") |
| 254 | } |
| 255 | |