package host import ( "context" "errors" "fmt" "path/filepath" "sync" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/layout" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootaws" "github.com/carlosframework/platform/internal/s3compat" ) // ClaimProductionFilesSource is the C8 files-manifest provider's production // source (platform-1e, 2026-10-12): registered under CARLOS_HOST_CLAIM // alone, apart from the moves owners, because Establish runs on every // claim-on serving wake and a claim-only box would otherwise refuse every // one (claimFilesRefusal). Open returns the provider for the verified box // actor. It grants no local process, roster or policy authority: the // provider sees only the observation, the launch inputs and the start's // captured work the serving owners hand it. type ClaimProductionFilesSource interface { Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor) (ClaimFilesManifest, error) Close(context.Context) error } var claimFilesSources struct { sync.Mutex Source ClaimProductionFilesSource } // RegisterProductionClaimFiles registers the C8 files source once at // process startup. A second registration, or a nil one, is refused: it can // neither replace a provider nor add a permissive fallback. func RegisterProductionClaimFiles(s ClaimProductionFilesSource) error { claimFilesSources.Lock() defer claimFilesSources.Unlock() if s == nil || claimFilesSources.Source != nil { return hostwrite.ErrUnavailable } claimFilesSources.Source = s return nil } func productionClaimFilesSource() ClaimProductionFilesSource { claimFilesSources.Lock() defer claimFilesSources.Unlock() return claimFilesSources.Source } // openClaimFilesManifest binds the registered source's provider into // out.Serving.Files with CARLOS_HOST_CLAIM on, moves on or off. With the // claim off, or nothing registered, out is unchanged and nothing is // opened. A provider the moves source already bound is a wiring error // (two providers), refused rather than replaced. The returned close is the // source's, or nil when nothing was opened. // // The provider reads the claim's data bucket as the serving and moves // owners do (whole-branch fixes, item 5): through hostbox.RequestStore // over data, so every version listing and exact-version read is bounded // by the claim's request limit, runs under the wake's own context, and is // preceded by check, the box's bound identity (claimBoundIdentity); a // refused identity issues no bucket call. Without a check the provider is // not opened. func 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) { if source == nil || getenv == nil || out == nil || !hostwrite.Enabled(getenv) { return nil, nil } if out.Serving.Files != nil { 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")) } if data == nil || check == nil { 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")) } files, err := source.Open(ctx, store, hostbox.RequestStore{Store: data, Check: check}, actor) if err != nil { return nil, err } if files == nil { return nil, errors.Join(hostwrite.ErrUnavailable, errors.New("host claim: the files source opened no provider")) } out.Serving.Files = files // The moves recovery owners establish through the same provider // (moves Task 8): one C8 provider per box, never a second. if err := bindClaimMovesFiles(out); err != nil { return nil, errors.Join(err, source.Close(ctx)) } return source.Close, nil } // movesFilesSource is the production ClaimProductionFilesSource: a // movesFiles over the verified actor's data bucket, read by exact version // through the claim's versioned store and written through a files store // bound to the bucket's cloud region. No pin reader is wired on this // branch (region HA's ReadPin is not on main), so Evidence is nil and a // host with no manifest anywhere in its chain refuses every serving wake // until it is. Routes is this box's registry: the env file Establish // carries is found through the host's row, as the launch source found it // (claimEnvFileFor). type movesFilesSource struct { DataDir string Routes claimRoutes newFiles func(ctx context.Context, bucket, region string) (release.Store, error) } // claimRoutes is the part of the box's registry the files provider reads: // a host's row, and every row for the layout's identifier check // (*registry.Registry). type claimRoutes interface { Get(host string) (*registry.Route, error) List() ([]registry.Route, error) } // movesFilesDirFor is the host's files directory and env file under // dataDir: / and /.env, the layout // moveCfg.FilesDir names. Moves moves only flat rows (routeRefusal's // s1Nested; the landing refuses a nested row at checking), so its own // paths stay flat; the C8 provider, which every claim-on wake runs, finds // the env file through the row instead (claimEnvFileFor). func movesFilesDirFor(dataDir string) func(host string) (string, string) { return func(host string) (string, string) { return filepath.Join(dataDir, host), filepath.Join(dataDir, host+".env") } } // claimEnvFile is where Establish reads a host's env file: the directory // and the name in it. Nested says the directory is the host's instance // directory, which the tenant can write, so the file is read only beneath // it (instancefs: no link at any component, a regular file, bounded) and // the adoption record removed the same way. type claimEnvFile struct { Dir, Name string Nested bool } // Path is the env file's path. func (e claimEnvFile) Path() string { return filepath.Join(e.Dir, e.Name) } // claimEnvFileFor resolves a host's env file as the launch source does // (activator's pathsFor, through activator.Classify with the exec // backend's own box facts: the layout journal and the peer rows): the // nested instance directory's .env for a nested row, the flat // /.env for a flat or foreign row, which the backend runs // as today's exec child with the flat names. The row must be the one the // wake selected (its database path, dbPath). Anything else refuses, never // a guess at the flat path: no row, a row that cannot be read, a row // whose database path moved, a layout migration in flight (fenced), an // identifier conflict. A stale flat file is therefore never read for a // nested instance. func claimEnvFileFor(dataDir string, routes claimRoutes) func(host, dbPath string) (claimEnvFile, error) { return func(host, dbPath string) (claimEnvFile, error) { if dataDir == "" || routes == nil || host == "" { return claimEnvFile{}, hostwrite.ErrUnavailable } rt, err := routes.Get(host) if err != nil { return claimEnvFile{}, fmt.Errorf("host: the row of %s, for its env file: %w", host, err) } if rt == nil || rt.Host != host { return claimEnvFile{}, fmt.Errorf("%w: %s has no row to find its env file by", hostbox.ErrChanged, host) } if dbPath == "" || rt.DBPath != dbPath { return claimEnvFile{}, fmt.Errorf("%w: the row of %s names the database %q, not the wake's %q", hostbox.ErrChanged, host, rt.DBPath, dbPath) } layoutFacts := activator.LayoutInputs{DataDir: dataDir, Journal: func(h string) bool { return layout.JournalPresent(dataDir, h) }, Peers: routes.List} switch l, reason := activator.Classify(*rt, layoutFacts); l { case activator.LayoutNested: return claimEnvFile{Dir: provision.InstanceDir(dataDir, host), Name: host + ".env", Nested: true}, nil case activator.LayoutFlat, activator.LayoutForeign: return claimEnvFile{Dir: dataDir, Name: host + ".env"}, nil default: return claimEnvFile{}, fmt.Errorf("%w: %s's layout is %s (%s); its env file is not read", hostbox.ErrChanged, host, l, reason) } } } func (s *movesFilesSource) Open(ctx context.Context, claims release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) { region := park.PlacementRegion(actor.Region) if s == nil || s.DataDir == "" || s.Routes == nil || s.newFiles == nil || claims == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" { return nil, hostwrite.ErrUnavailable } files, err := s.newFiles(ctx, actor.Bucket, actor.Cloud.Region) if err != nil { return nil, err } // The origin proof is claim Plan 15's, over the deployment store the // claims live in, with the home alias from the verified roster binding // (actor.HomeRegion) and this box's engine region. proof := ClaimOriginProof{Claims: claims, HomeRegion: actor.HomeRegion, Region: region} 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 } func (s *movesFilesSource) Close(context.Context) error { return nil } // newProductionFilesStore is the data bucket as a release.Store for the // files manifests: the S3 store (create-only and compare-and-swap Put) // over a client bound to the bucket's region, loaded through rootaws // (#267), with the options release.NewS3Store sets. func newProductionFilesStore(ctx context.Context, bucket, region string) (release.Store, error) { cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) if err != nil { return nil, err } client := awss3.NewFromConfig(cfg, s3compat.Options(), func(o *awss3.Options) { o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired }) return &release.S3Store{Client: client, Bucket: bucket}, nil } // registerProductionClaimFiles is the agent's registration, before // newClaimWiring: with CARLOS_HOST_CLAIM on, the production files source // over this box's data directory and registry. Off, it registers nothing // and the claim keeps its named refusal (claimFilesRefusal). func registerProductionClaimFiles(getenv func(string) string, dataDir string, reg *registry.Registry) error { if getenv == nil || !hostwrite.Enabled(getenv) { return nil } if dataDir == "" || reg == nil { return hostwrite.ErrUnavailable } return RegisterProductionClaimFiles(&movesFilesSource{DataDir: dataDir, Routes: reg, newFiles: newProductionFilesStore}) }