| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "path/filepath" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/hostwrite" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | // These selectors contain no local paths. Root must independently resolve the |
| 22 | // installation from its state inventory before returning an authorization. |
| 23 | type servingArtifactIdentity = hostbox.ServingArtifactIdentity |
| 24 | |
| 25 | type servingLegacyPredecessor = hostbox.ServingLegacyPredecessor |
| 26 | |
| 27 | type servingParkAuthorization = hostbox.ServingParkResult |
| 28 | |
| 29 | // A zero predecessor asks Park to resume an existing record for successor; |
| 30 | // it must never authorize a new retirement. Complete independently rereads |
| 31 | // the claim's paired tags. Neither call may run while Work is held. |
| 32 | type servingDatabaseParker interface { |
| 33 | Park(context.Context, servingArtifactIdentity, servingArtifactIdentity) (servingParkAuthorization, error) |
| 34 | ParkLegacy(context.Context, servingArtifactIdentity, servingLegacyPredecessor) (servingParkAuthorization, error) |
| 35 | Complete(context.Context, string, servingArtifactIdentity) error |
| 36 | } |
| 37 | |
| 38 | type claimServingArtifactRetirement struct { |
| 39 | Claims release.Store // The assembly's authorized claim writer, never a new bucket client. |
| 40 | Guard hostguard.Guard |
| 41 | DataDir, Box, Region string |
| 42 | Root servingDatabaseParker |
| 43 | mu sync.Mutex |
| 44 | completed map[string]servingParkAuthorization |
| 45 | } |
| 46 | |
| 47 | func servingRetirementID(prior, original servingArtifactIdentity, legacy bool) string { |
| 48 | body, _ := json.Marshal(struct { |
| 49 | Prior, Successor servingArtifactIdentity |
| 50 | Legacy bool |
| 51 | }{prior, original, legacy}) |
| 52 | sum := sha256.Sum256(body) |
| 53 | return hex.EncodeToString(sum[:]) |
| 54 | } |
| 55 | |
| 56 | func servingWriterIdentity(o hostclaim.Observed, box, region string) (servingArtifactIdentity, error) { |
| 57 | if o.Absent || o.Host == "" || o.Claim == nil || o.Claim.Host != o.Host || o.Claim.Holder == nil || box == "" || region == "" { |
| 58 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 59 | } |
| 60 | c, h := o.Claim, o.Claim.Holder |
| 61 | if c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || c.Op.Exec != box || h.WriterBox != box || h.Incarnation == "" || h.Location == "" || h.Generation == "" || h.Session == 0 || h.Stamp != h.Session { |
| 62 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 63 | } |
| 64 | found := 0 |
| 65 | for _, a := range h.Admissions { |
| 66 | if a.Session != h.Session { |
| 67 | continue |
| 68 | } |
| 69 | if a.Box != box || a.Region != region || a.Generation != h.Generation || a.Mode != "cold" { |
| 70 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 71 | } |
| 72 | found++ |
| 73 | } |
| 74 | if found != 1 { |
| 75 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 76 | } |
| 77 | return servingArtifactIdentity{Host: o.Host, Box: box, Region: region, Incarnation: h.Incarnation, Location: h.Location, Generation: h.Generation, Stamp: h.Stamp, Session: h.Session}, nil |
| 78 | } |
| 79 | |
| 80 | func (r *claimServingArtifactRetirement) snapshot(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs) (hostclaim.Observed, servingArtifactIdentity, string, uint64, error) { |
| 81 | fail := func(err error) (hostclaim.Observed, servingArtifactIdentity, string, uint64, error) { |
| 82 | return hostclaim.Observed{}, servingArtifactIdentity{}, "", 0, err |
| 83 | } |
| 84 | if ctx == nil || r == nil || r.Claims == nil || r.Root == nil || !filepath.IsAbs(r.DataDir) || r.Guard.DataDir != r.DataDir { |
| 85 | return fail(hostbox.ErrUnsupported) |
| 86 | } |
| 87 | expected, err := servingWriterIdentity(admitted, r.Box, r.Region) |
| 88 | if err != nil { |
| 89 | return fail(err) |
| 90 | } |
| 91 | if in.DBPath != provision.NestedDBPath(r.DataDir, admitted.Host) { |
| 92 | return fail(hostbox.ErrChanged) |
| 93 | } |
| 94 | db, err := filepath.Rel(r.DataDir, in.DBPath) |
| 95 | if err != nil || !filepath.IsLocal(db) { |
| 96 | return fail(hostbox.ErrChanged) |
| 97 | } |
| 98 | db = filepath.ToSlash(db) |
| 99 | fresh, err := hostclaim.Read(ctx, r.Claims, admitted.Host) |
| 100 | if err != nil { |
| 101 | return fail(err) |
| 102 | } |
| 103 | actual, err := servingWriterIdentity(fresh, r.Box, r.Region) |
| 104 | if err != nil || actual != expected { |
| 105 | return fail(hostbox.ErrChanged) |
| 106 | } |
| 107 | a, err := servingInventoryPair(fresh.Claim.Inventory[r.Box], db) |
| 108 | if err != nil || a > actual.Stamp { |
| 109 | return fail(hostbox.ErrChanged) |
| 110 | } |
| 111 | return fresh, actual, db, a, nil |
| 112 | } |
| 113 | |
| 114 | func servingPredecessor(c *hostclaim.Claim, current servingArtifactIdentity, stamp uint64, generation string) (servingArtifactIdentity, error) { |
| 115 | if stamp == 0 || stamp >= current.Stamp { |
| 116 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 117 | } |
| 118 | var out servingArtifactIdentity |
| 119 | count := 0 |
| 120 | for _, a := range c.Holder.Admissions { |
| 121 | if a.Session != stamp { |
| 122 | continue |
| 123 | } |
| 124 | if a.Mode != "cold" || a.Box != current.Box || a.Region != current.Region || a.Generation == "" || a.Generation == current.Generation || (generation != "" && a.Generation != generation) { |
| 125 | return out, hostbox.ErrChanged |
| 126 | } |
| 127 | out = current |
| 128 | out.Session, out.Stamp, out.Generation = a.Session, stamp, a.Generation |
| 129 | count++ |
| 130 | } |
| 131 | if count != 1 { |
| 132 | return servingArtifactIdentity{}, hostbox.ErrChanged |
| 133 | } |
| 134 | return out, nil |
| 135 | } |
| 136 | |
| 137 | func (r *claimServingArtifactRetirement) stopped(ctx context.Context, prior servingArtifactIdentity) error { |
| 138 | receipts, err := r.Guard.CompletedServingStops(ctx, prior.Host, hostguard.Captured{Incarnation: prior.Incarnation, Stamp: prior.Stamp}, prior.Session, prior.Box) |
| 139 | if err != nil { |
| 140 | return err |
| 141 | } |
| 142 | if len(receipts) == 0 { |
| 143 | return hostguard.ErrNoFenceReceipt |
| 144 | } |
| 145 | for _, receipt := range receipts { |
| 146 | if receipt.Target.Region != prior.Region || verifyRetirementReceipt(ctx, receipt) != nil { |
| 147 | return ErrClaimStopReceipt |
| 148 | } |
| 149 | } |
| 150 | return nil |
| 151 | } |
| 152 | |
| 153 | func (r *claimServingArtifactRetirement) Retire(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, generation string) error { |
| 154 | if generation == "" { |
| 155 | return hostbox.ErrChanged |
| 156 | } |
| 157 | return r.recover(ctx, admitted, in, generation) |
| 158 | } |
| 159 | |
| 160 | func (r *claimServingArtifactRetirement) Recover(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs) error { |
| 161 | return r.recover(ctx, admitted, in, "") |
| 162 | } |
| 163 | |
| 164 | func (r *claimServingArtifactRetirement) recover(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, generation string) error { |
| 165 | r.forgetCompletion(admitted.Host) |
| 166 | fresh, current, _, stamp, err := r.snapshot(ctx, admitted, in) |
| 167 | if err != nil { |
| 168 | return err |
| 169 | } |
| 170 | if stamp == 0 { |
| 171 | // Legacy authority is available only after PrepareState. In particular, |
| 172 | // missing tags never authorize deleting an existing generation marker. |
| 173 | if generation != "" { |
| 174 | return hostbox.ErrChanged |
| 175 | } |
| 176 | return nil |
| 177 | } |
| 178 | var prior servingArtifactIdentity |
| 179 | if stamp != current.Stamp { |
| 180 | prior, err = servingPredecessor(fresh.Claim, current, stamp, generation) |
| 181 | if err != nil { |
| 182 | return err |
| 183 | } |
| 184 | if err = r.stopped(ctx, prior); err != nil { |
| 185 | return err |
| 186 | } |
| 187 | } |
| 188 | local, err := servingAgentDirectory(in.DBPath) |
| 189 | if err != nil { |
| 190 | return err |
| 191 | } |
| 192 | authorization, err := r.Root.Park(ctx, current, prior) |
| 193 | if err != nil { |
| 194 | return err |
| 195 | } |
| 196 | if generation != "" && authorization.Predecessor.Generation != generation { |
| 197 | return hostbox.ErrChanged |
| 198 | } |
| 199 | return r.finish(ctx, admitted, in, authorization, local, nil) |
| 200 | } |
| 201 | |
| 202 | func (r *claimServingArtifactRetirement) BeforeLegacyInstall(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| 203 | r.forgetCompletion(admitted.Host) |
| 204 | _, current, _, stamp, err := r.snapshot(ctx, admitted, in) |
| 205 | if err != nil { |
| 206 | return err |
| 207 | } |
| 208 | if work == nil { |
| 209 | return hostbox.ErrUnsupported |
| 210 | } |
| 211 | if stamp != 0 { |
| 212 | return r.Recover(ctx, admitted, in) |
| 213 | } |
| 214 | prior := servingLegacyPredecessor{Session: in.Admission.ObservedSession, Generation: in.Admission.ObservedGeneration, Empty: in.Empty && in.Admission.ObservedSession == 0} |
| 215 | if (prior.Session == 0) != (prior.Generation == "") || prior.Session >= current.Session || (prior.Empty && prior.Session != 0) { |
| 216 | return hostbox.ErrChanged |
| 217 | } |
| 218 | local, err := servingAgentDirectory(in.DBPath) |
| 219 | if err != nil { |
| 220 | return err |
| 221 | } |
| 222 | authorization, err := r.Root.ParkLegacy(ctx, current, prior) |
| 223 | if err != nil { |
| 224 | return err |
| 225 | } |
| 226 | if !authorization.Legacy { |
| 227 | return hostbox.ErrChanged |
| 228 | } |
| 229 | return r.finish(ctx, admitted, in, authorization, local, work) |
| 230 | } |
| 231 | |
| 232 | func (r *claimServingArtifactRetirement) validateAuthorization(ctx context.Context, fresh hostclaim.Observed, current servingArtifactIdentity, a servingParkAuthorization) error { |
| 233 | original := a.OriginalSuccessor |
| 234 | if a.TagsFrom > current.Stamp { |
| 235 | return hostbox.ErrChanged |
| 236 | } |
| 237 | if a.TagsFrom != 0 && a.TagsFrom != current.Stamp && a.TagsFrom != a.Predecessor.Stamp { |
| 238 | if a.TagsFrom < original.Stamp { |
| 239 | return hostbox.ErrChanged |
| 240 | } |
| 241 | if _, err := servingPredecessor(fresh.Claim, current, a.TagsFrom, ""); err != nil { |
| 242 | return err |
| 243 | } |
| 244 | } |
| 245 | if !a.Legacy && a.TagsFrom == 0 { |
| 246 | return hostbox.ErrChanged |
| 247 | } |
| 248 | if a.Successor != current || original.Host != current.Host || original.Box != current.Box || original.Region != current.Region || original.Incarnation != current.Incarnation || original.Location != current.Location || original.Stamp == 0 || original.Session != original.Stamp || original.Generation == "" || original.Session > current.Session || a.Directory.Device == 0 || a.Directory.Inode == 0 || a.Directory.Mount == 0 || a.ID != servingRetirementID(a.Predecessor, original, a.Legacy) { |
| 249 | return hostbox.ErrChanged |
| 250 | } |
| 251 | if original.Session == current.Session && original != current { |
| 252 | return hostbox.ErrChanged |
| 253 | } |
| 254 | // A continuation preserves the original record identity; it cannot rename |
| 255 | // that record to the next admission. Root authenticates its own continuation. |
| 256 | if original.Session < current.Session { |
| 257 | if _, err := servingPredecessor(fresh.Claim, current, original.Stamp, original.Generation); err != nil { |
| 258 | return err |
| 259 | } |
| 260 | } |
| 261 | if !a.Legacy { |
| 262 | prior, err := servingPredecessor(fresh.Claim, current, a.Predecessor.Stamp, a.Predecessor.Generation) |
| 263 | if err != nil || prior != a.Predecessor || prior.Stamp >= original.Stamp { |
| 264 | return hostbox.ErrChanged |
| 265 | } |
| 266 | return r.stopped(ctx, prior) |
| 267 | } |
| 268 | if a.Predecessor != (servingArtifactIdentity{}) { |
| 269 | return hostbox.ErrChanged |
| 270 | } |
| 271 | return nil |
| 272 | } |
| 273 | |
| 274 | func (r *claimServingArtifactRetirement) finish(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, a servingParkAuthorization, local servingArtifactDirectory, work hostwrite.Work) error { |
| 275 | fresh, current, _, _, err := r.snapshot(ctx, admitted, in) |
| 276 | if err != nil { |
| 277 | return err |
| 278 | } |
| 279 | if err = r.validateAuthorization(ctx, fresh, current, a); err != nil { |
| 280 | return err |
| 281 | } |
| 282 | // Mount IDs belong to a namespace. Root keeps its original mount identity; |
| 283 | // the hardened agent pins its own mount before the RPC and checks it again |
| 284 | // during enrollment. Both views must still name the same device and inode. |
| 285 | if a.Directory.Device != local.Device || a.Directory.Inode != local.Inode || local.Mount == 0 { |
| 286 | return hostbox.ErrChanged |
| 287 | } |
| 288 | a.Directory = local |
| 289 | enroll := func() error { return r.enrollPairLocked(ctx, admitted, in, a) } |
| 290 | if work != nil { |
| 291 | err = work.With(ctx, in.Identity, enroll) |
| 292 | } else { |
| 293 | err = r.Guard.With(ctx, current.Host, hostguard.Captured{Incarnation: current.Incarnation, Stamp: current.Stamp}, func(c *hostclaim.Claim) error { |
| 294 | identity, err := servingWriterIdentity(hostclaim.Observed{Host: current.Host, Claim: c}, r.Box, r.Region) |
| 295 | if err != nil || identity != current { |
| 296 | return hostbox.ErrChanged |
| 297 | } |
| 298 | return nil |
| 299 | }, enroll) |
| 300 | } |
| 301 | if err != nil { |
| 302 | return err |
| 303 | } |
| 304 | if err = r.Root.Complete(ctx, a.ID, current); err != nil { |
| 305 | return err |
| 306 | } |
| 307 | r.mu.Lock() |
| 308 | if r.completed == nil { |
| 309 | r.completed = make(map[string]servingParkAuthorization) |
| 310 | } |
| 311 | r.completed[current.Host] = a |
| 312 | r.mu.Unlock() |
| 313 | return nil |
| 314 | } |
| 315 | |
| 316 | // enrollPairLocked runs only under the selected Work or the matching Guard. |
| 317 | // The root authorization proves the old sources have been consumed. The |
| 318 | // descriptor check prevents a replacement inode from reaching purgeDB. |
| 319 | func (r *claimServingArtifactRetirement) enrollPairLocked(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, a servingParkAuthorization) error { |
| 320 | fresh, current, db, stamp, err := r.snapshot(ctx, admitted, in) |
| 321 | if err != nil { |
| 322 | return err |
| 323 | } |
| 324 | if a.Successor != current { |
| 325 | return hostbox.ErrChanged |
| 326 | } |
| 327 | prior := a.TagsFrom |
| 328 | if stamp != prior && stamp != current.Stamp { |
| 329 | return hostbox.ErrChanged |
| 330 | } |
| 331 | if err = checkServingArtifactAbsence(in.DBPath, a.Directory); err != nil { |
| 332 | return err |
| 333 | } |
| 334 | transition := servingInventoryTransition(current, db, prior) |
| 335 | if stamp == current.Stamp { |
| 336 | return transition(fresh.Claim) |
| 337 | } |
| 338 | written, updateErr := hostclaim.Update(ctx, r.Claims, fresh, transition) |
| 339 | if updateErr == nil { |
| 340 | if err := in.observeClaimWrite(fresh, written); err != nil { |
| 341 | return err |
| 342 | } |
| 343 | } else if in.claimWrite != nil { |
| 344 | return updateErr |
| 345 | } |
| 346 | // An uncertain CAS is resolved only by a fresh, exact pair and the same |
| 347 | // writer. A refusal never permits a database or marker write. |
| 348 | after, same, _, tagged, readErr := r.snapshot(ctx, admitted, in) |
| 349 | if readErr != nil { |
| 350 | return readErr |
| 351 | } |
| 352 | if same != current || tagged != current.Stamp { |
| 353 | if updateErr != nil { |
| 354 | return updateErr |
| 355 | } |
| 356 | return hostbox.ErrChanged |
| 357 | } |
| 358 | if err = transition(after.Claim); err != nil { |
| 359 | return err |
| 360 | } |
| 361 | return checkServingArtifactAbsence(in.DBPath, a.Directory) |
| 362 | } |
| 363 | |
| 364 | func servingInventoryTransition(current servingArtifactIdentity, db string, prior uint64) func(*hostclaim.Claim) error { |
| 365 | return func(c *hostclaim.Claim) error { |
| 366 | actual, err := servingWriterIdentity(hostclaim.Observed{Host: current.Host, Claim: c}, current.Box, current.Region) |
| 367 | if err != nil || actual != current || !filepath.IsLocal(db) || filepath.ToSlash(filepath.Clean(db)) != db || strings.ContainsAny(db, "\\\x00") { |
| 368 | return hostbox.ErrChanged |
| 369 | } |
| 370 | entry := c.Inventory[current.Box] |
| 371 | stamp, err := servingInventoryPair(entry, db) |
| 372 | if err != nil || (stamp != prior && stamp != current.Stamp) { |
| 373 | return hostbox.ErrChanged |
| 374 | } |
| 375 | // Copy before editing: a rejected transition cannot mutate its observation. |
| 376 | paths := make(map[string]uint64, len(entry.Paths)+2) |
| 377 | for p, s := range entry.Paths { |
| 378 | paths[p] = s |
| 379 | } |
| 380 | paths[db], paths[db+".gen"] = current.Stamp, current.Stamp |
| 381 | if c.Inventory == nil { |
| 382 | c.Inventory = make(map[string]hostclaim.InventoryEntry) |
| 383 | } |
| 384 | c.Inventory[current.Box] = hostclaim.InventoryEntry{Layout: "nested", Paths: paths} |
| 385 | return nil |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | func (r *claimServingArtifactRetirement) EnrollLocked(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs) error { |
| 390 | if r == nil { |
| 391 | return hostbox.ErrUnsupported |
| 392 | } |
| 393 | r.mu.Lock() |
| 394 | a, ok := r.completed[admitted.Host] |
| 395 | r.mu.Unlock() |
| 396 | if !ok { |
| 397 | return hostbox.ErrChanged |
| 398 | } |
| 399 | // This final hook can only revalidate a pair completed outside Work. It |
| 400 | // cannot finish an incomplete root record or enroll an unparked legacy DB. |
| 401 | _, current, _, stamp, err := r.snapshot(ctx, admitted, in) |
| 402 | if err != nil { |
| 403 | return err |
| 404 | } |
| 405 | if a.Successor != current || stamp != current.Stamp { |
| 406 | return hostbox.ErrChanged |
| 407 | } |
| 408 | return r.enrollPairLocked(ctx, admitted, in, a) |
| 409 | } |
| 410 | |
| 411 | // servingInventoryPair preserves RecordInventory's normalized-path and |
| 412 | // ancestor rules without taking its lock again inside Work. |
| 413 | func servingInventoryPair(entry hostclaim.InventoryEntry, db string) (uint64, error) { |
| 414 | if !filepath.IsLocal(db) || filepath.ToSlash(filepath.Clean(db)) != db || strings.ContainsAny(db, "\\\x00") { |
| 415 | return 0, hostbox.ErrChanged |
| 416 | } |
| 417 | if len(entry.Paths) != 0 && entry.Layout != "nested" { |
| 418 | return 0, hostbox.ErrChanged |
| 419 | } |
| 420 | left, leftOK := entry.Paths[db] |
| 421 | right, rightOK := entry.Paths[db+".gen"] |
| 422 | if leftOK != rightOK || left != right || (leftOK && left == 0) { |
| 423 | return 0, hostbox.ErrChanged |
| 424 | } |
| 425 | all := make([]string, 0, len(entry.Paths)+2) |
| 426 | for p, stamp := range entry.Paths { |
| 427 | if !filepath.IsLocal(p) || filepath.ToSlash(filepath.Clean(p)) != p || strings.ContainsAny(p, "\\\x00") || stamp == 0 { |
| 428 | return 0, hostbox.ErrChanged |
| 429 | } |
| 430 | all = append(all, p) |
| 431 | } |
| 432 | if !leftOK { |
| 433 | all = append(all, db, db+".gen") |
| 434 | } |
| 435 | sort.Strings(all) |
| 436 | for i, p := range all { |
| 437 | for _, next := range all[i+1:] { |
| 438 | if strings.HasPrefix(next, p+"/") { |
| 439 | return 0, hostbox.ErrChanged |
| 440 | } |
| 441 | } |
| 442 | } |
| 443 | return left, nil |
| 444 | } |
| 445 | |
| 446 | // bindServingArtifactRetirement composes the serving contract with whichever |
| 447 | // operation owners the assembly already enabled. A missing root client leaves |
| 448 | // the existing production refusal in place. |
| 449 | func bindServingArtifactRetirement(w *ClaimWakeAssembly, r *claimServingArtifactRetirement) error { |
| 450 | if w == nil || r == nil || r.Root == nil || r.Claims == nil { |
| 451 | return hostbox.ErrUnsupported |
| 452 | } |
| 453 | operationRetire, operationRecover := w.RetireArtifacts, w.RecoverArtifacts |
| 454 | w.RetireArtifacts = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, generation string) error { |
| 455 | if o.Claim != nil && o.Claim.Op.Kind == hostclaim.KindServing { |
| 456 | return r.Retire(ctx, o, in, generation) |
| 457 | } |
| 458 | if operationRetire == nil { |
| 459 | return hostbox.ErrChanged |
| 460 | } |
| 461 | return operationRetire(ctx, o, in, generation) |
| 462 | } |
| 463 | w.RecoverArtifacts = func(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| 464 | if o.Claim != nil && o.Claim.Op.Kind == hostclaim.KindServing { |
| 465 | return r.Recover(ctx, o, in) |
| 466 | } |
| 467 | if operationRecover == nil { |
| 468 | return nil |
| 469 | } |
| 470 | return operationRecover(ctx, o, in) |
| 471 | } |
| 472 | w.BeforeLegacyInstall, w.BeforeInstallLocked = r.BeforeLegacyInstall, r.EnrollLocked |
| 473 | w.RequireServingEnrollment = true |
| 474 | return nil |
| 475 | } |
| 476 | |
| 477 | func (r *claimServingArtifactRetirement) forgetCompletion(host string) { |
| 478 | if r == nil { |
| 479 | return |
| 480 | } |
| 481 | r.mu.Lock() |
| 482 | delete(r.completed, host) |
| 483 | r.mu.Unlock() |
| 484 | } |
| 485 | |