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 "errors"
6 "fmt"
7 "path/filepath"
8 "sync"
9
10 "github.com/aws/aws-sdk-go-v2/aws"
11 awsconfig "github.com/aws/aws-sdk-go-v2/config"
12 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
13 "github.com/carlosframework/platform/internal/activator"
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostwrite"
16 "github.com/carlosframework/platform/internal/layout"
17 "github.com/carlosframework/platform/internal/lease"
18 "github.com/carlosframework/platform/internal/park"
19 "github.com/carlosframework/platform/internal/provision"
20 "github.com/carlosframework/platform/internal/registry"
21 "github.com/carlosframework/platform/internal/release"
22 "github.com/carlosframework/platform/internal/rootaws"
23 "github.com/carlosframework/platform/internal/s3compat"
24)
25
26// ClaimProductionFilesSource is the C8 files-manifest provider's production
27// source (platform-1e, 2026-10-12): registered under CARLOS_HOST_CLAIM
28// alone, apart from the moves owners, because Establish runs on every
29// claim-on serving wake and a claim-only box would otherwise refuse every
30// one (claimFilesRefusal). Open returns the provider for the verified box
31// actor. It grants no local process, roster or policy authority: the
32// provider sees only the observation, the launch inputs and the start's
33// captured work the serving owners hand it.
34type ClaimProductionFilesSource interface {
35 Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor) (ClaimFilesManifest, error)
36 Close(context.Context) error
37}
38
39var claimFilesSources struct {
40 sync.Mutex
41 Source ClaimProductionFilesSource
42}
43
44// RegisterProductionClaimFiles registers the C8 files source once at
45// process startup. A second registration, or a nil one, is refused: it can
46// neither replace a provider nor add a permissive fallback.
47func RegisterProductionClaimFiles(s ClaimProductionFilesSource) error {
48 claimFilesSources.Lock()
49 defer claimFilesSources.Unlock()
50 if s == nil || claimFilesSources.Source != nil {
51 return hostwrite.ErrUnavailable
52 }
53 claimFilesSources.Source = s
54 return nil
55}
56
57func productionClaimFilesSource() ClaimProductionFilesSource {
58 claimFilesSources.Lock()
59 defer claimFilesSources.Unlock()
60 return claimFilesSources.Source
61}
62
63// openClaimFilesManifest binds the registered source's provider into
64// out.Serving.Files with CARLOS_HOST_CLAIM on, moves on or off. With the
65// claim off, or nothing registered, out is unchanged and nothing is
66// opened. A provider the moves source already bound is a wiring error
67// (two providers), refused rather than replaced. The returned close is the
68// source's, or nil when nothing was opened.
69//
70// The provider reads the claim's data bucket as the serving and moves
71// owners do (whole-branch fixes, item 5): through hostbox.RequestStore
72// over data, so every version listing and exact-version read is bounded
73// by the claim's request limit, runs under the wake's own context, and is
74// preceded by check, the box's bound identity (claimBoundIdentity); a
75// refused identity issues no bucket call. Without a check the provider is
76// not opened.
77func openClaimFilesManifest(ctx context.Context, getenv func(string) string, source ClaimProductionFilesSource, store release.Store, data lease.VersionedStore, actor hostbox.Actor, out *ClaimOwnerBindings, check func(context.Context) error) (func(context.Context) error, error) {
78 if source == nil || getenv == nil || out == nil || !hostwrite.Enabled(getenv) {
79 return nil, nil
80 }
81 if out.Serving.Files != nil {
82 return nil, errors.Join(hostwrite.ErrUnavailable, errors.New("host claim: two C8 files manifest providers: the moves source bound one and a files source is registered"))
83 }
84 if data == nil || check == nil {
85 return nil, errors.Join(hostwrite.ErrUnavailable, errors.New("host claim: the C8 files manifest provider needs the claim's data bucket and the box's identity check"))
86 }
87 files, err := source.Open(ctx, store, hostbox.RequestStore{Store: data, Check: check}, actor)
88 if err != nil {
89 return nil, err
90 }
91 if files == nil {
92 return nil, errors.Join(hostwrite.ErrUnavailable, errors.New("host claim: the files source opened no provider"))
93 }
94 out.Serving.Files = files
95 // The moves recovery owners establish through the same provider
96 // (moves Task 8): one C8 provider per box, never a second.
97 if err := bindClaimMovesFiles(out); err != nil {
98 return nil, errors.Join(err, source.Close(ctx))
99 }
100 return source.Close, nil
101}
102
103// movesFilesSource is the production ClaimProductionFilesSource: a
104// movesFiles over the verified actor's data bucket, read by exact version
105// through the claim's versioned store and written through a files store
106// bound to the bucket's cloud region. No pin reader is wired on this
107// branch (region HA's ReadPin is not on main), so Evidence is nil and a
108// host with no manifest anywhere in its chain refuses every serving wake
109// until it is. Routes is this box's registry: the env file Establish
110// carries is found through the host's row, as the launch source found it
111// (claimEnvFileFor).
112type movesFilesSource struct {
113 DataDir string
114 Routes claimRoutes
115 newFiles func(ctx context.Context, bucket, region string) (release.Store, error)
116}
117
118// claimRoutes is the part of the box's registry the files provider reads:
119// a host's row, and every row for the layout's identifier check
120// (*registry.Registry).
121type claimRoutes interface {
122 Get(host string) (*registry.Route, error)
123 List() ([]registry.Route, error)
124}
125
126// movesFilesDirFor is the host's files directory and env file under
127// dataDir: <dataDir>/<host> and <dataDir>/<host>.env, the layout
128// moveCfg.FilesDir names. Moves moves only flat rows (routeRefusal's
129// s1Nested; the landing refuses a nested row at checking), so its own
130// paths stay flat; the C8 provider, which every claim-on wake runs, finds
131// the env file through the row instead (claimEnvFileFor).
132func movesFilesDirFor(dataDir string) func(host string) (string, string) {
133 return func(host string) (string, string) {
134 return filepath.Join(dataDir, host), filepath.Join(dataDir, host+".env")
135 }
136}
137
138// claimEnvFile is where Establish reads a host's env file: the directory
139// and the name in it. Nested says the directory is the host's instance
140// directory, which the tenant can write, so the file is read only beneath
141// it (instancefs: no link at any component, a regular file, bounded) and
142// the adoption record removed the same way.
143type claimEnvFile struct {
144 Dir, Name string
145 Nested bool
146}
147
148// Path is the env file's path.
149func (e claimEnvFile) Path() string { return filepath.Join(e.Dir, e.Name) }
150
151// claimEnvFileFor resolves a host's env file as the launch source does
152// (activator's pathsFor, through activator.Classify with the exec
153// backend's own box facts: the layout journal and the peer rows): the
154// nested instance directory's <host>.env for a nested row, the flat
155// <dataDir>/<host>.env for a flat or foreign row, which the backend runs
156// as today's exec child with the flat names. The row must be the one the
157// wake selected (its database path, dbPath). Anything else refuses, never
158// a guess at the flat path: no row, a row that cannot be read, a row
159// whose database path moved, a layout migration in flight (fenced), an
160// identifier conflict. A stale flat file is therefore never read for a
161// nested instance.
162func claimEnvFileFor(dataDir string, routes claimRoutes) func(host, dbPath string) (claimEnvFile, error) {
163 return func(host, dbPath string) (claimEnvFile, error) {
164 if dataDir == "" || routes == nil || host == "" {
165 return claimEnvFile{}, hostwrite.ErrUnavailable
166 }
167 rt, err := routes.Get(host)
168 if err != nil {
169 return claimEnvFile{}, fmt.Errorf("host: the row of %s, for its env file: %w", host, err)
170 }
171 if rt == nil || rt.Host != host {
172 return claimEnvFile{}, fmt.Errorf("%w: %s has no row to find its env file by", hostbox.ErrChanged, host)
173 }
174 if dbPath == "" || rt.DBPath != dbPath {
175 return claimEnvFile{}, fmt.Errorf("%w: the row of %s names the database %q, not the wake's %q", hostbox.ErrChanged, host, rt.DBPath, dbPath)
176 }
177 layoutFacts := activator.LayoutInputs{DataDir: dataDir, Journal: func(h string) bool { return layout.JournalPresent(dataDir, h) }, Peers: routes.List}
178 switch l, reason := activator.Classify(*rt, layoutFacts); l {
179 case activator.LayoutNested:
180 return claimEnvFile{Dir: provision.InstanceDir(dataDir, host), Name: host + ".env", Nested: true}, nil
181 case activator.LayoutFlat, activator.LayoutForeign:
182 return claimEnvFile{Dir: dataDir, Name: host + ".env"}, nil
183 default:
184 return claimEnvFile{}, fmt.Errorf("%w: %s's layout is %s (%s); its env file is not read", hostbox.ErrChanged, host, l, reason)
185 }
186 }
187}
188
189func (s *movesFilesSource) Open(ctx context.Context, claims release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) {
190 region := park.PlacementRegion(actor.Region)
191 if s == nil || s.DataDir == "" || s.Routes == nil || s.newFiles == nil || claims == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" {
192 return nil, hostwrite.ErrUnavailable
193 }
194 files, err := s.newFiles(ctx, actor.Bucket, actor.Cloud.Region)
195 if err != nil {
196 return nil, err
197 }
198 // The origin proof is claim Plan 15's, over the deployment store the
199 // claims live in, with the home alias from the verified roster binding
200 // (actor.HomeRegion) and this box's engine region.
201 proof := ClaimOriginProof{Claims: claims, HomeRegion: actor.HomeRegion, Region: region}
202 return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, EnvFile: claimEnvFileFor(s.DataDir, s.Routes), Proof: proof}, nil
203}
204
205func (s *movesFilesSource) Close(context.Context) error { return nil }
206
207// newProductionFilesStore is the data bucket as a release.Store for the
208// files manifests: the S3 store (create-only and compare-and-swap Put)
209// over a client bound to the bucket's region, loaded through rootaws
210// (#267), with the options release.NewS3Store sets.
211func newProductionFilesStore(ctx context.Context, bucket, region string) (release.Store, error) {
212 cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
213 if err != nil {
214 return nil, err
215 }
216 client := awss3.NewFromConfig(cfg, s3compat.Options(), func(o *awss3.Options) {
217 o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
218 o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
219 })
220 return &release.S3Store{Client: client, Bucket: bucket}, nil
221}
222
223// registerProductionClaimFiles is the agent's registration, before
224// newClaimWiring: with CARLOS_HOST_CLAIM on, the production files source
225// over this box's data directory and registry. Off, it registers nothing
226// and the claim keeps its named refusal (claimFilesRefusal).
227func registerProductionClaimFiles(getenv func(string) string, dataDir string, reg *registry.Registry) error {
228 if getenv == nil || !hostwrite.Enabled(getenv) {
229 return nil
230 }
231 if dataDir == "" || reg == nil {
232 return hostwrite.ErrUnavailable
233 }
234 return RegisterProductionClaimFiles(&movesFilesSource{DataDir: dataDir, Routes: reg, newFiles: newProductionFilesStore})
235}
236