| 1 | // Package maintenanceguard stores denial state and lifecycle exclusion. Its |
| 2 | // records do not prove human approval, process health, or lease ownership. |
| 3 | package maintenanceguard |
| 4 | |
| 5 | import ( |
| 6 | "amadan.net/carlos/platform.git/launchctx/wireid" |
| 7 | "crypto/sha256" |
| 8 | "encoding/binary" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "github.com/carlosframework/platform/internal/controlname" |
| 13 | "regexp" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | "time" |
| 17 | ) |
| 18 | |
| 19 | const Version = 1 |
| 20 | const MaxContracts = 16 |
| 21 | const MaxReferenceBytes = 2 << 20 |
| 22 | |
| 23 | var ( |
| 24 | ErrUnavailable = errors.New("maintenance guard: unavailable") |
| 25 | ErrConflict = errors.New("maintenance guard: conflict") |
| 26 | ErrBusy = errors.New("maintenance guard: lifecycle claimed") |
| 27 | ErrDenied = errors.New("maintenance guard: serving denied") |
| 28 | atomRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) |
| 29 | contractRE = regexp.MustCompile(`^[a-z][a-z0-9]*([.-][a-z0-9]+)*$`) |
| 30 | ) |
| 31 | |
| 32 | func ValidID(s string) bool { return wireid.ValidID(s) } |
| 33 | func ValidDigest(s string) bool { return wireid.ValidDigest(s) } |
| 34 | func ValidContract(s string) bool { return len(s) > 0 && len(s) <= 64 && contractRE.MatchString(s) } |
| 35 | func ValidContracts(set []string) bool { |
| 36 | if len(set) > MaxContracts { |
| 37 | return false |
| 38 | } |
| 39 | for i, s := range set { |
| 40 | if !ValidContract(s) || (i > 0 && set[i-1] >= s) { |
| 41 | return false |
| 42 | } |
| 43 | } |
| 44 | return true |
| 45 | } |
| 46 | func atom(s string) bool { return s != "." && s != ".." && atomRE.MatchString(s) } |
| 47 | func revision(s string) bool { |
| 48 | if s == "" || len(s) > 256 { |
| 49 | return false |
| 50 | } |
| 51 | for _, c := range []byte(s) { |
| 52 | if c <= 32 || c > 126 || c == '*' { |
| 53 | return false |
| 54 | } |
| 55 | } |
| 56 | return true |
| 57 | } |
| 58 | func digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) } |
| 59 | |
| 60 | // RecordIdentity binds immutable creation fields. It is continuity metadata, |
| 61 | // not proof against a privileged writer restoring or forging those fields. |
| 62 | func RecordIdentity(account, app, host string, created time.Time) (string, error) { |
| 63 | if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) || created.IsZero() { |
| 64 | return "", ErrInvalid |
| 65 | } |
| 66 | stamp := created.UTC().Format(time.RFC3339Nano) |
| 67 | if parsed, e := time.Parse(time.RFC3339Nano, stamp); e != nil || !parsed.Equal(created) { |
| 68 | return "", ErrInvalid |
| 69 | } |
| 70 | raw := []byte("carlos-maintenance-record-identity-v1\x00") |
| 71 | for _, s := range []string{account, app, host, stamp} { |
| 72 | raw = binary.BigEndian.AppendUint32(raw, uint32(len(s))) |
| 73 | raw = append(raw, s...) |
| 74 | } |
| 75 | return digest(raw), nil |
| 76 | } |
| 77 | |
| 78 | type Identity struct { |
| 79 | Account string `json:"account"` |
| 80 | App string `json:"app"` |
| 81 | Host string `json:"host"` |
| 82 | Record string `json:"record"` |
| 83 | } |
| 84 | |
| 85 | func (i Identity) valid() bool { |
| 86 | return i.locationValid() && ValidDigest(i.Record) |
| 87 | } |
| 88 | func (i Identity) locationValid() bool { |
| 89 | return controlname.ValidAccount(i.Account) && controlname.ValidApp(i.App) && controlname.ValidHostShape(i.Host) |
| 90 | } |
| 91 | |
| 92 | type Reference struct { |
| 93 | ID string `json:"id"` |
| 94 | Digest string `json:"digest"` |
| 95 | } |
| 96 | |
| 97 | func (r Reference) valid() bool { return ValidID(r.ID) && ValidDigest(r.Digest) } |
| 98 | |
| 99 | type Phase string |
| 100 | |
| 101 | const ( |
| 102 | Requested Phase = "requested" |
| 103 | Quiescing Phase = "quiescing" |
| 104 | Fenced Phase = "fenced" |
| 105 | Executing Phase = "executing" |
| 106 | Succeeded Phase = "succeeded" |
| 107 | Failed Phase = "failed" |
| 108 | Ambiguous Phase = "ambiguous" |
| 109 | ResumeRequested Phase = "resume-requested" |
| 110 | Resumed Phase = "resumed" |
| 111 | ) |
| 112 | |
| 113 | func (p Phase) valid() bool { |
| 114 | switch p { |
| 115 | case Requested, Quiescing, Fenced, Executing, Succeeded, Failed, Ambiguous, ResumeRequested, Resumed: |
| 116 | return true |
| 117 | } |
| 118 | return false |
| 119 | } |
| 120 | |
| 121 | type Guard struct { |
| 122 | Version int `json:"version"` |
| 123 | Identity Identity `json:"identity"` |
| 124 | Generation int64 `json:"generation"` |
| 125 | Phase Phase `json:"phase"` |
| 126 | OperationID string `json:"operation_id"` |
| 127 | OwnerBox string `json:"owner_box"` |
| 128 | RecordRevision string `json:"record_revision"` |
| 129 | Request Reference `json:"request"` |
| 130 | Attempt Reference `json:"attempt"` |
| 131 | ArtifactVersion string `json:"artifact_version"` |
| 132 | ArtifactDigest string `json:"artifact_digest"` |
| 133 | ConfigDigest string `json:"config_digest"` |
| 134 | RequiredContracts []string `json:"required_contracts"` |
| 135 | Evidence *Reference `json:"evidence,omitempty"` |
| 136 | } |
| 137 | |
| 138 | func (g Guard) valid() bool { |
| 139 | if g.Version != Version || !g.Identity.valid() || g.Generation <= 0 || !g.Phase.valid() || !ValidID(g.OperationID) || !atom(g.OwnerBox) || !revision(g.RecordRevision) || !g.Request.valid() || !g.Attempt.valid() || !atom(g.ArtifactVersion) || !ValidDigest(g.ArtifactDigest) || !ValidDigest(g.ConfigDigest) || !ValidContracts(g.RequiredContracts) { |
| 140 | return false |
| 141 | } |
| 142 | if g.Evidence != nil && !g.Evidence.valid() { |
| 143 | return false |
| 144 | } |
| 145 | return (g.Phase != Resumed && g.Phase != Succeeded) || (g.Evidence != nil && (g.Phase != Resumed || len(g.RequiredContracts) > 0)) |
| 146 | } |
| 147 | func cloneGuard(g Guard) Guard { |
| 148 | g.RequiredContracts = append([]string{}, g.RequiredContracts...) |
| 149 | if g.Evidence != nil { |
| 150 | r := *g.Evidence |
| 151 | g.Evidence = &r |
| 152 | } |
| 153 | return g |
| 154 | } |
| 155 | func guardBytes(g Guard) ([]byte, error) { |
| 156 | if !g.valid() { |
| 157 | return nil, ErrInvalid |
| 158 | } |
| 159 | return json.Marshal(cloneGuard(g)) |
| 160 | } |
| 161 | |
| 162 | type Projection struct { |
| 163 | Version int `json:"version"` |
| 164 | Generation int64 `json:"generation"` |
| 165 | GuardDigest string `json:"guard_digest"` |
| 166 | } |
| 167 | |
| 168 | func (p Projection) valid() bool { |
| 169 | return p.Version == Version && p.Generation > 0 && ValidDigest(p.GuardDigest) |
| 170 | } |
| 171 | func EncodeProjection(p Projection) ([]byte, error) { |
| 172 | if !p.valid() { |
| 173 | return nil, ErrInvalid |
| 174 | } |
| 175 | return json.Marshal(p) |
| 176 | } |
| 177 | func DecodeProjection(raw []byte) (Projection, error) { |
| 178 | var p Projection |
| 179 | if decode(raw, &p) != nil || !p.valid() { |
| 180 | return Projection{}, ErrInvalid |
| 181 | } |
| 182 | return p, nil |
| 183 | } |
| 184 | func containsAll(superset, subset []string) bool { |
| 185 | for _, s := range subset { |
| 186 | i := sort.SearchStrings(superset, s) |
| 187 | if i == len(superset) || superset[i] != s { |
| 188 | return false |
| 189 | } |
| 190 | } |
| 191 | return true |
| 192 | } |
| 193 | func hostBase(host string) (string, error) { |
| 194 | if !controlname.ValidHostShape(host) { |
| 195 | return "", ErrInvalid |
| 196 | } |
| 197 | return "control/maintenance/hosts/" + host + "/", nil |
| 198 | } |
| 199 | func GuardKey(host string) (string, error) { |
| 200 | p, e := hostBase(host) |
| 201 | if e != nil { |
| 202 | return "", e |
| 203 | } |
| 204 | return p + "guard.json", nil |
| 205 | } |
| 206 | func refKey(host, kind, id string) (string, error) { |
| 207 | p, e := hostBase(host) |
| 208 | if e != nil || !ValidID(id) { |
| 209 | return "", ErrInvalid |
| 210 | } |
| 211 | return p + kind + "/" + id + ".json", nil |
| 212 | } |
| 213 | func RequestKey(host, id string) (string, error) { return refKey(host, "requests", id) } |
| 214 | func AttemptKey(host, id string) (string, error) { return refKey(host, "attempts", id) } |
| 215 | func ReceiptKey(host, id string) (string, error) { return refKey(host, "receipts", id) } |
| 216 | func appBase(account, app string) (string, error) { |
| 217 | if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { |
| 218 | return "", ErrInvalid |
| 219 | } |
| 220 | return "control/maintenance/apps/" + account + "/" + app + "/", nil |
| 221 | } |
| 222 | func LifecycleKey(account, app string) (string, error) { |
| 223 | p, e := appBase(account, app) |
| 224 | if e != nil { |
| 225 | return "", e |
| 226 | } |
| 227 | return p + "lifecycle.json", nil |
| 228 | } |
| 229 | func cleanETag(s string) bool { return s != "" && len(s) <= 512 && !strings.ContainsAny(s, "\r\n\x00") } |
| 230 | |