| 1 | package instancectl |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path" |
| 9 | "path/filepath" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/cgroup" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | ) |
| 17 | |
| 18 | // ClaimUnits runs a reserved main role through main's isolated run unit for |
| 19 | // the claim root launch protocol (claim Plan 11, R10). It is the same |
| 20 | // template, slice, tier and build view /start uses; what differs is that the |
| 21 | // unit's /opt/carlos/app source is the exact build root verified, not the |
| 22 | // host's live link, and that it refuses rather than joins a unit that is not |
| 23 | // idle. Root calls it only after validating the reservation under the host |
| 24 | // guard; it adds no request surface of its own. |
| 25 | type ClaimUnits struct { |
| 26 | BoundHost, BoundUnit string |
| 27 | BoundAddr string |
| 28 | Systemctl func(context.Context, ...string) (string, error) |
| 29 | CgroupRoot string |
| 30 | ExeRoot string |
| 31 | UnitRoot string |
| 32 | DataRoot string |
| 33 | // Room is the disk floor check; nil skips it, as a nil Statfs does. |
| 34 | Room func() error |
| 35 | WaitBudget time.Duration |
| 36 | Poll time.Duration |
| 37 | } |
| 38 | |
| 39 | // ClaimUnit is one start: the host, the verified build and the slice tier. |
| 40 | type ClaimUnit struct { |
| 41 | Host string |
| 42 | Executable string |
| 43 | MemoryMax int64 |
| 44 | } |
| 45 | |
| 46 | // ClaimUnitStarted is what systemd reported after the start. ControlGroup is |
| 47 | // relative to the cgroup root. |
| 48 | type ClaimUnitStarted struct { |
| 49 | Unit, InvocationID, ControlGroup string |
| 50 | MainPID int |
| 51 | } |
| 52 | |
| 53 | var ( |
| 54 | // ErrClaimUnitBusy refuses a start while the host's run unit is active, |
| 55 | // changing state, or its cgroup still holds a process. |
| 56 | ErrClaimUnitBusy = errors.New("instance control: the claim unit is not idle") |
| 57 | // ErrClaimUnit refuses a start whose request or outcome it cannot vouch for. |
| 58 | ErrClaimUnit = errors.New("instance control: the claim unit did not start as requested") |
| 59 | ) |
| 60 | |
| 61 | // ClaimUnitControlGroup is the host's run unit cgroup, relative to the |
| 62 | // cgroup root. |
| 63 | func ClaimUnitControlGroup(host string) string { |
| 64 | return path.Join(parentSlices, SliceName(host), UnitName(RoleRun, host)) |
| 65 | } |
| 66 | |
| 67 | func claimTier(n int64) (cgroup.Limit, bool) { |
| 68 | switch n { |
| 69 | case cgroup.TierDefault.MemoryMax, cgroup.TierChildren.MemoryMax: |
| 70 | return cgroup.Limit{MemoryMax: n}, true |
| 71 | } |
| 72 | return cgroup.Limit{}, false |
| 73 | } |
| 74 | |
| 75 | func (u ClaimUnits) budget() time.Duration { |
| 76 | if u.WaitBudget > 0 { |
| 77 | return u.WaitBudget |
| 78 | } |
| 79 | return 30 * time.Second |
| 80 | } |
| 81 | |
| 82 | func (u ClaimUnits) poll() time.Duration { |
| 83 | if u.Poll > 0 { |
| 84 | return u.Poll |
| 85 | } |
| 86 | return 50 * time.Millisecond |
| 87 | } |
| 88 | |
| 89 | func (u ClaimUnits) show(ctx context.Context, unit string, props ...string) (map[string]string, error) { |
| 90 | out, err := u.Systemctl(ctx, "show", "-p", strings.Join(props, ","), unit) |
| 91 | if err != nil { |
| 92 | return nil, fmt.Errorf("show %s: %w", unit, err) |
| 93 | } |
| 94 | return parseShow(out), nil |
| 95 | } |
| 96 | |
| 97 | // cgroupEmpty reads the unit's cgroup.events: an absent group is empty. |
| 98 | func (u ClaimUnits) cgroupEmpty(host string) error { |
| 99 | _, group, err := u.Placement(host) |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | events := filepath.Join(u.CgroupRoot, group, "cgroup.events") |
| 104 | b, err := os.ReadFile(events) |
| 105 | if errors.Is(err, os.ErrNotExist) { |
| 106 | return nil |
| 107 | } |
| 108 | if err != nil { |
| 109 | return err |
| 110 | } |
| 111 | if strings.Contains(string(b), "populated 0") { |
| 112 | return nil |
| 113 | } |
| 114 | return ErrClaimUnitBusy |
| 115 | } |
| 116 | |
| 117 | func idleState(state string) bool { return state == "inactive" || state == "failed" } |
| 118 | |
| 119 | // Start starts host's run unit on the verified build. It refuses unless the |
| 120 | // unit is idle, and stops any invocation it then cannot vouch for: one that |
| 121 | // systemd did not newly create, or that runs outside the host's run cgroup. |
| 122 | func (u ClaimUnits) Start(ctx context.Context, in ClaimUnit) (ClaimUnitStarted, error) { |
| 123 | return u.start(ctx, in, nil, nil, nil) |
| 124 | } |
| 125 | |
| 126 | // StartChecked runs the root check after all preparation, immediately before |
| 127 | // systemctl start. It binds root's verified input snapshot over the mutable |
| 128 | // tenant launch file. The ordinary Start path keeps its existing inputs. |
| 129 | func (u ClaimUnits) StartChecked(ctx context.Context, in ClaimUnit, launch Launch, check func() error) (ClaimUnitStarted, error) { |
| 130 | if check == nil { |
| 131 | return ClaimUnitStarted{}, ErrClaimUnit |
| 132 | } |
| 133 | return u.start(ctx, in, &launch, check, nil) |
| 134 | } |
| 135 | |
| 136 | func (u ClaimUnits) start(ctx context.Context, in ClaimUnit, launch *Launch, check func() error, permit *ClaimExecPermit) (ClaimUnitStarted, error) { |
| 137 | tier, ok := claimTier(in.MemoryMax) |
| 138 | if u.Systemctl == nil || u.CgroupRoot == "" || u.ExeRoot == "" || !ok || !provision.ValidHostShape(in.Host) || |
| 139 | !filepath.IsAbs(in.Executable) || filepath.Clean(in.Executable) != in.Executable || in.Executable == SystemBinary || strings.HasPrefix(in.Executable, LiveDir+"/") { |
| 140 | return ClaimUnitStarted{}, ErrClaimUnit |
| 141 | } |
| 142 | unit, group, err := u.Placement(in.Host) |
| 143 | if err != nil { |
| 144 | return ClaimUnitStarted{}, err |
| 145 | } |
| 146 | if u.BoundUnit != "" && (permit == nil || launch == nil || check == nil) { |
| 147 | return ClaimUnitStarted{}, ErrClaimUnit |
| 148 | } |
| 149 | before, err := u.show(ctx, unit, "ActiveState", "InvocationID") |
| 150 | if err != nil { |
| 151 | return ClaimUnitStarted{}, err |
| 152 | } |
| 153 | if !idleState(before["ActiveState"]) { |
| 154 | return ClaimUnitStarted{}, ErrClaimUnitBusy |
| 155 | } |
| 156 | if err := u.cgroupEmpty(in.Host); err != nil { |
| 157 | return ClaimUnitStarted{}, err |
| 158 | } |
| 159 | if u.Room != nil { |
| 160 | if err := u.Room(); err != nil { |
| 161 | return ClaimUnitStarted{}, err |
| 162 | } |
| 163 | } |
| 164 | if err := ensureExe(u.ExeRoot, provision.InstanceID(in.Host), in.Executable, in.Host); err != nil { |
| 165 | return ClaimUnitStarted{}, err |
| 166 | } |
| 167 | if err := sliceTier(ctx, u.Systemctl, in.Host, tier); err != nil { |
| 168 | return ClaimUnitStarted{}, err |
| 169 | } |
| 170 | var invocation string |
| 171 | refuse := func(cause error) (ClaimUnitStarted, error) { |
| 172 | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), u.budget()) |
| 173 | defer cancel() |
| 174 | if u.BoundUnit != "" { |
| 175 | return ClaimUnitStarted{}, errors.Join(cause, u.StopInvocation(cleanup, in.Host, invocation)) |
| 176 | } |
| 177 | return ClaimUnitStarted{}, errors.Join(cause, u.Stop(cleanup, in.Host)) |
| 178 | } |
| 179 | if launch != nil { |
| 180 | if err := u.freezeLaunch(ctx, in.Host, *launch); err != nil { |
| 181 | return ClaimUnitStarted{}, err |
| 182 | } |
| 183 | } |
| 184 | var gate *claimExecGate |
| 185 | if permit != nil { |
| 186 | gate, err = u.prepareExecGate(ctx, in.Host, *permit) |
| 187 | if err != nil { |
| 188 | return ClaimUnitStarted{}, errors.Join(err, u.clearLaunch(ctx, in.Host)) |
| 189 | } |
| 190 | defer gate.close() |
| 191 | } |
| 192 | if check != nil { |
| 193 | if err := check(); err != nil { |
| 194 | return ClaimUnitStarted{}, errors.Join(err, u.clearLaunch(ctx, in.Host)) |
| 195 | } |
| 196 | } |
| 197 | if out, err := u.Systemctl(ctx, "start", unit); err != nil { |
| 198 | return refuse(fmt.Errorf("start %s: %s: %w", unit, strings.TrimSpace(out), err)) |
| 199 | } |
| 200 | ready, cancel := context.WithTimeout(ctx, u.budget()) |
| 201 | defer cancel() |
| 202 | for { |
| 203 | after, err := u.show(ready, unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState") |
| 204 | if err != nil { |
| 205 | return refuse(err) |
| 206 | } |
| 207 | id := after["InvocationID"] |
| 208 | if idleState(after["ActiveState"]) || (id != "" && id == before["InvocationID"]) || (invocation != "" && id != invocation) { |
| 209 | return refuse(ErrClaimUnit) |
| 210 | } |
| 211 | if id != "" { |
| 212 | invocation = id |
| 213 | } |
| 214 | pid, _ := strconv.Atoi(after["MainPID"]) |
| 215 | if id != "" && pid > 0 && after["ActiveState"] == "active" { |
| 216 | if after["ControlGroup"] != "/"+group { |
| 217 | return refuse(ErrClaimUnit) |
| 218 | } |
| 219 | started := ClaimUnitStarted{Unit: unit, InvocationID: id, ControlGroup: group, MainPID: pid} |
| 220 | if gate != nil { |
| 221 | if err := gate.authorize(ready, u, &started, check); err != nil { |
| 222 | cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), u.budget()) |
| 223 | defer cancel() |
| 224 | if sameErr := u.Same(cleanup, started); sameErr != nil { |
| 225 | return ClaimUnitStarted{}, errors.Join(err, u.Gone(cleanup, in.Host, started.InvocationID)) |
| 226 | } |
| 227 | return refuse(err) |
| 228 | } |
| 229 | } |
| 230 | return started, nil |
| 231 | } |
| 232 | select { |
| 233 | case <-ready.Done(): |
| 234 | return refuse(ready.Err()) |
| 235 | case <-time.After(u.poll()): |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | } |
| 240 | |
| 241 | // Stop stops host's run unit and returns once systemd reports it inactive or |
| 242 | // failed and its cgroup is empty, bounded by ctx and WaitBudget. |
| 243 | func (u ClaimUnits) Stop(ctx context.Context, host string) error { |
| 244 | if u.Systemctl == nil || u.CgroupRoot == "" || !provision.ValidHostShape(host) { |
| 245 | return ErrClaimUnit |
| 246 | } |
| 247 | unit, _, err := u.Placement(host) |
| 248 | if err != nil { |
| 249 | return err |
| 250 | } |
| 251 | if out, err := u.Systemctl(ctx, "stop", unit); err != nil { |
| 252 | return fmt.Errorf("stop %s: %s: %w", unit, strings.TrimSpace(out), err) |
| 253 | } |
| 254 | ctx, cancel := context.WithTimeout(ctx, u.budget()) |
| 255 | defer cancel() |
| 256 | for { |
| 257 | state, err := u.show(ctx, unit, "ActiveState") |
| 258 | if err == nil && idleState(state["ActiveState"]) { |
| 259 | break |
| 260 | } |
| 261 | select { |
| 262 | case <-ctx.Done(): |
| 263 | return fmt.Errorf("%s: still %q: %w", unit, state["ActiveState"], ErrClaimUnitBusy) |
| 264 | case <-time.After(u.poll()): |
| 265 | } |
| 266 | } |
| 267 | if err := WaitCgroupEmpty(ctx, u.CgroupRoot, SliceName(host), unit, u.poll()); err != nil { |
| 268 | return err |
| 269 | } |
| 270 | return u.clearLaunch(ctx, host) |
| 271 | } |
| 272 | |
| 273 | // Gone proves an invocation of host's run unit has ended: systemd reports a |
| 274 | // different invocation, or the unit idle with an empty cgroup. An empty |
| 275 | // invocation asks only the second question. |
| 276 | func (u ClaimUnits) Gone(ctx context.Context, host, invocation string) error { |
| 277 | if u.Systemctl == nil || u.CgroupRoot == "" || !provision.ValidHostShape(host) { |
| 278 | return ErrClaimUnit |
| 279 | } |
| 280 | unit, _, err := u.Placement(host) |
| 281 | if err != nil { |
| 282 | return err |
| 283 | } |
| 284 | state, err := u.show(ctx, unit, "ActiveState", "InvocationID") |
| 285 | if err != nil { |
| 286 | return err |
| 287 | } |
| 288 | if u.BoundUnit == "" && invocation != "" && state["InvocationID"] != "" && state["InvocationID"] != invocation { |
| 289 | return nil |
| 290 | } |
| 291 | if !idleState(state["ActiveState"]) { |
| 292 | return ErrClaimUnitBusy |
| 293 | } |
| 294 | if err := u.cgroupEmpty(host); err != nil { |
| 295 | return err |
| 296 | } |
| 297 | return u.clearLaunch(ctx, host) |
| 298 | } |
| 299 | |
| 300 | // DiskRoom is the start floor /start applies (#214 item 1), for the claim |
| 301 | // launch: statfs of dataRoot, refused below DiskFloor. |
| 302 | func DiskRoom(dataRoot string) func() error { |
| 303 | return func() error { |
| 304 | avail, total, err := statfsBytes(dataRoot) |
| 305 | if err != nil { |
| 306 | return fmt.Errorf("statfs %s: %w", dataRoot, err) |
| 307 | } |
| 308 | if floor := DiskFloor(total); avail < floor { |
| 309 | return fmt.Errorf("%w: %s free, below the %s floor", ErrDiskFull, humanBytes(avail), humanBytes(floor)) |
| 310 | } |
| 311 | return nil |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // Same refuses replacement invocations and PID changes throughout readiness. |
| 316 | func (u ClaimUnits) Same(ctx context.Context, s ClaimUnitStarted) error { |
| 317 | if u.Systemctl == nil || s.Unit == "" || s.InvocationID == "" || s.MainPID <= 0 { |
| 318 | return ErrClaimUnit |
| 319 | } |
| 320 | if u.BoundUnit != "" { |
| 321 | unit, group, err := u.Placement(u.BoundHost) |
| 322 | if err != nil || s.Unit != unit || s.ControlGroup != group { |
| 323 | return ErrClaimUnit |
| 324 | } |
| 325 | } |
| 326 | state, err := u.show(ctx, s.Unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState") |
| 327 | if err != nil { |
| 328 | return err |
| 329 | } |
| 330 | if state["InvocationID"] != s.InvocationID || state["MainPID"] != strconv.Itoa(s.MainPID) || state["ControlGroup"] != "/"+s.ControlGroup || state["ActiveState"] != "active" { |
| 331 | return ErrClaimUnit |
| 332 | } |
| 333 | return nil |
| 334 | } |
| 335 | |