carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
18package release
19
20import (
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
34const 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.
43const (
44 RefusalNotUp = ""
45 RefusalWrongProgram = "wrong-program"
46 RefusalNotProven = "not-proven"
47)
48
49type 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
80func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") }
81
82func 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.
91func 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
107func 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
127func 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
151func 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.
163func (r *Refusal) Blocks(p *ChannelPointer) bool {
164 return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt)
165}
166