| 1 | package hostresolve |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "github.com/carlosframework/platform/internal/hostactors" |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/hostfence" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | "math" |
| 14 | "os" |
| 15 | "reflect" |
| 16 | "slices" |
| 17 | "time" |
| 18 | ) |
| 19 | |
| 20 | var ErrResolve = errors.New("hostresolve: evidence or operation does not authorize resolution") |
| 21 | |
| 22 | type Service struct { |
| 23 | Directory hostactors.Directory |
| 24 | Signer release.Signer |
| 25 | Now func() time.Time |
| 26 | Owners map[string]OperationOwner |
| 27 | } |
| 28 | type Inspection struct { |
| 29 | Challenge json.RawMessage `json:"challenge"` |
| 30 | Details hostfence.Challenge `json:"details"` |
| 31 | } |
| 32 | |
| 33 | func nonce() (string, error) { |
| 34 | var b [16]byte |
| 35 | _, err := rand.Read(b[:]) |
| 36 | return hex.EncodeToString(b[:]), err |
| 37 | } |
| 38 | func (s Service) record(ctx context.Context, c *hostclaim.Claim) (hostfence.RecordObservation, error) { |
| 39 | _, etag, err := s.Directory.Store.Get(ctx, c.Holder.Key) |
| 40 | if errors.Is(err, os.ErrNotExist) { |
| 41 | return hostfence.RecordObservation{Key: c.Holder.Key, CommitState: "absent"}, nil |
| 42 | } |
| 43 | if err != nil { |
| 44 | return hostfence.RecordObservation{}, err |
| 45 | } |
| 46 | return hostfence.RecordObservation{Key: c.Holder.Key, ETag: etag, CommitState: "present"}, nil |
| 47 | } |
| 48 | |
| 49 | // effects implements only the owners present on the Plan 8 baseline. Moves' |
| 50 | // target-loss policy must come from the moves owner, never from an HTTP flag. |
| 51 | func effects(c *hostclaim.Claim, box string) (hostfence.Consequences, hostclaim.LossConsequences, error) { |
| 52 | var shown hostfence.Consequences |
| 53 | var actual hostclaim.LossConsequences |
| 54 | if c == nil || c.Holder == nil { |
| 55 | return shown, actual, ErrResolve |
| 56 | } |
| 57 | actual.RecoveryIntent = json.RawMessage(`{"kind":"continue-parking"}`) |
| 58 | switch c.Op.Kind { |
| 59 | case hostclaim.KindServing: |
| 60 | shown.Transition = "destroyed-local-only" |
| 61 | actual.RecoveryIntent = json.RawMessage(`{"kind":"destroyed-local-only"}`) |
| 62 | for _, o := range c.Obligations { |
| 63 | if o.Kind == "park-local" && o.Box == box { |
| 64 | ref := hostclaim.ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement} |
| 65 | shown.DischargeLocal = append(shown.DischargeLocal, ref) |
| 66 | actual.DischargeLocal = append(actual.DischargeLocal, ref) |
| 67 | key := o.Op + "/" + o.ID + "/" + o.Retirement |
| 68 | shown.DestroyedStorageAssertion = append(shown.DestroyedStorageAssertion, key) |
| 69 | actual.DestroyedStorage = append(actual.DestroyedStorage, key) |
| 70 | } |
| 71 | } |
| 72 | if len(actual.DischargeLocal) == 0 { |
| 73 | return shown, actual, ErrResolve |
| 74 | } |
| 75 | case hostclaim.KindDeleting, hostclaim.KindParking: |
| 76 | if c.Op.ID == "" || (!slices.Contains(c.Op.Participants, box) && c.Op.Exec != box) { |
| 77 | return shown, actual, ErrResolve |
| 78 | } |
| 79 | shown.Transition = "continue-parking" |
| 80 | shown.PossibleWriteLoss = true |
| 81 | shown.PossibleTimerLoss = true |
| 82 | actual.RevokeExecution = c.Op.Exec == box |
| 83 | actual.RemoveParticipation = slices.Contains(c.Op.Participants, box) |
| 84 | actual.WritesPotentiallyLost = true |
| 85 | actual.TimersPotentiallyLost = true |
| 86 | for _, o := range c.Obligations { |
| 87 | if (o.Kind == "park-replica" || o.Kind == "park-generation") && o.Owner == "box:"+box { |
| 88 | actual.ReassignRegional = append(actual.ReassignRegional, hostclaim.ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}) |
| 89 | shown.Chores = append(shown.Chores, o.Op+"/"+o.ID+"/"+o.Retirement) |
| 90 | } |
| 91 | } |
| 92 | default: |
| 93 | return shown, actual, ErrResolve |
| 94 | } |
| 95 | return shown, actual, nil |
| 96 | } |
| 97 | func (s Service) Inspect(ctx context.Context, actor, host, name string) (Inspection, error) { |
| 98 | ref, b, err := s.Directory.Resolve(ctx, name) |
| 99 | if err != nil { |
| 100 | return Inspection{}, err |
| 101 | } |
| 102 | if b.State == "retired" { |
| 103 | return Inspection{}, ErrResolve |
| 104 | } |
| 105 | o, err := hostclaim.Read(ctx, s.Directory.Store, host) |
| 106 | if err != nil { |
| 107 | return Inspection{}, err |
| 108 | } |
| 109 | if o.Absent || o.Claim.Holder == nil { |
| 110 | return Inspection{}, ErrResolve |
| 111 | } |
| 112 | policy, err := s.operationOwner(o.Claim.Op.Kind) |
| 113 | if err != nil { |
| 114 | return Inspection{}, err |
| 115 | } |
| 116 | record, err := s.record(ctx, o.Claim) |
| 117 | if err != nil { |
| 118 | return Inspection{}, err |
| 119 | } |
| 120 | shown, _, err := inspectOwner(ctx, policy, o, record, ref) |
| 121 | if err != nil { |
| 122 | return Inspection{}, err |
| 123 | } |
| 124 | id, err := nonce() |
| 125 | if err != nil { |
| 126 | return Inspection{}, err |
| 127 | } |
| 128 | c := o.Claim |
| 129 | challenge, err := hostfence.PrepareChallenge(s.Now(), hostfence.Challenge{ID: id, Actor: actor, Host: host, HolderIncarnation: c.Holder.Incarnation, HolderStamp: c.Holder.Stamp, HolderSession: c.Holder.Session, ClaimGen: c.Gen, ClaimETag: o.ETag, Op: hostfence.Operation{ID: c.Op.ID, Kind: c.Op.Kind, Phase: c.Op.Phase}, Record: record, Box: ref, Enrollment: b.Enrollment, Cloud: hostfence.CloudBinding{Provider: b.Cloud.Provider, Account: b.Cloud.Account, Region: b.Cloud.Region, InstanceID: b.Cloud.InstanceID}, Consequences: shown}) |
| 130 | if err != nil { |
| 131 | return Inspection{}, err |
| 132 | } |
| 133 | wire, err := hostfence.SignChallenge(s.Signer, challenge) |
| 134 | return Inspection{wire, challenge}, err |
| 135 | } |
| 136 | |
| 137 | type owner struct{} |
| 138 | |
| 139 | func (owner) ValidateDeclareLost(c hostclaim.Claim, d hostclaim.LostDeclaration) error { |
| 140 | _, want, err := effects(&c, d.Box) |
| 141 | if err != nil { |
| 142 | return err |
| 143 | } |
| 144 | if !reflect.DeepEqual(want, d.Consequences) { |
| 145 | return ErrResolve |
| 146 | } |
| 147 | return nil |
| 148 | } |
| 149 | func (s Service) Declare(ctx context.Context, actor, host string, in hostfence.Submission) (hostclaim.Observed, error) { |
| 150 | now := s.Now().UTC() |
| 151 | var c hostfence.Challenge |
| 152 | err := release.VerifyRootEnvelope(s.Directory.PublicKey, in.Challenge, &c) |
| 153 | if err != nil { |
| 154 | return hostclaim.Observed{}, err |
| 155 | } |
| 156 | if c.Actor != actor || c.Host != host || in.Confirmation != c.ID || !in.Attestation.MaintainComputeFence || !in.Attestation.ColdQuarantineStartOnly || !reflect.DeepEqual(in.Attestation.Disks, c.Consequences.DestroyedStorageAssertion) { |
| 157 | return hostclaim.Observed{}, ErrResolve |
| 158 | } |
| 159 | // A retry uses the durable accepted declaration even after its claim CAS. |
| 160 | // Inspect a new challenge only when no accepted declaration exists. |
| 161 | prior, readErr := hostclaim.ReadLossRecord(ctx, s.Directory.Store, s.Directory.PublicKey, c.ID) |
| 162 | if readErr == nil { |
| 163 | if prior.Record.Actor != actor || prior.Record.Host != host || prior.Record.Enrollment != c.Enrollment { |
| 164 | return hostclaim.Observed{}, ErrResolve |
| 165 | } |
| 166 | return s.finish(ctx, prior) |
| 167 | } |
| 168 | if !errors.Is(readErr, hostclaim.ErrLossRecordAbsent) { |
| 169 | return hostclaim.Observed{}, readErr |
| 170 | } |
| 171 | // Acceptance survives a crash before either quarantine or loss materialization. |
| 172 | acceptance, readErr := s.readAcceptance(ctx, c.ID) |
| 173 | if readErr == nil { |
| 174 | if acceptance.Loss.Actor != actor || acceptance.Loss.Host != host || acceptance.Loss.Enrollment != c.Enrollment || acceptance.Loss.Box != c.Box || acceptance.Loss.ExpectedClaimETag != c.ClaimETag { |
| 175 | return hostclaim.Observed{}, ErrResolve |
| 176 | } |
| 177 | return s.accept(ctx, acceptance) |
| 178 | } |
| 179 | if !errors.Is(readErr, os.ErrNotExist) { |
| 180 | return hostclaim.Observed{}, readErr |
| 181 | } |
| 182 | if _, err = hostfence.VerifyChallenge(s.Directory.PublicKey, in.Challenge, now); err != nil { |
| 183 | return hostclaim.Observed{}, err |
| 184 | } |
| 185 | if err = hostfence.ValidateObservation(c, in.Observation, now); err != nil { |
| 186 | return hostclaim.Observed{}, err |
| 187 | } |
| 188 | roster, err := s.Directory.Read(ctx) |
| 189 | if err != nil { |
| 190 | return hostclaim.Observed{}, err |
| 191 | } |
| 192 | b, ok := roster.Roster.Boxes[c.Box] |
| 193 | if !ok || b.Enrollment != c.Enrollment || b.State == "retired" || b.Cloud != (hostactors.Cloud{Provider: c.Cloud.Provider, Account: c.Cloud.Account, Region: c.Cloud.Region, InstanceID: c.Cloud.InstanceID}) { |
| 194 | return hostclaim.Observed{}, ErrResolve |
| 195 | } |
| 196 | observed, err := hostclaim.Read(ctx, s.Directory.Store, host) |
| 197 | if err != nil { |
| 198 | return hostclaim.Observed{}, err |
| 199 | } |
| 200 | if observed.Absent || observed.ETag != c.ClaimETag || observed.Claim.Gen != c.ClaimGen { |
| 201 | return hostclaim.Observed{}, ErrResolve |
| 202 | } |
| 203 | record, err := s.record(ctx, observed.Claim) |
| 204 | if err != nil || record != c.Record { |
| 205 | return hostclaim.Observed{}, ErrResolve |
| 206 | } |
| 207 | policy, err := s.operationOwner(observed.Claim.Op.Kind) |
| 208 | if err != nil { |
| 209 | return hostclaim.Observed{}, err |
| 210 | } |
| 211 | shown, actual, err := inspectOwner(ctx, policy, observed, record, c.Box) |
| 212 | if err != nil || !reflect.DeepEqual(shown, c.Consequences) { |
| 213 | return hostclaim.Observed{}, ErrResolve |
| 214 | } |
| 215 | if b.QuarantineRevision == math.MaxUint64 || slices.Contains(b.LossIDs, c.ID) { |
| 216 | return hostclaim.Observed{}, ErrResolve |
| 217 | } |
| 218 | loss := hostclaim.LossRecord{V: 1, ID: c.ID, State: "prepared", Actor: actor, AcceptedAt: now, Host: host, HolderIncarnation: c.HolderIncarnation, HolderStamp: c.HolderStamp, HolderSession: c.HolderSession, ExpectedClaimGen: c.ClaimGen, ExpectedClaimETag: c.ClaimETag, OpID: c.Op.ID, OpKind: c.Op.Kind, OpPhase: c.Op.Phase, Box: c.Box, Enrollment: c.Enrollment, QuarantineRevision: b.QuarantineRevision + 1, FencedAt: now, ConfirmedConsequences: actual, Timers: hostclaim.TimerLoss{Knowledge: hostclaim.TimersUnknown}} |
| 219 | loss.WriteSources, err = ownerSources(ctx, policy, observed, actual) |
| 220 | if err != nil { |
| 221 | return hostclaim.Observed{}, err |
| 222 | } |
| 223 | loss.CloudObservation = hostclaim.LossCloudObservation{ObservedAt: in.Observation.ObservedAt, Account: in.Observation.Account, Region: in.Observation.Region, InstanceID: in.Observation.InstanceID, State: in.Observation.State, RequestID: in.Observation.RequestID} |
| 224 | loss.OperatorAttestation = hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, DestroyedStorage: in.Attestation.Disks} |
| 225 | if err := loss.Validate(); err != nil { |
| 226 | return hostclaim.Observed{}, err |
| 227 | } |
| 228 | acceptance = lossAcceptance{V: 1, Bucket: s.Directory.Bucket, RosterETag: roster.ETag, Loss: loss} |
| 229 | wire, err := release.SignRootEnvelope(s.Signer, acceptance) |
| 230 | if err != nil { |
| 231 | return hostclaim.Observed{}, err |
| 232 | } |
| 233 | key, err := acceptanceKey(c.ID) |
| 234 | if err != nil { |
| 235 | return hostclaim.Observed{}, err |
| 236 | } |
| 237 | if err := release.CreateExact(ctx, s.Directory.Store, key, wire); err != nil { |
| 238 | return hostclaim.Observed{}, err |
| 239 | } |
| 240 | return s.accept(ctx, acceptance) |
| 241 | } |
| 242 | |
| 243 | type lossAcceptance struct { |
| 244 | V int `json:"v"` |
| 245 | Bucket string `json:"bucket"` |
| 246 | RosterETag string `json:"roster_etag"` |
| 247 | Loss hostclaim.LossRecord `json:"loss"` |
| 248 | } |
| 249 | |
| 250 | func acceptanceKey(id string) (string, error) { |
| 251 | if _, err := hostclaim.LossRecordKey(id); err != nil { |
| 252 | return "", err |
| 253 | } |
| 254 | return "control/host-loss-acceptances/" + id + ".json", nil |
| 255 | } |
| 256 | func (s Service) readAcceptance(ctx context.Context, id string) (lossAcceptance, error) { |
| 257 | key, err := acceptanceKey(id) |
| 258 | if err != nil { |
| 259 | return lossAcceptance{}, err |
| 260 | } |
| 261 | raw, _, err := s.Directory.Store.Get(ctx, key) |
| 262 | if err != nil { |
| 263 | return lossAcceptance{}, err |
| 264 | } |
| 265 | var a lossAcceptance |
| 266 | if release.VerifyRootEnvelope(s.Directory.PublicKey, raw, &a) != nil || a.V != 1 || a.Bucket != s.Directory.Bucket || a.RosterETag == "" || a.Loss.ID != id || a.Loss.State != "prepared" || a.Loss.Validate() != nil { |
| 267 | return lossAcceptance{}, ErrResolve |
| 268 | } |
| 269 | return a, nil |
| 270 | } |
| 271 | func (s Service) accept(ctx context.Context, a lossAcceptance) (hostclaim.Observed, error) { |
| 272 | if _, err := s.operationOwner(a.Loss.OpKind); err != nil { |
| 273 | return hostclaim.Observed{}, err |
| 274 | } |
| 275 | roster, err := s.Directory.Read(ctx) |
| 276 | if err != nil { |
| 277 | return hostclaim.Observed{}, err |
| 278 | } |
| 279 | loss := a.Loss |
| 280 | b, ok := roster.Roster.Boxes[loss.Box] |
| 281 | if !ok || b.Enrollment != loss.Enrollment || b.State == "retired" || b.Cloud.Account != loss.CloudObservation.Account || b.Cloud.Region != loss.CloudObservation.Region || b.Cloud.InstanceID != loss.CloudObservation.InstanceID { |
| 282 | return hostclaim.Observed{}, ErrResolve |
| 283 | } |
| 284 | if !slices.Contains(b.LossIDs, loss.ID) { |
| 285 | // Retry unrelated roster changes with a fresh CAS. A declaration for |
| 286 | // this same box defeats the accepted revision and needs new inspection. |
| 287 | if b.QuarantineRevision == math.MaxUint64 || b.QuarantineRevision+1 != loss.QuarantineRevision { |
| 288 | return hostclaim.Observed{}, ErrResolve |
| 289 | } |
| 290 | roster, err = s.Directory.Quarantine(ctx, s.Signer, roster, loss.Box, loss.Enrollment, loss.ID) |
| 291 | if err != nil { |
| 292 | return hostclaim.Observed{}, err |
| 293 | } |
| 294 | b = roster.Roster.Boxes[loss.Box] |
| 295 | } |
| 296 | if b.State != "quarantined" || b.QuarantineRevision < loss.QuarantineRevision { |
| 297 | return hostclaim.Observed{}, ErrResolve |
| 298 | } |
| 299 | // The global deny remains in force through every subsequent failure. |
| 300 | prepared, err := hostclaim.CreateLossRecord(ctx, s.Directory.Store, s.Signer, loss) |
| 301 | if err != nil { |
| 302 | return hostclaim.Observed{}, err |
| 303 | } |
| 304 | return s.finish(ctx, prepared) |
| 305 | } |
| 306 | |
| 307 | func (s Service) finish(ctx context.Context, loss hostclaim.ObservedLossRecord) (hostclaim.Observed, error) { |
| 308 | policy, err := s.operationOwner(loss.Record.OpKind) |
| 309 | if err != nil { |
| 310 | return hostclaim.Observed{}, err |
| 311 | } |
| 312 | observed, err := hostclaim.Read(ctx, s.Directory.Store, loss.Record.Host) |
| 313 | if err != nil { |
| 314 | return hostclaim.Observed{}, err |
| 315 | } |
| 316 | if loss.Record.State == "conflicted" { |
| 317 | return hostclaim.Observed{}, hostclaim.ErrConflict |
| 318 | } |
| 319 | // A retained loss reference proves an earlier successful CAS even if |
| 320 | // bookkeeping failed. Otherwise a changed ETag fences every stale retry. |
| 321 | if loss.Record.State == "prepared" && (observed.Absent || !slices.Contains(observed.Claim.LossRefs, loss.Record.ID)) && (observed.Absent || observed.ETag != loss.Record.ExpectedClaimETag || observed.Claim.Gen != loss.Record.ExpectedClaimGen) { |
| 322 | r := loss.Record |
| 323 | r.State = "conflicted" |
| 324 | if _, err := hostclaim.ReplaceLossRecord(ctx, s.Directory.Store, s.Signer, loss, r); err != nil { |
| 325 | return hostclaim.Observed{}, err |
| 326 | } |
| 327 | return hostclaim.Observed{}, hostclaim.ErrConflict |
| 328 | } |
| 329 | if loss.Record.State == "prepared" && !slices.Contains(observed.Claim.LossRefs, loss.Record.ID) { |
| 330 | if err := ownerHook(ctx, policy, observed, loss.Record, false); err != nil { |
| 331 | return hostclaim.Observed{}, err |
| 332 | } |
| 333 | } |
| 334 | next, err := hostclaim.DeclareLost(ctx, s.Directory.Store, s.Directory.PublicKey, observed, loss.Record.ID, policy) |
| 335 | if err != nil { |
| 336 | return hostclaim.Observed{}, err |
| 337 | } |
| 338 | if loss.Record.State == "committed" { |
| 339 | return next, ownerHook(ctx, policy, next, loss.Record, true) |
| 340 | } |
| 341 | r := loss.Record |
| 342 | r.State = "committed" |
| 343 | r.ResultClaimGen = next.Claim.Gen |
| 344 | _, err = hostclaim.ReplaceLossRecord(ctx, s.Directory.Store, s.Signer, loss, r) |
| 345 | if err != nil { |
| 346 | return next, err |
| 347 | } |
| 348 | return next, ownerHook(ctx, policy, next, r, true) |
| 349 | } |
| 350 | |