//go:build linux package main // ops_plan_linux.go — a machine running its lines of a signed ship plan // (ops_plan.go), and undoing one. // // op_plan_line [receipts_b64] [sealed credential ...] // op_plan_undo // // A line runs as an action op's apply (ops_verbs_linux.go) with the plan as // its approval: no change list is bound (the digest a build produces cannot // be known when the person approves), and the verbs' own rules still hold — // a tree only forward, an image only from a receipt of this plan and only // forward from what this machine rolled, unless the line adopts the live // image. Every run, success or not, ends in a line receipt signed with this // machine's key: a failed one is what a machine needs to undo an earlier line. // // A line's attempts run one at a time, each only after the one before it // failed, and never after one passed: the machine keeps one record per line // (planRunRecord), written before an attempt starts and before each of its // verbs runs, and that record — not the pod — says which attempt is next. // // An undo takes a line back to the state THIS machine recorded before the // line's FIRST attempt, and nothing else: the tree to its old commit, the // image to its old reference, each unit the line restarted restarted again. // It needs a failed check of a LATER line of the same plan on that line's // last allowed attempt, or the failure of this line's own latest attempt when // none of its attempts passed — a receipt signed by the key the plan names // for that line's machine — and it is spent once. import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) const planRunsDirName = "plan_runs" // A line's state on this machine. Only a failed line runs again; passed and // undone are final. const ( planStateRunning = "running" planStatePassed = "passed" planStateFailed = "failed" planStateUndone = "undone" ) // planKeepAfterExpiry: how long a line's record and receipts outlive their // plan, for op_plan_status (an expired plan runs and undoes nothing). const planKeepAfterExpiry = 7 * 24 * time.Hour // planRunRecord is what this machine keeps of a line: which attempt is the // latest and how it ended, and every verb any attempt ran, for its undo. type planRunRecord struct { Plan string `json:"plan"` N int `json:"n"` At string `json:"at"` Expires string `json:"expires"` Attempt int `json:"attempt"` // the latest attempt started here State string `json:"state"` // The bastion process running the attempt (planProcAt): an attempt whose // process is gone was stopped by a restart, and is settled as failed. Proc string `json:"proc,omitempty"` // The receipt an attempt stopped by a restart is settled with. Receipt lineReceipt `json:"receipt"` // Every verb any attempt ran, in the order first run, each with the state // it was in before the line's first attempt touched it and the state the // latest attempt was to leave it in. Verbs []opVerbLine `json:"verbs"` Froms []string `json:"froms"` Tos []string `json:"tos"` } // planLineMu orders the reads and writes of the line records. var planLineMu sync.Mutex // planProc names this bastion process: the boot, the pid, and its start // time, so a pid reused later, or after a reboot, is another name. The pid is // the one /proc shows (read from /proc/self), so the name and the check that // the process is alive read the same /proc even in another pid namespace. var planProc, _ = planProcAt("self") // planProcRoot is /proc (a test points it elsewhere). var planProcRoot = "/proc" // planProcAt names a process as planProc does. Only a process that is not // there returns an error errors.Is(os.ErrNotExist); any other failure to read // is not an answer. func planProcAt(which string) (string, error) { boot, err := os.ReadFile(filepath.Join(planProcRoot, "sys/kernel/random/boot_id")) if err != nil { return "", fmt.Errorf("the boot id does not read: %v", err) } stat, err := os.ReadFile(filepath.Join(planProcRoot, which, "stat")) if err != nil { return "", err } // The pid, then the command in parentheses (which may hold anything), // then the fields after it: the start time is field 22, the 20th after. s := string(stat) i := strings.LastIndexByte(s, ')') j := strings.IndexByte(s, ' ') f := []string{} if i >= 0 { f = strings.Fields(s[i+1:]) } if i < 0 || j < 0 || j > i || len(f) < 20 { return "", errors.New("the process's stat does not read") } return strings.TrimSpace(string(boot)) + ":" + s[:j] + ":" + f[19], nil } // planProcGone: the process an attempt was running in is known to be gone — // not there, or another process under its pid. A /proc that cannot be read // is not known, and never settles an attempt. func planProcGone(id string) bool { parts := strings.Split(id, ":") if len(parts) != 3 { return true // not a name planProcAt makes: no process runs under it } if _, err := strconv.Atoi(parts[1]); err != nil { return true } now, err := planProcAt(parts[1]) if errors.Is(err, os.ErrNotExist) { return true } return err == nil && now != id } func planRunPath(nonce string, n int) (string, error) { d, err := configDir() if err != nil { return "", err } dir := filepath.Join(d, planRunsDirName) if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } if !printableASCII(nonce) || strings.ContainsAny(nonce, "/\\.") { return "", errors.New("a plan nonce that cannot name a file") } return filepath.Join(dir, nonce+"-"+strconv.Itoa(n)+".json"), nil } // planDecodeArgs reads the plan envelope and the line number. func planDecodeArgs(args []string) (*shipPlan, string, int, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[0])) if err != nil { return nil, "", 0, errors.New("the plan does not decode") } var env shipPlanEnvelope if err := json.Unmarshal(raw, &env); err != nil { return nil, "", 0, errors.New("the plan is not an envelope") } p, sha, err := planOpen(env) if err != nil { return nil, "", 0, err } n, err := strconv.Atoi(strings.TrimSpace(args[1])) if err != nil { return nil, "", 0, errors.New("the line is not a number") } return p, sha, n, nil } func planDecodeReceipts(b64 string) ([]opSignedReceipt, error) { if strings.TrimSpace(b64) == "" { return nil, nil } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { return nil, errors.New("the receipts do not decode") } var out []opSignedReceipt if err := json.Unmarshal(raw, &out); err != nil { return nil, errors.New("the receipts are not a list") } return out, nil } func handlePlanLine(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 2 { opFail(ws, "op_plan_line: need [receipts_b64] [sealed credential ...]") return } recB64 := "" if len(args) > 2 { recB64 = args[2] } var creds []string if len(args) > 3 { creds = args[3:] } out, err := planRunLine(dir, args[0], args[1], recB64, creds) if err != nil { opFail(ws, "op_plan_line refused: "+err.Error()) return } b, _ := json.Marshal(out) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // planRunLine checks a line and runs it. A refusal before the run returns an // error (nothing ran, nothing is spent unless the spend itself was reached); // a run that ran returns its signed receipt, successful or not. func planRunLine(dir, planB64, nArg, recB64 string, credBlobs []string) (map[string]any, error) { p, sha, n, err := planDecodeArgs([]string{planB64, nArg}) if err != nil { return nil, err } l, err := planLineFor(p, n) if err != nil { return nil, err } receipts, err := planDecodeReceipts(recB64) if err != nil { return nil, err } params, steps, err := planInputs(p, sha, l, receipts) if err != nil { return nil, err } m, err := opLoad(dir, l.OpSHA256) if err != nil { return nil, fmt.Errorf("this machine does not hold the op %s@%s (op_sync it first): %v", l.Op, truncate(l.OpSHA256, 12), err) } if err := planLineMatchesOp(l, m, params); err != nil { return nil, err } if _, err := opOpenCreds(credBlobs, m, p.Nonce, false); err != nil { return nil, err } if ok, why := opActionAvailable(); !ok { return nil, errors.New("this machine cannot run an action op: " + why) } if err := planRecordVersion(p.Pipeline, p.Version); err != nil { return nil, fmt.Errorf("could not record the plan's version: %v", err) } hn, _ := os.Hostname() lr := lineReceipt{Plan: p.Nonce, PlanSHA256: sha, Pipeline: p.Pipeline, Commit: p.Commit, N: n, Step: l.Step, Machine: hn, OpSHA256: l.OpSHA256, ParamsSHA256: planParamsSHA(params)} // One attempt at a time, the next only after the last failed, and spent // before it runs, as an approval is. attempt, err := planBeginAttempt(p, l, lr) if err != nil { return nil, err } lr.Attempt = attempt id, _ := newID() t := &opTerms{Kind: opKindRun, Op: m.Name, Version: m.Version, SHA256: l.OpSHA256, Privilege: m.Privilege, Params: params, Nonce: p.Nonce, Uses: 1} pc := &opPlanCtx{Commit: p.Commit, AdoptLive: l.AdoptLive, Steps: steps, beforeVerb: func(v opVerbLine, from, to string) error { return planRecordVerb(p.Nonce, n, attempt, v, from, to) }} rep, runErr := opRunAction(opStoreDir(dir, l.OpSHA256), m, t, opModeApply, id, credBlobs, pc) lr.At, lr.OK = time.Now().UTC().Format(time.RFC3339), runErr == nil if runErr != nil { lr.Error = truncate(runErr.Error(), 500) } if rep != nil { lr.VerifyError, lr.Verbs, lr.Checks = rep.VerifyError, rep.Verbs, rep.Checks for _, s := range rep.Receipts { if r, err := opVerifyReceipt(s); err == nil { lr.Images = append(lr.Images, opResultImage{Image: r.Image, Digest: r.Digest, Source: r.Source, Commit: r.Commit}) } } } signed, err := lineReceiptSign(lr) if err != nil { // Ended as failed with no receipt: nothing can retry or undo it on // the strength of a receipt that does not exist. _ = planEndAttempt(p.Nonce, n, attempt, false) return nil, fmt.Errorf("the line ran and its receipt could not be signed: %v", err) } // The receipt is kept before the attempt is ended, so a record that says // an attempt ended always has its receipt beside it. A record that could // not be ended stays running: nothing runs or undoes the line after it // until this bastion restarts and settles it from the kept receipt. keepErr := planKeepReceipt(p.Nonce, n, signed) endErr := planEndAttempt(p.Nonce, n, attempt, !lr.failed()) out := map[string]any{"receipt": signed, "ok": lr.OK, "error": lr.Error, "verify_error": lr.VerifyError} if keepErr != nil || endErr != nil { out["keep_error"] = fmt.Sprint(errors.Join(keepErr, endErr)) } if rep != nil { out["report"] = rep } dcnAudit(dir, "op_plan_line", 0, p.Pipeline+"#"+strconv.Itoa(n)+" "+l.Op, fmt.Sprintf("ok=%v attempt=%d plan=%s", lr.OK, attempt, truncate(p.Nonce, 12))) return out, nil } func planParamsSHA(params string) string { sum := sha256.Sum256([]byte(params)) return hex.EncodeToString(sum[:]) } // ── the line record ───────────────────────────────────────────────────────── // planLoadRun reads a line's record: nil when the line never started here. func planLoadRun(nonce string, n int) (*planRunRecord, error) { path, err := planRunPath(nonce, n) if err != nil { return nil, err } raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("could not read this machine's record of line %d: %v", n, err) } var rec planRunRecord if err := json.Unmarshal(raw, &rec); err != nil || rec.Plan != nonce || rec.N != n { return nil, fmt.Errorf("this machine's record of line %d does not read", n) } return &rec, nil } func planSaveRun(rec *planRunRecord) error { path, err := planRunPath(rec.Plan, rec.N) if err != nil { return err } rec.At = time.Now().UTC().Format(time.RFC3339) raw, _ := json.MarshalIndent(rec, "", " ") return atomicWrite(path, raw) } // planBeginAttempt starts the line's next attempt: the first, or the one // after an attempt that FAILED here. It refuses while an attempt is running, // once one passed, and once the line was undone, and records the attempt as // running before anything of it runs; a record it cannot write refuses. func planBeginAttempt(p *shipPlan, l *shipPlanLine, tmpl lineReceipt) (int, error) { planLineMu.Lock() defer planLineMu.Unlock() if planProc == "" { return 0, errors.New("this machine cannot name its own process, so it cannot keep a line's attempts apart") } rec, err := planLoadRun(p.Nonce, l.N) if err != nil { return 0, err } next := 1 if rec != nil { if rec.State == planStateRunning && planProcGone(rec.Proc) { if err := planSettleLocked(rec); err != nil { return 0, err } } switch rec.State { case planStateRunning: return 0, fmt.Errorf("attempt %d of line %d is running here; the next waits for its receipt", rec.Attempt, l.N) case planStatePassed: return 0, fmt.Errorf("line %d of this plan already succeeded here; it is not run again", l.N) case planStateUndone: return 0, fmt.Errorf("line %d of this plan was undone here; it is not run again", l.N) case planStateFailed: next = rec.Attempt + 1 default: return 0, fmt.Errorf("this machine's record of line %d does not read", l.N) } } else { rec = &planRunRecord{Plan: p.Nonce, N: l.N, Expires: p.Expires} } k, err := planSpendAttempt(p, l) if err != nil { return 0, err } if k != next { return 0, fmt.Errorf("line %d: its record here says attempt %d is next and its spent attempts say %d; refused rather than run out of order", l.N, next, k) } tmpl.Attempt = k rec.Attempt, rec.State, rec.Proc, rec.Receipt = k, planStateRunning, planProc, tmpl if err := planSaveRun(rec); err != nil { return 0, fmt.Errorf("could not record attempt %d of line %d before it runs (%v); refused rather than run unrecorded", k, l.N, err) } return k, nil } // planRecordVerb adds a verb the running attempt is about to run to the // line's record. A verb an earlier attempt already ran keeps the state it // started in before the first attempt, so an undo goes back to before the // line, not to where a failed attempt left it. func planRecordVerb(nonce string, n, attempt int, v opVerbLine, from, to string) error { planLineMu.Lock() defer planLineMu.Unlock() rec, err := planLoadRun(nonce, n) if err != nil { return err } if rec == nil || rec.Attempt != attempt || rec.State != planStateRunning || rec.Proc != planProc { return fmt.Errorf("the record of line %d is not this attempt's", n) } for i, had := range rec.Verbs { if had.Verb == v.Verb && had.target() == v.target() { // Where an earlier attempt left it, or where it was before: a // target in neither state was moved by something else between // the attempts, and an undo to before the first attempt would // take that change back too. Refused before it runs. if from != rec.Froms[i] && from != rec.Tos[i] { return fmt.Errorf("it is at %s, which is neither where it was before this line's first attempt nor where that attempt was taking it: something else moved it since, and it is left alone", truncate(from, 90)) } rec.Verbs[i], rec.Tos[i] = v, to return planSaveRun(rec) } } rec.Verbs, rec.Froms, rec.Tos = append(rec.Verbs, v), append(rec.Froms, from), append(rec.Tos, to) return planSaveRun(rec) } // planEndAttempt records how the running attempt ended. func planEndAttempt(nonce string, n, attempt int, passed bool) error { planLineMu.Lock() defer planLineMu.Unlock() rec, err := planLoadRun(nonce, n) if err != nil { return err } if rec == nil || rec.Attempt != attempt || rec.State != planStateRunning || rec.Proc != planProc { return fmt.Errorf("the record of line %d is not this attempt's", n) } rec.State, rec.Proc = planStateFailed, "" if passed { rec.State = planStatePassed } return planSaveRun(rec) } // planSettleLocked ends an attempt whose bastion process is gone (the op // unit went with it, opStartupSweep). If its receipt was kept, that is how it // ended; if not, it failed, and a receipt saying so is signed and kept, so // the pod can read it (op_plan_status) and retry or undo on it. func planSettleLocked(rec *planRunRecord) error { if r := planKeptReceipt(rec.Plan, rec.N, rec.Attempt); r != nil { rec.State = planStateFailed if !r.failed() { rec.State = planStatePassed } } else { lr := rec.Receipt lr.Attempt, lr.OK, lr.At = rec.Attempt, false, time.Now().UTC().Format(time.RFC3339) lr.Error = fmt.Sprintf("attempt %d stopped when the bastion running it did", rec.Attempt) signed, err := lineReceiptSign(lr) if err != nil { return fmt.Errorf("could not sign the receipt of line %d's stopped attempt: %v", rec.N, err) } if err := planKeepReceipt(rec.Plan, rec.N, signed); err != nil { return fmt.Errorf("could not keep the receipt of line %d's stopped attempt: %v", rec.N, err) } rec.State = planStateFailed } rec.Proc = "" return planSaveRun(rec) } // planSettleStopped runs at startup (opStartupSweep, after the op units are // gone): every attempt the last process died in is settled, and a line whose // plan expired more than planKeepAfterExpiry ago is let go with its receipts. func planSettleStopped() { d, err := configDir() if err != nil { return } dir := filepath.Join(d, planRunsDirName) entries, _ := os.ReadDir(dir) planLineMu.Lock() defer planLineMu.Unlock() for _, e := range entries { name := e.Name() if !strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".receipts.json") { continue } raw, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { continue } var rec planRunRecord if json.Unmarshal(raw, &rec) != nil { continue } if exp, err := time.Parse(time.RFC3339, rec.Expires); err == nil && time.Since(exp) > planKeepAfterExpiry { _ = os.Remove(filepath.Join(dir, name)) _ = os.Remove(filepath.Join(dir, strings.TrimSuffix(name, ".json")+".receipts.json")) continue } if rec.State == planStateRunning && planProcGone(rec.Proc) { if err := planSettleLocked(&rec); err != nil { fmt.Fprintf(os.Stderr, "privatae-bastion: ship plan %s line %d: %v\n", truncate(rec.Plan, 12), rec.N, err) } } } } func handlePlanUndo(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 3 { opFail(ws, "op_plan_undo: need ") return } out, err := planUndoLine(args[0], args[1], args[2]) if err != nil { opFail(ws, "op_plan_undo refused: "+err.Error()) return } b, _ := json.Marshal(out) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // planUndoLine takes line n back to what this machine recorded before it ran. func planUndoLine(planB64, nArg, failedB64 string) (map[string]any, error) { p, sha, n, err := planDecodeArgs([]string{planB64, nArg}) if err != nil { return nil, err } l, err := planLineFor(p, n) if err != nil { return nil, err } if !l.Undo { return nil, fmt.Errorf("line %d is not one the plan lets be undone", n) } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(failedB64)) if err != nil { return nil, errors.New("the failed receipt does not decode") } var s opSignedReceipt if err := json.Unmarshal(raw, &s); err != nil { return nil, errors.New("the failed receipt is not a receipt") } fr, err := lineReceiptVerify(s, p, sha) if err != nil { return nil, err } // What may start an undo: a later line whose CHECK failed (its apply ran, // its verify or log check did not pass — the machines' own verdict, not // something a pod can cause by handing a bad credential) on the last // attempt the plan allows it, so no retry of it can still pass; or the // line's own failure, which takes back only what that line itself did. later := fr.N > n && fr.OK && fr.VerifyError != "" own := fr.N == n && fr.failed() if fr.Undo || !(later || own) { return nil, fmt.Errorf("an undo of line %d needs a signed receipt of a LATER line of this plan whose check failed, or of line %d's own failure", n, n) } if later { if last := planLineAttempts(p, fr.N); fr.Attempt != last { return nil, fmt.Errorf("line %d's check failed on attempt %d of the %d the plan allows it; only its last attempt's failure undoes an earlier line, since a retry may still pass", fr.N, fr.Attempt, last) } } planLineMu.Lock() rec, err := planLoadRun(p.Nonce, n) if err == nil && rec == nil { err = fmt.Errorf("this machine has no record of running line %d, so there is nothing of it to undo", n) } if err == nil && rec.State == planStateRunning && planProcGone(rec.Proc) { err = planSettleLocked(rec) } if err == nil { err = planUndoAllowed(rec, fr, later) } if err == nil { err = planSpend(p, strconv.Itoa(n)+":undo") } if err == nil { // Undone before the undo runs, so no attempt of the line starts // under it; an undo that cannot be recorded does not run. prev := rec.State rec.State = planStateUndone if err = planSaveRun(rec); err != nil { rec.State = prev err = fmt.Errorf("could not record the undo of line %d before it runs (%v); refused", n, err) } } planLineMu.Unlock() if err != nil { return nil, err } hn, _ := os.Hostname() ur := lineReceipt{Plan: p.Nonce, PlanSHA256: sha, Pipeline: p.Pipeline, Commit: p.Commit, N: n, Step: l.Step, Machine: hn, OpSHA256: l.OpSHA256, At: time.Now().UTC().Format(time.RFC3339), Undo: true, OK: true} ctx := context.Background() // The state first, newest first (the tree, the image back where they // were), and only then each unit the line restarted, in its order, so it // starts again on what was put back — never on the failed code. order := []int{} for i := len(rec.Verbs) - 1; i >= 0; i-- { if !strings.HasPrefix(rec.Verbs[i].Verb, "unit.") { order = append(order, i) } } for i := range rec.Verbs { if strings.HasPrefix(rec.Verbs[i].Verb, "unit.") { order = append(order, i) } } for _, i := range order { v := rec.Verbs[i] note, err := planUndoVerb(ctx, v, rec.Froms[i], rec.Tos[i]) ur.Verbs = append(ur.Verbs, opVerbResult{Verb: v.Verb, Target: v.target(), OK: err == nil, Note: note}) if err != nil { ur.OK, ur.Error = false, truncate(v.Verb+" "+v.target()+": "+err.Error(), 500) break } } signed, err := lineReceiptSign(ur) if err != nil { return nil, fmt.Errorf("the undo ran and its receipt could not be signed: %v", err) } out := map[string]any{"receipt": signed, "ok": ur.OK, "error": ur.Error} if err := planKeepReceipt(p.Nonce, n, signed); err != nil { out["keep_error"] = err.Error() } return out, nil } // planLineAttempts is how many times the plan lets line n run. func planLineAttempts(p *shipPlan, n int) int { for i := range p.Lines { if p.Lines[i].N == n && p.Lines[i].Attempts > 1 { return p.Lines[i].Attempts } } return 1 } // planUndoAllowed checks the receipt against what this machine recorded of // the line: a later line's failure undoes a line that passed here; the // line's own failure must be its LATEST attempt's, and none may have passed. func planUndoAllowed(rec *planRunRecord, fr *lineReceipt, later bool) error { switch rec.State { case planStateRunning: return fmt.Errorf("attempt %d of line %d is running here; an undo waits for its receipt", rec.Attempt, rec.N) case planStateUndone: return fmt.Errorf("line %d of this plan was undone here already", rec.N) } if later { if rec.State != planStatePassed { return fmt.Errorf("line %d did not pass here, so no later line ran on it", rec.N) } return nil } if rec.State == planStatePassed { return fmt.Errorf("an attempt of line %d passed here; an earlier attempt's failure does not undo it", rec.N) } if fr.Attempt != rec.Attempt { return fmt.Errorf("that receipt is of attempt %d of line %d, and its latest attempt here is %d: only the latest attempt's failure undoes the line", fr.Attempt, rec.N, rec.Attempt) } return nil } // planUndoVerb takes one verb back from `to` to `from`, and only from `to`: // a tree or image that moved since the line ran is someone else's change, and // is left alone. func planUndoVerb(ctx context.Context, v opVerbLine, from, to string) (string, error) { switch v.Verb { case "tree.checkout": defer opLockTarget(v)() path := v.Args["path"] head, branch, err := opTreeState(v) if err != nil { return "", err } if head == from { return "already at " + truncate(from, 12), nil } if head != to { return "", fmt.Errorf("the tree is at %s, not the %s the line left it at: it moved since, and is left alone", truncate(head, 12), truncate(to, 12)) } if err := opTreeWriteDirs(path, head, from); err != nil { return "", err } // Never over a file the machine keeps: a path the move back writes // that is there now, untracked and ignored, stops it (reset --keep, // unlike merge, has no --no-overwrite-ignore). // The names are matched as they are, never as patterns. names, err := opTreeGitStdout(path, "diff", "--name-only", "--no-renames", "-z", head, from) if err != nil { return "", errors.New("could not read which files moving back writes: " + err.Error()) } var paths []string for _, n := range strings.Split(names, "\x00") { if n != "" { paths = append(paths, n) } } if len(paths) > 0 { kept, err := opTreeGitStdout(path, append([]string{"--literal-pathspecs", "ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--"}, paths...)...) if err != nil { return "", errors.New("could not read which files the tree keeps: " + err.Error()) } if strings.Trim(kept, "\x00") != "" { return "", fmt.Errorf("moving back would overwrite %s, a file this machine keeps and git ignores", strings.SplitN(strings.Trim(kept, "\x00"), "\x00", 2)[0]) } } // Read again right before the move back: reset --keep moves the // branch from wherever it is, and a commit something else made // since the first read is left alone. opTreeBeforeMove(path) if now, err := opTreeGit(path, nil, "rev-parse", "HEAD"); err != nil { return "", errors.New("could not read the tree's commit: " + truncate(now, 300)) } else if err := opMovedSince(now, head); err != nil { return "", err } var out string if branch != "" { // Back on the branch it was on, keeping any local work: reset --keep // refuses when a local change is in the way. out, err = opTreeGit(path, nil, "reset", "--keep", "--quiet", from) } else { out, err = opTreeGit(path, nil, "checkout", "--no-overwrite-ignore", "--quiet", "--detach", from) } if err != nil { return "", errors.New("could not move the tree back: " + truncate(out, 300)) } return truncate(to, 12) + " → " + truncate(from, 12) + " (undone)", nil case "k8s.set_image": defer opLockTarget(v)() a := v.Args live, err := opLiveImage(ctx, v) if err != nil { return "", err } if live == from { return "already " + truncate(from, 90), nil } if live != to { return "", fmt.Errorf("the container runs %s, not the %s the line set: it moved since, and is left alone", truncate(live, 90), truncate(to, 90)) } // Read again right before the write, as a roll does. if now, err := opLiveImage(ctx, v); err != nil { return "", err } else if err := opMovedSince(now, live); err != nil { return "", err } wl := a["kind"] + "/" + a["name"] if out, err := opKubectl(ctx, "-n", a["namespace"], "set", "image", wl, a["container"]+"="+from); err != nil { return "", errors.New("set image failed: " + truncate(out, 300)) } if out, err := opKubectl(ctx, "-n", a["namespace"], "rollout", "status", wl, "--timeout=5m"); err != nil { return "", errors.New("the rollout did not finish: " + truncate(out, 300)) } return truncate(to, 60) + " → " + truncate(from, 60) + " (undone), rolled out", nil case "unit.reload", "unit.restart": // The unit is started again on what the other verbs just put back. return opUnitDo(ctx, v) } return "", fmt.Errorf("%s cannot be undone", v.Verb) } // ── what a line left: its receipts, for a pod that lost the answer ───────── func planReceiptsPath(nonce string, n int) (string, error) { p, err := planRunPath(nonce, n) if err != nil { return "", err } return strings.TrimSuffix(p, ".json") + ".receipts.json", nil } // planKeepReceipt keeps every receipt a line (and its undo) signed, in order. func planKeepReceipt(nonce string, n int, s opSignedReceipt) error { path, err := planReceiptsPath(nonce, n) if err != nil { return err } planMu.Lock() defer planMu.Unlock() var all []opSignedReceipt raw, err := os.ReadFile(path) if err == nil { if err := json.Unmarshal(raw, &all); err != nil { return fmt.Errorf("the kept receipts of line %d do not read", n) } } else if !errors.Is(err, os.ErrNotExist) { return err } all = append(all, s) raw, _ = json.MarshalIndent(all, "", " ") return atomicWrite(path, raw) } // op_plan_status : the receipts line n of that plan signed // here, in order — a read, for a pod whose answer to op_plan_line was lost // (a restart mid-run). Nothing is spent by asking. func handlePlanStatus(ws *websocket.Conn, dir string, args []string) { if dir == "" || len(args) < 2 { opFail(ws, "op_plan_status: need ") return } n, err := strconv.Atoi(strings.TrimSpace(args[1])) if err != nil { opFail(ws, "op_plan_status: the line is not a number") return } path, err := planReceiptsPath(strings.TrimSpace(args[0]), n) if err != nil { opFail(ws, "op_plan_status: "+err.Error()) return } var all []opSignedReceipt planMu.Lock() if raw, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(raw, &all) } planMu.Unlock() if all == nil { all = []opSignedReceipt{} } b, _ := json.Marshal(map[string]any{"receipts": all}) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // planKeptReceipt is the kept receipt of attempt k of line n, or nil. func planKeptReceipt(nonce string, n, k int) *lineReceipt { path, err := planReceiptsPath(nonce, n) if err != nil { return nil } planMu.Lock() raw, err := os.ReadFile(path) planMu.Unlock() if err != nil { return nil } var all []opSignedReceipt if json.Unmarshal(raw, &all) != nil { return nil } for _, sr := range all { body, err := base64.StdEncoding.DecodeString(sr.Payload) if err != nil { continue } var r lineReceipt if json.Unmarshal(body, &r) == nil && r.Plan == nonce && r.N == n && !r.Undo && r.Attempt == k { return &r } } return nil }