| 1 | // Refusal records — D1 of the console release defences (spec |
| 2 | // 2026-10-06-console-release-defences-design.md §2.5). |
| 3 | // |
| 4 | // When a named unit does not come up on a new build, the root pass rolls it |
| 5 | // back and writes one of these per host in the rolled-back process group. |
| 6 | // Every later pass, the agent's fast loop and root alike, reads it before |
| 7 | // staging and skips the promotion it names. Without it the very next pass |
| 8 | // would adopt the same build and bounce the service again. |
| 9 | // |
| 10 | // The record blocks a PROMOTION, not a version name: it matches while the |
| 11 | // channel pointer still has the version and PromotedAt it had when the build |
| 12 | // was refused. `carlos promote --retry` re-signs the pointer with a fresh |
| 13 | // PromotedAt, and so does any move to another version; either clears it. |
| 14 | // |
| 15 | // Root writes these under the bake directory, which the sandboxed agent can |
| 16 | // read. A file that does not parse reads as absent: a torn write must never |
| 17 | // become a permanent block. |
| 18 | package release |
| 19 | |
| 20 | import ( |
| 21 | "encoding/json" |
| 22 | "errors" |
| 23 | "fmt" |
| 24 | "log" |
| 25 | "os" |
| 26 | "path/filepath" |
| 27 | "sort" |
| 28 | "strings" |
| 29 | "time" |
| 30 | |
| 31 | "github.com/carlosframework/platform/internal/safefile" |
| 32 | ) |
| 33 | |
| 34 | const refusalSchema = 1 |
| 35 | |
| 36 | // What a refusal is for. RefusalNotUp (the zero value, so every record written |
| 37 | // before Kind existed reads as one) is D1: the unit did not come up and was |
| 38 | // rolled back. RefusalWrongProgram is D2: the staged binary is a different Go |
| 39 | // program from any this route has run, so it was never pointed live and |
| 40 | // nothing restarted. RefusalNotProven is D3 (proofgate.go): the staged bytes |
| 41 | // are not the ones the proof covers, so they too were never pointed live. A |
| 42 | // "--retry" cures the first and loops on the other two. |
| 43 | const ( |
| 44 | RefusalNotUp = "" |
| 45 | RefusalWrongProgram = "wrong-program" |
| 46 | RefusalNotProven = "not-proven" |
| 47 | ) |
| 48 | |
| 49 | type Refusal struct { |
| 50 | Schema int `json:"schema"` |
| 51 | // Kind is RefusalNotUp, RefusalWrongProgram or RefusalNotProven. Omitted |
| 52 | // when not-up. |
| 53 | Kind string `json:"kind,omitempty"` |
| 54 | Host string `json:"host"` |
| 55 | App string `json:"app"` |
| 56 | Version string `json:"version"` |
| 57 | SHA256 string `json:"sha256,omitempty"` |
| 58 | Platform string `json:"platform,omitempty"` |
| 59 | PointerPromotedAt time.Time `json:"pointer_promoted_at"` |
| 60 | RefusedAt time.Time `json:"refused_at"` |
| 61 | Reason string `json:"reason"` |
| 62 | // The rollback's outcome, recorded after its restart (spec §2.6: a |
| 63 | // recovery that failed must not be published as a rollback). |
| 64 | // RestoredVersion is what the bound host's link was put back on, "" when |
| 65 | // that repoint itself failed or there was nothing to put back. Recovered |
| 66 | // is true only when the link was rolled back AND the restart on it came |
| 67 | // up; RecoveryError says why not otherwise. |
| 68 | RestoredVersion string `json:"restored_version,omitempty"` |
| 69 | Recovered bool `json:"recovered"` |
| 70 | RecoveryError string `json:"recovery_error,omitempty"` |
| 71 | // MoveOp is set only on a move's held-stage refusal (materializeheld.go, |
| 72 | // ruling D3-held): the op of the move whose held stage was refused. It |
| 73 | // scopes the record to that move, which retires it (a later held stage |
| 74 | // of the move that succeeds, or the move ending), and it is never set on |
| 75 | // an adoption refusal, so adoption's records are byte-identical. Held |
| 76 | // records live under HeldRefusalBase, never in RefusalDir(bakeDir). |
| 77 | MoveOp string `json:"move_op,omitempty"` |
| 78 | } |
| 79 | |
| 80 | func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") } |
| 81 | |
| 82 | func refusalPath(bakeDir, host string) (string, error) { |
| 83 | if host == "" || strings.ContainsAny(host, `/\`) || host == "." || host == ".." { |
| 84 | return "", fmt.Errorf("refusal: invalid host %q", host) |
| 85 | } |
| 86 | return filepath.Join(RefusalDir(bakeDir), host+".json"), nil |
| 87 | } |
| 88 | |
| 89 | // WriteRefusal writes atomically (temp file + rename) and world-readable, |
| 90 | // because the reader is the sandboxed agent. |
| 91 | func WriteRefusal(bakeDir string, r Refusal) error { |
| 92 | p, err := refusalPath(bakeDir, r.Host) |
| 93 | if err != nil { |
| 94 | return err |
| 95 | } |
| 96 | r.Schema = refusalSchema |
| 97 | b, err := json.MarshalIndent(r, "", " ") |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | if err := safefile.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| 102 | return err |
| 103 | } |
| 104 | return safefile.WriteFile(p, append(b, '\n'), 0o644) |
| 105 | } |
| 106 | |
| 107 | func ReadRefusal(bakeDir, host string) (*Refusal, error) { |
| 108 | p, err := refusalPath(bakeDir, host) |
| 109 | if err != nil { |
| 110 | return nil, err |
| 111 | } |
| 112 | b, err := safefile.ReadFile(p) |
| 113 | if errors.Is(err, os.ErrNotExist) { |
| 114 | return nil, nil |
| 115 | } |
| 116 | if err != nil { |
| 117 | return nil, err |
| 118 | } |
| 119 | var r Refusal |
| 120 | if err := json.Unmarshal(b, &r); err != nil || r.Schema != refusalSchema { |
| 121 | log.Printf("refusal: %s does not parse (%v), treating it as absent", p, err) |
| 122 | return nil, nil |
| 123 | } |
| 124 | return &r, nil |
| 125 | } |
| 126 | |
| 127 | func ListRefusals(bakeDir string) ([]Refusal, error) { |
| 128 | ents, err := os.ReadDir(RefusalDir(bakeDir)) |
| 129 | if errors.Is(err, os.ErrNotExist) { |
| 130 | return nil, nil |
| 131 | } |
| 132 | if err != nil { |
| 133 | return nil, err |
| 134 | } |
| 135 | var out []Refusal |
| 136 | for _, e := range ents { |
| 137 | name := e.Name() |
| 138 | if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, ".") { |
| 139 | continue |
| 140 | } |
| 141 | r, err := ReadRefusal(bakeDir, strings.TrimSuffix(name, ".json")) |
| 142 | if err != nil || r == nil { |
| 143 | continue |
| 144 | } |
| 145 | out = append(out, *r) |
| 146 | } |
| 147 | sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| 148 | return out, nil |
| 149 | } |
| 150 | |
| 151 | func ClearRefusal(bakeDir, host string) error { |
| 152 | p, err := refusalPath(bakeDir, host) |
| 153 | if err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if err := safefile.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 157 | return err |
| 158 | } |
| 159 | return nil |
| 160 | } |
| 161 | |
| 162 | // Blocks reports whether this record still refuses the promotion p names. |
| 163 | func (r *Refusal) Blocks(p *ChannelPointer) bool { |
| 164 | return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt) |
| 165 | } |
| 166 | |