package main // ops_plan.go — the signed ship plan: one approval for a whole ship. // // A ship is several action ops on several machines, in order: build and push // on one, move a tree and restart on another, roll a workload on a third. One // op_run per step would be one tap per step. So the person's device signs ONE // statement listing every step of this run, and each // machine runs only its own lines of it. // // What the device signs (a DSSE envelope, payload type shipPlanPayloadType): // // the pipeline's name and a version that only increases, the commit being // shipped, a nonce, an expiry (4 h at most), whether the plan holds root // work and that the person's code was checked on the device for it, the key // of every machine in it — the keys the DEVICE pinned itself, never ones the // pod supplied — and the lines: step, machine, op and its sha256, the params // as text, the params a line takes from an earlier step's receipt, its // verbs, whether it may be undone, and whether a roll adopts the live image. // // What each machine checks before it runs a line (planLineCheck): // // the envelope verifies under a device key pinned HERE; the plan is live // and not longer-lived than the cap; its version is not older than one this // machine already ran for that pipeline; the plan names this machine's own // key for its hostname (a device that pinned another key for this machine // is not talking about this machine); the line names this machine; the // line was not spent before (plan nonce + line, kept for the plan's life); // a root line comes from a plan that says the code was given; the held // op's manifest declares exactly the verbs the line shows. // // Values that do not exist at approval (the digest a build produces) come // from a receipt: a line names, per param, the step whose receipt supplies it. // The receipt must be signed by the key the plan names for its machine, name // this plan's nonce and commit, be that step's, and say that step succeeded. // So an image the pod pushed itself, or a receipt from another run, is never // rolled, and a roll cannot run before its build. // // Attempts and undo (ops_plan_linux.go): a line's attempts run one at a // time, the next only after the last failed. A line marked undoable may be // reverted on this machine only to the state it recorded before the line's // first attempt, on a signed receipt of a later line's failed check (its last // allowed attempt) or of the line's own latest attempt failing. import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) const ( shipPlanPayloadType = "application/vnd.privatae.ship-plan.v1+json" shipPlanKind = "ship_plan" shipPlanTTLMax = 4 * time.Hour shipPlanLinesMax = 64 shipPlanVersion = 1 planSpentFile = "plan_spent.json" planVersionsFile = "plan_versions.json" ) var ( planStepRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) planInputRe = regexp.MustCompile(`^([a-z][a-z0-9-]{0,31})\.digest(?::([a-z0-9][a-z0-9._:/-]{0,254}))?$`) planHostRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$`) planMu sync.Mutex planNonceMin = 16 ) // shipPlanLine is one step of a ship on one machine. type shipPlanLine struct { N int `json:"n"` // 1..len(lines), in order Step string `json:"step"` Machine string `json:"machine"` // hostname Op string `json:"op"` OpSHA256 string `json:"op_sha256"` // Params: the JSON text of the op's params, bound verbatim; Inputs names // the params filled at run time from an earlier step's receipt, as // ".digest" (the one image that step pushed) or // ".digest:". Params string `json:"params"` Inputs map[string]string `json:"inputs,omitempty"` // Verbs: what the op's manifest declares, shown to the person and checked // against the held op by the machine. Verbs []opVerbLine `json:"verbs,omitempty"` // Undo: the line may be reverted after a failed later line (ops_plan_linux.go). Undo bool `json:"undo,omitempty"` // AdoptLive: a k8s.set_image on this line takes the live image as its // starting point when this machine has no record of it (the first roll), // instead of refusing. The person reads this on the device. AdoptLive bool `json:"adopt_live,omitempty"` // Attempts: how many times the line may run (1 when unset, at most // planAttemptsMax): a pipeline's retry rule, approved with the plan. Attempts int `json:"attempts,omitempty"` } const planAttemptsMax = 4 // planTextOK: printable ASCII and spaces only — what a person reads on the // device has no control character, line break or Unicode trick in it. func planTextOK(s string) bool { for i := 0; i < len(s); i++ { if s[i] < 0x20 || s[i] > 0x7e { return false } } return true } // planParamsOK: the params are one JSON object, printable, with no key twice // (the device shows the text; the machine and the program must read the // same values from it). func planParamsOK(text string) error { if !planTextOK(text) || len(text) > opParamsMaxBytes { return errors.New("params must be printable ASCII JSON") } dec := json.NewDecoder(strings.NewReader(text)) var walk func(depth int) error walk = func(depth int) error { if depth > 8 { return errors.New("params nest too deep") } tok, err := dec.Token() if err != nil { return errors.New("params are not JSON") } switch t := tok.(type) { case json.Delim: switch t { case '{': seen := map[string]bool{} for dec.More() { kt, err := dec.Token() k, ok := kt.(string) if err != nil || !ok { return errors.New("params are not JSON") } if seen[k] { return fmt.Errorf("params name %q twice", k) } seen[k] = true if err := walk(depth + 1); err != nil { return err } } _, err = dec.Token() return err case '[': for dec.More() { if err := walk(depth + 1); err != nil { return err } } _, err = dec.Token() return err } } return nil } if !strings.HasPrefix(strings.TrimSpace(text), "{") { return errors.New("params must be a JSON object") } if err := walk(0); err != nil { return err } if dec.More() { return errors.New("params hold more than one JSON value") } return nil } // planVerbsShapeOK: the verbs a line shows are verbs this bastion runs, // with only their own argument names and printable values (the machine // compares them with its op anyway; this is what keeps the device's words // honest before it signs). func planVerbsShapeOK(verbs []opVerbLine) error { if len(verbs) > opVerbsMax { return fmt.Errorf("at most %d verbs", opVerbsMax) } for _, v := range verbs { spec, ok := opVerbSpecs[v.Verb] if !ok { return fmt.Errorf("%q is not a verb this bastion runs", v.Verb) } allowed := map[string]bool{} for _, k := range append(append([]string{}, spec.required...), spec.optional...) { allowed[k] = true } for k, val := range v.Args { if !allowed[k] { return fmt.Errorf("%s takes no argument %q", v.Verb, k) } if !planTextOK(val) || len(val) > 2000 || (k != "must" && k != "must_not" && strings.Contains(val, " ")) { return fmt.Errorf("%s %s is not printable", v.Verb, k) } } } return nil } // shipPlan is the statement the device signs. type shipPlan struct { V int `json:"v"` Kind string `json:"kind"` Pipeline string `json:"pipeline"` Version int64 `json:"version"` Commit string `json:"commit"` Nonce string `json:"nonce"` Expires string `json:"expires"` Root bool `json:"root"` CodeGiven bool `json:"code_given"` Device string `json:"device"` // the signing device's key, b64 Machines map[string]string `json:"machines"` // hostname -> machine key (owner.go), b64 Lines []shipPlanLine `json:"lines"` } // shipPlanEnvelope is DSSE: the plan's exact bytes and the device's signature // over their pre-authentication encoding. type shipPlanEnvelope struct { PayloadType string `json:"payloadType"` Payload string `json:"payload"` Signature string `json:"signature"` } func dssePAE(payloadType string, body []byte) []byte { return []byte(fmt.Sprintf("DSSEv1 %d %s %d %s", len(payloadType), payloadType, len(body), body)) } // planShape judges a plan's form, wherever it is read (the device before it // signs, each machine before it runs a line). It does not judge keys. func planShape(p *shipPlan) error { if p.V != shipPlanVersion || p.Kind != shipPlanKind { return errors.New("not a ship plan this bastion reads") } if !opNameRe.MatchString(p.Pipeline) { return errors.New("the plan names no pipeline") } if p.Version < 1 { return errors.New("the plan's version must be 1 or more") } if !opCommitRe.MatchString(p.Commit) { return errors.New("the plan's commit is not a full commit id") } if len(p.Nonce) < planNonceMin || !printableASCII(p.Nonce) { return errors.New("the plan carries no nonce") } exp, err := time.Parse(time.RFC3339, p.Expires) if err != nil { return errors.New("the plan states no expiry") } if exp.After(time.Now().Add(shipPlanTTLMax + deviceClockSkew)) { return fmt.Errorf("a ship plan may not live longer than %s", shipPlanTTLMax) } if len(p.Lines) == 0 || len(p.Lines) > shipPlanLinesMax { return fmt.Errorf("a ship plan holds 1..%d lines", shipPlanLinesMax) } steps := map[string]int{} root := false for i, l := range p.Lines { if l.N != i+1 { return fmt.Errorf("line %d is numbered %d", i+1, l.N) } if !planStepRe.MatchString(l.Step) { return fmt.Errorf("line %d: a step is [a-z][a-z0-9-]{0,31}", l.N) } if _, dup := steps[l.Step]; dup { return fmt.Errorf("line %d: step %s appears twice", l.N, l.Step) } steps[l.Step] = l.N if !planHostRe.MatchString(l.Machine) { return fmt.Errorf("line %d names no machine", l.N) } if _, ok := p.Machines[l.Machine]; !ok { return fmt.Errorf("line %d's machine %s has no key in the plan", l.N, l.Machine) } if !opNameRe.MatchString(l.Op) || len(l.OpSHA256) != 64 { return fmt.Errorf("line %d names no op", l.N) } if _, err := hex.DecodeString(l.OpSHA256); err != nil { return fmt.Errorf("line %d's op sha256 is malformed", l.N) } if err := planParamsOK(l.Params); err != nil { return fmt.Errorf("line %d: %v", l.N, err) } var params map[string]any if err := json.Unmarshal([]byte(l.Params), ¶ms); err != nil || params == nil { return fmt.Errorf("line %d's params are not a JSON object", l.N) } if err := planVerbsShapeOK(l.Verbs); err != nil { return fmt.Errorf("line %d: %v", l.N, err) } if l.Attempts < 0 || l.Attempts > planAttemptsMax { return fmt.Errorf("line %d may run 1..%d times", l.N, planAttemptsMax) } for name, from := range l.Inputs { if !opParamRe.MatchString(name) { return fmt.Errorf("line %d: input %q is not a param name", l.N, name) } m := planInputRe.FindStringSubmatch(from) if m == nil { return fmt.Errorf("line %d: input %s must be .digest or .digest:", l.N, name) } at, ok := steps[m[1]] if !ok || at >= l.N { return fmt.Errorf("line %d: input %s comes from %s, which is not an earlier step", l.N, name, m[1]) } if _, bound := params[name]; bound { return fmt.Errorf("line %d: %s is both bound and an input", l.N, name) } } if len(l.Verbs) > 0 { root = true } } if root != p.Root { return errors.New("the plan's root flag does not match its lines") } return nil } // planOpen verifies an envelope under the device keys pinned on THIS machine // and returns the plan and its sha256. func planOpen(env shipPlanEnvelope) (*shipPlan, string, error) { if env.PayloadType != shipPlanPayloadType { return nil, "", errors.New("not a ship plan envelope") } body, err := base64.StdEncoding.DecodeString(env.Payload) if err != nil { return nil, "", errors.New("the plan does not decode") } var p shipPlan if err := json.Unmarshal(body, &p); err != nil { return nil, "", errors.New("the plan does not parse") } pinned := false for _, k := range deviceKeys() { pinned = pinned || k == p.Device } if !pinned { return nil, "", errors.New("the plan is signed by a device this machine has not pinned (pin-device, with machine authority)") } pub, err := base64.StdEncoding.DecodeString(p.Device) sig, err2 := base64.StdEncoding.DecodeString(env.Signature) if err != nil || err2 != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(pub), dssePAE(shipPlanPayloadType, body), sig) { return nil, "", errors.New("the plan's signature does not verify") } if err := planShape(&p); err != nil { return nil, "", err } sum := sha256.Sum256(body) return &p, hex.EncodeToString(sum[:]), nil } // planLineFor is the line n of a verified plan, checked as THIS machine's to // run now: live, not stale, this machine's key, this machine's line. func planLineFor(p *shipPlan, n int) (*shipPlanLine, error) { exp, _ := time.Parse(time.RFC3339, p.Expires) if !exp.After(time.Now()) { return nil, errors.New("the plan has expired") } if n < 1 || n > len(p.Lines) { return nil, fmt.Errorf("the plan has no line %d", n) } l := &p.Lines[n-1] hn, _ := os.Hostname() if l.Machine != hn { return nil, fmt.Errorf("line %d is for %s, and this machine is %s", n, l.Machine, hn) } pub, _, err := loadOrCreateOwnerKey() if err != nil { return nil, err } if p.Machines[hn] != base64.StdEncoding.EncodeToString(pub) { return nil, errors.New("the plan names another key for this machine than its own: the device pinned a different machine under this name") } if len(l.Verbs) > 0 && !p.CodeGiven { return nil, errors.New("a root line runs only from a plan approved with your code on your device") } if seen := planVersionSeen(p.Pipeline); p.Version < seen { return nil, fmt.Errorf("this machine already ran version %d of %s; version %d is older", seen, p.Pipeline, p.Version) } return l, nil } // ── the ledgers: spent lines, versions seen ───────────────────────────────── func planLedgerPath(name string) (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, name), nil } func planLedgerLoad(name string) map[string]string { out := map[string]string{} if p, err := planLedgerPath(name); err == nil { if raw, err := os.ReadFile(p); err == nil { _ = json.Unmarshal(raw, &out) } } return out } func planLedgerSave(name string, m map[string]string) error { p, err := planLedgerPath(name) if err != nil { return err } raw, _ := json.MarshalIndent(m, "", " ") return atomicWrite(p, raw) } // planSpendAttempt spends the next of a line's attempts and returns its // number (1-based); refuses when all are spent. func planSpendAttempt(p *shipPlan, l *shipPlanLine) (int, error) { max := l.Attempts if max < 1 { max = 1 } for k := 1; k <= max; k++ { key := strconv.Itoa(l.N) + "#" + strconv.Itoa(k) err := planSpend(p, key) if err == nil { return k, nil } if !strings.Contains(err.Error(), "ran already") { return 0, err } } return 0, fmt.Errorf("line %d of this plan ran already (%d time(s), all it may)", l.N, max) } // planSpend records a line (or its undo) as spent, before it runs, for the // plan's life; refuses one spent already. A spend that cannot be written // refuses: an unrecorded spend is a reusable one. func planSpend(p *shipPlan, key string) error { planMu.Lock() defer planMu.Unlock() m := planLedgerLoad(planSpentFile) now := time.Now() for k, exp := range m { if t, err := time.Parse(time.RFC3339, exp); err != nil || !t.After(now) { delete(m, k) } } full := p.Nonce + ":" + key if _, spent := m[full]; spent { return fmt.Errorf("line %s of this plan ran already", key) } m[full] = p.Expires if err := planLedgerSave(planSpentFile, m); err != nil { return fmt.Errorf("could not record this line's use (%v); refused rather than reusable", err) } return nil } func planVersionSeen(pipeline string) int64 { planMu.Lock() defer planMu.Unlock() var v int64 _, _ = fmt.Sscan(planLedgerLoad(planVersionsFile)[pipeline], &v) return v } func planRecordVersion(pipeline string, v int64) error { planMu.Lock() defer planMu.Unlock() m := planLedgerLoad(planVersionsFile) var seen int64 _, _ = fmt.Sscan(m[pipeline], &seen) if v > seen { m[pipeline] = fmt.Sprint(v) return planLedgerSave(planVersionsFile, m) } return nil } // ── line receipts ─────────────────────────────────────────────────────────── const lineReceiptPayloadType = "application/vnd.privatae.line-receipt.v1+json" // lineReceipt is what a machine signs after it ran a line of a plan: which // line of which plan, the op and its exact params, what it pushed, and // whether it and its checks succeeded. The pod relays receipts and cannot // write one. type lineReceipt struct { V int `json:"v"` Plan string `json:"plan"` // the plan's nonce PlanSHA256 string `json:"plan_sha256"` Pipeline string `json:"pipeline"` Commit string `json:"commit"` N int `json:"n"` Step string `json:"step"` Machine string `json:"machine"` Key string `json:"key"` OpSHA256 string `json:"op_sha256"` ParamsSHA256 string `json:"params_sha256"` // of the params as run, inputs filled At string `json:"at"` Attempt int `json:"attempt,omitempty"` OK bool `json:"ok"` Undo bool `json:"undo,omitempty"` // this receipt is for the line's undo Error string `json:"error,omitempty"` VerifyError string `json:"verify_error,omitempty"` Images []opResultImage `json:"images,omitempty"` Verbs []opVerbResult `json:"verbs,omitempty"` Checks []opVerbResult `json:"checks,omitempty"` } // failed: the line, or a check after it, did not succeed. func (r *lineReceipt) failed() bool { return !r.OK || r.VerifyError != "" } func lineReceiptSign(r lineReceipt) (opSignedReceipt, error) { pub, priv, err := loadOrCreateOwnerKey() if err != nil { return opSignedReceipt{}, err } r.V, r.Key = 1, base64.StdEncoding.EncodeToString(pub) raw, _ := json.Marshal(r) return opSignedReceipt{PayloadType: lineReceiptPayloadType, Payload: base64.StdEncoding.EncodeToString(raw), Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, dssePAE(lineReceiptPayloadType, raw)))}, nil } // lineReceiptVerify checks a line receipt against a plan: signed by the key // the plan names for the machine the receipt names (the key the device // pinned), of this plan (nonce, sha256, commit), and of the line it names. func lineReceiptVerify(s opSignedReceipt, p *shipPlan, planSHA string) (*lineReceipt, error) { if s.PayloadType != lineReceiptPayloadType { return nil, errors.New("not a line receipt") } raw, err := base64.StdEncoding.DecodeString(s.Payload) if err != nil { return nil, errors.New("a receipt does not decode") } var r lineReceipt if err := json.Unmarshal(raw, &r); err != nil || r.V != 1 { return nil, errors.New("a receipt does not parse") } want, ok := p.Machines[r.Machine] if !ok || r.Key != want { return nil, fmt.Errorf("a receipt is signed by a key the plan does not name for %s", r.Machine) } pub, err := base64.StdEncoding.DecodeString(want) sig, err2 := base64.StdEncoding.DecodeString(s.Signature) if err != nil || err2 != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(pub), dssePAE(lineReceiptPayloadType, raw), sig) { return nil, errors.New("a receipt's signature does not verify") } if r.Plan != p.Nonce || r.PlanSHA256 != planSHA || r.Commit != p.Commit || r.Pipeline != p.Pipeline { return nil, errors.New("a receipt is from another plan") } if r.N < 1 || r.N > len(p.Lines) || p.Lines[r.N-1].Step != r.Step || p.Lines[r.N-1].Machine != r.Machine { return nil, errors.New("a receipt names a line the plan does not hold") } return &r, nil } // planInputs fills a line's inputs from the receipts handed with it and // returns the params text to run with. Each input needs its step's receipt: // successful, and naming exactly one image (or the image the input names). func planInputs(p *shipPlan, planSHA string, l *shipPlanLine, receipts []opSignedReceipt) (string, map[string]*lineReceipt, error) { byStep := map[string]*lineReceipt{} for _, s := range receipts { r, err := lineReceiptVerify(s, p, planSHA) if err != nil { return "", nil, err } if r.Undo { continue } // A successful receipt of a step wins over a failed attempt of it. if prev, ok := byStep[r.Step]; !ok || (prev.failed() && !r.failed()) { byStep[r.Step] = r } } // IN ORDER: a line runs only after every line before it succeeded, shown // by their signed receipts. So a check cannot run (and fail) before the // step it checks, and a failed later line is always one that ran after. for i := 1; i < l.N; i++ { prior := p.Lines[i-1] r, ok := byStep[prior.Step] if !ok || r.failed() { return "", nil, fmt.Errorf("line %d runs only after line %d (%s) succeeded, and no successful receipt of it came", l.N, i, prior.Step) } } if len(l.Inputs) == 0 { return l.Params, byStep, nil } var params map[string]any if err := json.Unmarshal([]byte(l.Params), ¶ms); err != nil { return "", nil, errors.New("the line's params do not parse") } names := make([]string, 0, len(l.Inputs)) for n := range l.Inputs { names = append(names, n) } sort.Strings(names) for _, name := range names { m := planInputRe.FindStringSubmatch(l.Inputs[name]) r, ok := byStep[m[1]] if !ok { return "", nil, fmt.Errorf("%s comes from step %s, and no receipt of it came with this line: it runs after that step, never before", name, m[1]) } if r.failed() { return "", nil, fmt.Errorf("%s comes from step %s, which did not succeed", name, m[1]) } var hit []opResultImage for _, im := range r.Images { if m[2] == "" || im.Image == m[2] { hit = append(hit, im) } } if len(hit) != 1 { return "", nil, fmt.Errorf("step %s's receipt names %d matching images, not one", m[1], len(hit)) } if hit[0].Commit != p.Commit { return "", nil, fmt.Errorf("step %s built %s, not the plan's commit %s", m[1], truncate(hit[0].Commit, 12), truncate(p.Commit, 12)) } params[name] = hit[0].Digest } out, _ := json.Marshal(params) return string(out), byStep, nil } // planLineMatchesOp: the line runs the held op it names, with params the op // accepts, and shows exactly the verbs that op declares (with the params // bound at approval put in), so a device never approves one list while the // machine runs another. func planLineMatchesOp(l *shipPlanLine, m *opManifest, params string) error { if !m.isAction() { return errors.New("a ship plan line runs an action op") } if m.Name != l.Op { return errors.New("the held op is not the one the line names") } if _, err := checkParams(m, params); err != nil { return err } a, _ := json.Marshal(planVerbsForShow(m, l)) b, _ := json.Marshal(l.Verbs) if string(a) != string(b) { return errors.New("the line shows other verbs than the held op declares") } return nil } // planVerbsForShow is the verb list a line shows: the manifest's verbs, with // each param bound in the line's params put in, and each input left as // `{{name}}`. The pod builds it, the device shows it, the machine rebuilds it // from the held op and compares. func planVerbsForShow(m *opManifest, l *shipPlanLine) []opVerbLine { lines, err := opParseVerbs(m) if err != nil || len(lines) == 0 { return nil } var params map[string]any _ = json.Unmarshal([]byte(l.Params), ¶ms) for i, v := range lines { for k, val := range v.Args { if ref := opParamRefRe.FindStringSubmatch(val); ref != nil { if s, ok := params[ref[1]].(string); ok { lines[i].Args[k] = s } } } } return lines } // ── the device's own pins of machine keys ─────────────────────────────────── // The device keeps, beside its own key, the key of each machine it may sign a // plan for — typed in by the person from what the machine printed when they // pinned this device there (cmdPinDevice prints it), never taken from the pod. func machinePinsPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "machine_pins.json"), nil } func machinePinsLoad() map[string]string { out := map[string]string{} if p, err := machinePinsPath(); err == nil { if raw, err := os.ReadFile(p); err == nil { _ = json.Unmarshal(raw, &out) } } return out } func pinMachineKey(host, key string) error { host, key = strings.TrimSpace(host), strings.TrimSpace(key) if !planHostRe.MatchString(host) { return errors.New("usage: privatae-bastion pin-machine , as the machine printed it") } pub, err := base64.StdEncoding.DecodeString(key) if err != nil || len(pub) != ed25519.PublicKeySize { return errors.New("bad machine key (want the base64 key the machine printed)") } deviceMu.Lock() defer deviceMu.Unlock() m := machinePinsLoad() if prev, ok := m[host]; ok && prev != key { return fmt.Errorf("%s is pinned here with another key; unpin it first (unpin-machine %s) if the machine really changed", host, host) } m[host] = key p, err := machinePinsPath() if err != nil { return err } raw, _ := json.MarshalIndent(m, "", " ") if err := atomicWrite(p, raw); err != nil { return err } _ = os.Chmod(p, 0o600) return nil } func unpinMachineKey(host string) error { deviceMu.Lock() defer deviceMu.Unlock() m := machinePinsLoad() if _, ok := m[host]; !ok { return fmt.Errorf("%s is not pinned here", host) } delete(m, host) p, err := machinePinsPath() if err != nil { return err } raw, _ := json.MarshalIndent(m, "", " ") return atomicWrite(p, raw) } // opPlanCtx is what a plan line's run hands its verbs: the plan's commit, // whether the line adopts the live image, and the line receipts of the // earlier steps it takes inputs from (verified, planInputs). type opPlanCtx struct { Commit string AdoptLive bool Steps map[string]*lineReceipt // beforeVerb records a verb, where it starts and where it is to end, for // the line's undo, before it runs; a verb it cannot record does not run. beforeVerb func(v opVerbLine, from, to string) error } // receiptFor is the image a successful earlier step of this plan built with // this digest, as an opReceipt, or nil. func (pc *opPlanCtx) receiptFor(digest string) *opReceipt { for _, r := range pc.Steps { if r.failed() { continue } for _, im := range r.Images { if im.Digest == digest { return &opReceipt{Image: im.Image, Digest: im.Digest, Source: opCleanRepoURL(im.Source), Commit: im.Commit} } } } return nil }