| 1 | package activator |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/cgroup" |
| 15 | "github.com/carlosframework/platform/internal/hostwrite" |
| 16 | "github.com/carlosframework/platform/internal/instancekey" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // ClaimLaunchSnapshot is read-only launch selection. The actual launch still |
| 22 | // opens and hashes the executable under the captured work immediately before exec. |
| 23 | // |
| 24 | // Two readers take one. SnapshotClaimLaunch is the pure read: the claim's |
| 25 | // input policy compares its Identity to a Work's, and nothing is launched, |
| 26 | // so no gate runs and no bucket is read. SelectClaimLaunch is the launch |
| 27 | // sources' read: the same snapshot, then the replica gate on the env and |
| 28 | // the layer that read produced (ruling 29), with its fresh bucket read for |
| 29 | // a configured key (ruling 27). |
| 30 | type ClaimLaunchSnapshot struct { |
| 31 | Identity hostwrite.Identity |
| 32 | Executable, SHA256, Version string |
| 33 | // Staged is the file SHA256 hashes, from one resolution of the live |
| 34 | // link (release.ResolveLiveLink): the version it is staged under, its path |
| 35 | // and that digest. Version is a separate read of the link; the claimed |
| 36 | // start refuses when the two disagree (ClaimStart.Staged). |
| 37 | Staged release.StagedIdentity |
| 38 | Args, Env []string |
| 39 | Roles []string |
| 40 | Limit cgroup.Limit |
| 41 | // InstanceKeySHA256 and InstanceKeySource name the CARLOS_INSTANCE_KEY |
| 42 | // Env delivers (its digest) and the file that supplied it |
| 43 | // (instancekey.SourcePlatform or SourceConfig); both empty when Env |
| 44 | // delivers none. O6 hands them to the process's adoption verb. |
| 45 | InstanceKeySHA256, InstanceKeySource string |
| 46 | } |
| 47 | |
| 48 | // claimLaunchRoute is the route shape the claim launch path serves. |
| 49 | func claimLaunchRoute(rt registry.Route) bool { |
| 50 | return rt.Host != "" && rt.Socket != "" && filepath.IsAbs(rt.DBPath) && !rt.IsSidecar() && rt.Unit == "" && rt.Backing == "" && rt.Tunnel == "" |
| 51 | } |
| 52 | |
| 53 | // EnsureClaimAdminToken is the claim path's half of what startChild and |
| 54 | // seedChild do before reading the env: a first wake has no |
| 55 | // CARLOS_ADMIN_TOKEN and no CARLOS_INSTANCE_KEY until something mints |
| 56 | // them, and the claim path never runs the legacy launch preparation. A |
| 57 | // token already present is left exactly as it is, so a retry never rotates |
| 58 | // it. Only a missing token, or a missing key, is minted, and only inside |
| 59 | // authorize, which must run the write under the claim's current serving |
| 60 | // authority and refuse otherwise. SnapshotClaimLaunch stays read-only. |
| 61 | func (b *ExecBackend) EnsureClaimAdminToken(ctx context.Context, rt registry.Route, authorize func(context.Context, func() error) error) error { |
| 62 | if ctx.Err() != nil { |
| 63 | return ctx.Err() |
| 64 | } |
| 65 | if !claimLaunchRoute(rt) { |
| 66 | return hostwrite.ErrUnavailable |
| 67 | } |
| 68 | if err := b.claimLaunchShape(rt); err != nil { |
| 69 | return err |
| 70 | } |
| 71 | vars, err := b.instanceEnv(rt) |
| 72 | if err != nil { |
| 73 | return err |
| 74 | } |
| 75 | // The instance key is initialized the same way and in the same |
| 76 | // authorized write (L21). vars is the effective env, so a key the |
| 77 | // rendered configuration supplies counts as present, and a replica |
| 78 | // gets none (ruling 26). When both are present nothing is written and |
| 79 | // authorize is not called, but the file is still made durable before |
| 80 | // the launch source answers: a key whose mint failed its sync is never |
| 81 | // launched with until a sync succeeds (settleClaimInstanceEnv, ruling |
| 82 | // 18). A file that is not 0600 takes the guarded path. The key's |
| 83 | // ensure sets the mode when the own file holds the key; when the key |
| 84 | // is the configuration's, or the row is a replica, the ensure writes |
| 85 | // nothing, the mode stays as it is, and the next launch of that row |
| 86 | // asks the guard again. The token's mode has never been the claim |
| 87 | // path's to tighten. |
| 88 | if vars["CARLOS_ADMIN_TOKEN"] != "" && (rt.Replica || vars[InstanceKeyVar] != "") { |
| 89 | err := b.settleClaimInstanceEnv(rt) |
| 90 | switch { |
| 91 | case err == nil: |
| 92 | return nil |
| 93 | case errors.Is(err, os.ErrNotExist): |
| 94 | // No own env file: the token and the key both come from the |
| 95 | // rendered configuration. A configured key is the app's and |
| 96 | // is not the platform's to make durable (ruling 5), and an |
| 97 | // absent file is not created here (ruling 7), so the launch |
| 98 | // proceeds as it did before the key existed. |
| 99 | return nil |
| 100 | case !errors.Is(err, errInstanceEnvNeedsWrite): |
| 101 | return err |
| 102 | } |
| 103 | } |
| 104 | if authorize == nil { |
| 105 | return hostwrite.ErrUnavailable |
| 106 | } |
| 107 | paths := b.pathsFor(rt) |
| 108 | return authorize(ctx, func() error { |
| 109 | // Only a missing token is minted: one the rendered configuration |
| 110 | // supplies is the one the launch delivers, and a second one in the |
| 111 | // own file would shadow it. |
| 112 | if vars["CARLOS_ADMIN_TOKEN"] == "" { |
| 113 | if err := ensureAdminTokenAt(paths, rt.Host+".env"); err != nil { |
| 114 | return err |
| 115 | } |
| 116 | } |
| 117 | // The one place the claim launch source names the host around the |
| 118 | // key's error: the refusal itself does not, and every other launch |
| 119 | // path wraps it the same way. |
| 120 | if _, err := b.ensureInstanceKeyFor(ctx, rt, paths); err != nil { |
| 121 | return fmt.Errorf("instance key for %s: %w", rt.Host, err) |
| 122 | } |
| 123 | return nil |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | // SnapshotClaimLaunch is the read-only snapshot with no gate: what the |
| 128 | // claim's input policy compares identities with. It reads the env files |
| 129 | // and the live build; it never reads the bucket, and a replica row that |
| 130 | // the launch would refuse still snapshots here, because the question is |
| 131 | // "is the identity still this one", not "may this start". |
| 132 | func (b *ExecBackend) SnapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) { |
| 133 | out, _, _, err := b.snapshotClaimLaunch(ctx, rt) |
| 134 | return out, err |
| 135 | } |
| 136 | |
| 137 | // SelectClaimLaunch is the launch sources' snapshot: SnapshotClaimLaunch, |
| 138 | // then the replica gate on the env and the layer of that same read |
| 139 | // (ruling 29). A claim launch has no control socket until O6, so a replica |
| 140 | // is selected only with its home's key, verified (ruling 26); the gate's |
| 141 | // fresh bucket read for a configured key (ruling 27) runs here and only |
| 142 | // here on the claim path. |
| 143 | func (b *ExecBackend) SelectClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) { |
| 144 | out, vars, layer, err := b.snapshotClaimLaunch(ctx, rt) |
| 145 | if err != nil { |
| 146 | return out, err |
| 147 | } |
| 148 | if err := b.replicaKeyGate(rt, vars, layer, false); err != nil { |
| 149 | return ClaimLaunchSnapshot{}, err |
| 150 | } |
| 151 | return out, nil |
| 152 | } |
| 153 | |
| 154 | // snapshotClaimLaunch composes the snapshot and hands back, from the one |
| 155 | // read that composed its env, the env and the layer its key came from, for |
| 156 | // SelectClaimLaunch's gate. |
| 157 | func (b *ExecBackend) snapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, map[string]string, instancekey.Layer, error) { |
| 158 | var out ClaimLaunchSnapshot |
| 159 | if ctx.Err() != nil { |
| 160 | return out, nil, instancekey.LayerNone, ctx.Err() |
| 161 | } |
| 162 | if !claimLaunchRoute(rt) { |
| 163 | return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable |
| 164 | } |
| 165 | if err := b.claimLaunchShape(rt); err != nil { |
| 166 | return out, nil, instancekey.LayerNone, err |
| 167 | } |
| 168 | cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| 169 | if err != nil { |
| 170 | return out, nil, instancekey.LayerNone, err |
| 171 | } |
| 172 | vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept |
| 173 | if vars["CARLOS_ADMIN_TOKEN"] == "" { |
| 174 | return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable |
| 175 | } |
| 176 | // The key this launch delivers and the file that supplied it, from |
| 177 | // the same read as vars: what the replica gate decides on (ruling 29) |
| 178 | // and what the process's adoption report is checked against (ruling |
| 179 | // 21, O6). Not in the identity: the env already is. |
| 180 | key, layer := instancekey.Effective(cfgEnv, ownEnv) |
| 181 | if key != "" { |
| 182 | out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer) |
| 183 | } |
| 184 | out.Executable = b.liveLink(rt.Host) |
| 185 | // One resolution of the live link names both the file hashed below and |
| 186 | // the version it is staged under (settings spec §4.2, ruling |
| 187 | // R-EXEC-CLAIMVERSION): opening the link itself would hash whatever it |
| 188 | // named at that moment, with no way to say which build that was. |
| 189 | out.Staged, err = release.ResolveLiveLink(b.OptDir, rt.Host) |
| 190 | if err != nil { |
| 191 | return out, nil, instancekey.LayerNone, err |
| 192 | } |
| 193 | f, err := os.Open(out.Staged.StagedPath) |
| 194 | if err != nil { |
| 195 | return out, nil, instancekey.LayerNone, err |
| 196 | } |
| 197 | info, err := f.Stat() |
| 198 | if err != nil || !info.Mode().IsRegular() { |
| 199 | f.Close() |
| 200 | return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable |
| 201 | } |
| 202 | digest := sha256.New() |
| 203 | _, err = io.Copy(digest, f) |
| 204 | closeErr := f.Close() |
| 205 | if err != nil { |
| 206 | return out, nil, instancekey.LayerNone, err |
| 207 | } |
| 208 | if closeErr != nil { |
| 209 | return out, nil, instancekey.LayerNone, closeErr |
| 210 | } |
| 211 | out.SHA256 = hex.EncodeToString(digest.Sum(nil)) |
| 212 | out.Staged.Digest = out.SHA256 |
| 213 | out.Version, err = b.LiveVersion(rt.Host) |
| 214 | if err != nil || out.Version == "" { |
| 215 | return out, nil, instancekey.LayerNone, hostwrite.ErrUnavailable |
| 216 | } |
| 217 | out.Roles, err = b.ClaimLaunchRoles(rt) |
| 218 | if err != nil { |
| 219 | return out, nil, instancekey.LayerNone, err |
| 220 | } |
| 221 | out.Args = []string{"--socket", rt.Socket, "--db", rt.DBPath} |
| 222 | out.Env = childEnv(b.agentOwnedVars(rt, "", vars, false)) |
| 223 | raw, err := json.Marshal(out.Env) |
| 224 | if err != nil { |
| 225 | return out, nil, instancekey.LayerNone, err |
| 226 | } |
| 227 | config := sha256.Sum256(raw) |
| 228 | out.Identity = hostwrite.Identity{Config: hex.EncodeToString(config[:]), Release: out.SHA256} |
| 229 | out.Limit = b.tier(rt) |
| 230 | return out, vars, layer, nil |
| 231 | } |
| 232 | |