| new file mode 100644 |
| index 000000000..d9804aab3 |
| --- /dev/null |
| +++ b/internal/release/refusal.go |
| @@ -0,0 +1,147 @@ |
| +// Refusal records — D1 of the console release defences (spec |
| +// 2026-10-06-console-release-defences-design.md §2.5). |
| +// |
| +// When a named unit does not come up on a new build, the root pass rolls it |
| +// back and writes one of these per host in the rolled-back process group. |
| +// Every later pass, the agent's fast loop and root alike, reads it before |
| +// staging and skips the promotion it names. Without it the very next pass |
| +// would adopt the same build and bounce the service again. |
| +// |
| +// The record blocks a PROMOTION, not a version name: it matches while the |
| +// channel pointer still has the version and PromotedAt it had when the build |
| +// was refused. `carlos promote --retry` re-signs the pointer with a fresh |
| +// PromotedAt, and so does any move to another version; either clears it. |
| +// |
| +// Root writes these under the bake directory, which the sandboxed agent can |
| +// read. A file that does not parse reads as absent: a torn write must never |
| +// become a permanent block. |
| +package release |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const refusalSchema = 1 |
| + |
| +type Refusal struct { |
| + Schema int `json:"schema"` |
| + Host string `json:"host"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + SHA256 string `json:"sha256,omitempty"` |
| + Platform string `json:"platform,omitempty"` |
| + PointerPromotedAt time.Time `json:"pointer_promoted_at"` |
| + RefusedAt time.Time `json:"refused_at"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") } |
| + |
| +func refusalPath(bakeDir, host string) (string, error) { |
| + if host == "" || strings.ContainsAny(host, `/\`) || host == "." || host == ".." { |
| + return "", fmt.Errorf("refusal: invalid host %q", host) |
| + } |
| + return filepath.Join(RefusalDir(bakeDir), host+".json"), nil |
| +} |
| + |
| +// WriteRefusal writes atomically (temp file + rename) and world-readable, |
| +// because the reader is the sandboxed agent. |
| +func WriteRefusal(bakeDir string, r Refusal) error { |
| + p, err := refusalPath(bakeDir, r.Host) |
| + if err != nil { |
| + return err |
| + } |
| + r.Schema = refusalSchema |
| + b, err := json.MarshalIndent(r, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + return err |
| + } |
| + tmp, err := os.CreateTemp(filepath.Dir(p), ".refusal-*") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(tmp.Name()) |
| + if _, err := tmp.Write(append(b, '\n')); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Chmod(0o644); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Close(); err != nil { |
| + return err |
| + } |
| + return os.Rename(tmp.Name(), p) |
| +} |
| + |
| +func ReadRefusal(bakeDir, host string) (*Refusal, error) { |
| + p, err := refusalPath(bakeDir, host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + b, err := os.ReadFile(p) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r Refusal |
| + if err := json.Unmarshal(b, &r); err != nil || r.Schema != refusalSchema { |
| + log.Printf("refusal: %s does not parse (%v), treating it as absent", p, err) |
| + return nil, nil |
| + } |
| + return &r, nil |
| +} |
| + |
| +func ListRefusals(bakeDir string) ([]Refusal, error) { |
| + ents, err := os.ReadDir(RefusalDir(bakeDir)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []Refusal |
| + for _, e := range ents { |
| + name := e.Name() |
| + if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, ".") { |
| + continue |
| + } |
| + r, err := ReadRefusal(bakeDir, strings.TrimSuffix(name, ".json")) |
| + if err != nil || r == nil { |
| + continue |
| + } |
| + out = append(out, *r) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host }) |
| + return out, nil |
| +} |
| + |
| +func ClearRefusal(bakeDir, host string) error { |
| + p, err := refusalPath(bakeDir, host) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// Blocks reports whether this record still refuses the promotion p names. |
| +func (r *Refusal) Blocks(p *ChannelPointer) bool { |
| + return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt) |
| +} |