package activator import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ClaimLaunchSnapshot is read-only launch selection. The actual launch still // opens and hashes the executable under the captured work immediately before exec. // // Two readers take one. SnapshotClaimLaunch is the pure read: the claim's // input policy compares its Identity to a Work's, and nothing is launched, // so no gate runs and no bucket is read. SelectClaimLaunch is the launch // sources' read: the same snapshot, then the replica gate on the env and // the layer that read produced (ruling 29), with its fresh bucket read for // a configured key (ruling 27). type ClaimLaunchSnapshot struct { Identity hostwrite.Identity Executable, SHA256, Version string // Staged is the file SHA256 hashes, from one resolution of the live // link (release.ResolveLiveLink): the version it is staged under, its path // and that digest. Version is a separate read of the link; the claimed // start refuses when the two disagree (ClaimStart.Staged). Staged release.StagedIdentity Args, Env []string Roles []string Limit cgroup.Limit // InstanceKeySHA256 and InstanceKeySource name the CARLOS_INSTANCE_KEY // Env delivers (its digest) and the file that supplied it // (instancekey.SourcePlatform or SourceConfig); both empty when Env // delivers none. O6 hands them to the process's adoption verb. InstanceKeySHA256, InstanceKeySource string } // claimLaunchRoute is the route shape the claim launch path serves. func claimLaunchRoute(rt registry.Route) bool { return rt.Host != "" && rt.Socket != "" && filepath.IsAbs(rt.DBPath) && !rt.IsSidecar() && rt.Unit == "" && rt.Backing == "" && rt.Tunnel == "" } // EnsureClaimAdminToken is the claim path's half of what startChild and // seedChild do before reading the env: a first wake has no // CARLOS_ADMIN_TOKEN and no CARLOS_INSTANCE_KEY until something mints // them, and the claim path never runs the legacy launch preparation. A // token already present is left exactly as it is, so a retry never rotates // it. Only a missing token, or a missing key, is minted, and only inside // authorize, which must run the write under the claim's current serving // authority and refuse otherwise. SnapshotClaimLaunch stays read-only. func (b *ExecBackend) EnsureClaimAdminToken(ctx context.Context, rt registry.Route, authorize func(context.Context, func() error) error) error { if ctx.Err() != nil { return ctx.Err() } if !claimLaunchRoute(rt) { return hostwrite.ErrUnavailable } if err := b.claimLaunchShape(rt); err != nil { return err } vars, err := b.instanceEnv(rt) if err != nil { return err } // The instance key is initialized the same way and in the same // authorized write (L21). vars is the effective env, so a key the // rendered configuration supplies counts as present, and a replica // gets none (ruling 26). When both are present nothing is written and // authorize is not called, but the file is still made durable before // the launch source answers: a key whose mint failed its sync is never // launched with until a sync succeeds (settleClaimInstanceEnv, ruling // 18). A file that is not 0600 takes the guarded path. The key's // ensure sets the mode when the own file holds the key; when the key // is the configuration's, or the row is a replica, the ensure writes // nothing, the mode stays as it is, and the next launch of that row // asks the guard again. The token's mode has never been the claim // path's to tighten. if vars["CARLOS_ADMIN_TOKEN"] != "" && (rt.Replica || vars[InstanceKeyVar] != "") { err := b.settleClaimInstanceEnv(rt) switch { case err == nil: return nil case errors.Is(err, os.ErrNotExist): // No own env file: the token and the key both come from the // rendered configuration. A configured key is the app's and // is not the platform's to make durable (ruling 5), and an // absent file is not created here (ruling 7), so the launch // proceeds as it did before the key existed. return nil case !errors.Is(err, errInstanceEnvNeedsWrite): return err } } if authorize == nil { return hostwrite.ErrUnavailable } paths := b.pathsFor(rt) return authorize(ctx, func() error { // Only a missing token is minted: one the rendered configuration // supplies is the one the launch delivers, and a second one in the // own file would shadow it. if vars["CARLOS_ADMIN_TOKEN"] == "" { if err := ensureAdminTokenAt(paths, rt.Host+".env"); err != nil { return err } } // The one place the claim launch source names the host around the // key's error: the refusal itself does not, and every other launch // path wraps it the same way. if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil { return fmt.Errorf("instance key for %s: %w", rt.Host, err) } return nil }) } // SnapshotClaimLaunch is the read-only snapshot with no gate: what the // claim's input policy compares identities with. It reads the env files // and the live build; it never reads the bucket, and a replica row that // the launch would refuse still snapshots here, because the question is // "is the identity still this one", not "may this start". func (b *ExecBackend) SnapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) { out, _, _, err := b.snapshotClaimLaunch(ctx, rt) return out, err } // SelectClaimLaunch is the launch sources' snapshot: SnapshotClaimLaunch, // then the replica gate on the env and the layer of that same read // (ruling 29). A claim launch has no control socket until O6, so a replica // is selected only with its home's key, verified (ruling 26); the gate's // fresh bucket read for a configured key (ruling 27) runs here and only // here on the claim path. func (b *ExecBackend) SelectClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) { out, vars, layer, err := b.snapshotClaimLaunch(ctx, rt) if err != nil { return out, err } if err := b.replicaKeyGate(rt, vars, layer, false); err != nil { return ClaimLaunchSnapshot{}, err } return out, nil } // snapshotClaimLaunch composes the snapshot and hands back, from the one // read that composed its env, the env and the layer its key came from, for // SelectClaimLaunch's gate. func (b *ExecBackend) snapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, map[string]string, instancekey.Layer, error) { var out ClaimLaunchSnapshot if ctx.Err() != nil { return out, nil, instancekey.LayerNone, ctx.Err() } if !claimLaunchRoute(rt) { return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable } if err := b.claimLaunchShape(rt); err != nil { return out, nil, instancekey.LayerNone, err } cfgEnv, ownEnv, err := b.instanceEnvParts(rt) if err != nil { return out, nil, instancekey.LayerNone, err } vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept if vars["CARLOS_ADMIN_TOKEN"] == "" { return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable } // The key this launch delivers and the file that supplied it, from // the same read as vars: what the replica gate decides on (ruling 29) // and what the process's adoption report is checked against (ruling // 21, O6). Not in the identity: the env already is. key, layer := instancekey.Effective(cfgEnv, ownEnv) if key != "" { out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer) } out.Executable = b.liveLink(rt.Host) // One resolution of the live link names both the file hashed below and // the version it is staged under (settings spec ยง4.2, ruling // R-EXEC-CLAIMVERSION): opening the link itself would hash whatever it // named at that moment, with no way to say which build that was. out.Staged, err = release.ResolveLiveLink(b.OptDir, rt.Host) if err != nil { return out, nil, instancekey.LayerNone, err } f, err := os.Open(out.Staged.StagedPath) if err != nil { return out, nil, instancekey.LayerNone, err } info, err := f.Stat() if err != nil || !info.Mode().IsRegular() { f.Close() return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable } digest := sha256.New() _, err = io.Copy(digest, f) closeErr := f.Close() if err != nil { return out, nil, instancekey.LayerNone, err } if closeErr != nil { return out, nil, instancekey.LayerNone, closeErr } out.SHA256 = hex.EncodeToString(digest.Sum(nil)) out.Staged.Digest = out.SHA256 out.Version, err = b.LiveVersion(rt.Host) if err != nil || out.Version == "" { return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable } out.Roles, err = b.ClaimLaunchRoles(rt) if err != nil { return out, nil, instancekey.LayerNone, err } out.Args = []string{"--socket", rt.Socket, "--db", rt.DBPath} out.Env = childEnv(b.agentOwnedVars(rt, "", vars, false)) raw, err := json.Marshal(out.Env) if err != nil { return out, nil, instancekey.LayerNone, err } config := sha256.Sum256(raw) out.Identity = hostwrite.Identity{Config: hex.EncodeToString(config[:]), Release: out.SHA256} out.Limit = b.tier(rt) return out, vars, layer, nil }