carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
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.
29type 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.
48func (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.
142var tenantRootSeam = func(stage string) {}
143
144// syncTenantDir fsyncs dir, opened as OpenDirBeneath opens it: no symlink
145// at any component, never a FIFO.
146func 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".
169func 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.
175type 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.
186type ReleasePositionsFile struct {
187 Dir string
188}
189
190func (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
198func (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.
217func 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
222func (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.
252func validOpID(op string) bool {
253 return op != "" && op != "." && op != ".." && !strings.ContainsAny(op, "/\\\x00")
254}
255