package hostops import ( "context" "encoding/json" "math" "reflect" "time" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/release" ) const RegistrationTTL = 90 * time.Second const RegistrationRenewEvery = 20 * time.Second const RegistrationSkew = 5 * time.Second type RegistrationOwner struct { Console string `json:"console,omitempty"` Box string `json:"box,omitempty"` Enrollment string `json:"enrollment"` Controller string `json:"controller"` } type SavedClaim struct { Absent bool `json:"absent"` ETag string `json:"etag,omitempty"` Gen uint64 `json:"gen,omitempty"` } type Registration struct { Kind string `json:"kind"` Host string `json:"host"` Owner RegistrationOwner `json:"owner"` AppID string `json:"app_id"` Epoch uint64 `json:"epoch"` Claim SavedClaim `json:"claim"` Expires time.Time `json:"expires"` CreatedAt time.Time `json:"created_at"` Incarnation string `json:"incarnation"` } type Effect struct { ID string `json:"id"` OperationID string `json:"operation_id"` AppID string `json:"app_id"` Resource string `json:"resource"` Action string `json:"action"` Inputs map[string]string `json:"inputs"` Owner RegistrationOwner `json:"owner"` State string `json:"state"` ResultReference string `json:"result_reference,omitempty"` } type AppOperation struct { ID string `json:"id"` Kind string `json:"kind"` AppID string `json:"app_id"` Phase string `json:"phase"` Intent map[string]string `json:"intent"` } type PurgeMark struct { ID string `json:"id"` AppID string `json:"app_id"` } type RestoreMark struct { ID string `json:"id"` AppID string `json:"app_id"` Epoch uint64 `json:"epoch"` TombstoneETag string `json:"tombstone_etag"` DeletedAt int64 `json:"deleted_at"` State string `json:"state"` } type AppAdmission struct { V int `json:"v"` AppID string `json:"app_id"` Revision uint64 `json:"revision"` Epoch uint64 `json:"epoch"` State string `json:"state"` Op *AppOperation `json:"op"` Registrations map[string]Registration `json:"registrations"` Effects map[string]Effect `json:"effects"` LastPurge *PurgeMark `json:"last_purge"` LastRestore *RestoreMark `json:"last_restore,omitempty"` } type AdmissionObserved struct { Key string ETag string Value AppAdmission } // Kinds accepted for an exclusive operation. "purging" is reachable only // through BeginPurge, which consumes a reserved journal. const ( OpDeleting = "deleting" OpPlacing = "placing" OpPurging = "purging" OpAppMutation = "app-mutation" ) func AdmissionKey(account, app string) (string, error) { if !controlname.ValidAccount(account) || !controlname.ValidApp(app) { return "", ErrIdentity } return "apps/" + account + "/" + app + "/admission.json", nil } // ReadAdmission is a read. It works through the data boundary; it never // initializes, repairs or interprets absence. func ReadAdmission(ctx context.Context, s release.Store, account, app, appID string) (AdmissionObserved, error) { if appID == "" { return AdmissionObserved{}, ErrIdentity } key, err := AdmissionKey(account, app) if err != nil { return AdmissionObserved{}, err } body, etag, err := s.Get(ctx, key) if err != nil { return AdmissionObserved{}, err } var a AppAdmission if err = decode(body, &a); err != nil { return AdmissionObserved{}, err } if !validAdmission(a) || etag == "" || a.AppID != appID { return AdmissionObserved{}, ErrIdentity } return AdmissionObserved{Key: key, ETag: etag, Value: a}, nil } // ReadCanonicalAdmission reads the object's own app_id and then validates it. // A missing object is os.ErrNotExist; a blank or invalid one is ErrIdentity. func ReadCanonicalAdmission(ctx context.Context, s release.Store, account, app string) (AdmissionObserved, error) { key, err := AdmissionKey(account, app) if err != nil { return AdmissionObserved{}, err } body, _, err := s.Get(ctx, key) if err != nil { return AdmissionObserved{}, err } var identity struct { AppID string `json:"app_id"` } if err = json.Unmarshal(body, &identity); err != nil { return AdmissionObserved{}, err } if identity.AppID == "" { return AdmissionObserved{}, ErrIdentity } return ReadAdmission(ctx, s, account, app, identity.AppID) } func validAdmission(a AppAdmission) bool { for id, r := range a.Registrations { if r.Kind != RegistrationIngest { continue } if id == "" || r.Host != "" || r.Claim != (SavedClaim{}) || r.AppID != a.AppID || r.Epoch != a.Epoch || !validRegistrationOwner(r.Owner) || r.Owner.Console == "" || r.Expires.IsZero() || r.Incarnation != "" || !r.CreatedAt.IsZero() { return false } } return a.V == 1 && a.AppID != "" && a.Revision != 0 && a.Epoch != 0 && (a.State == "open" || a.State == "closed") && a.Registrations != nil && a.Effects != nil && (a.Op == nil || (a.Op.ID != "" && a.Op.Kind != "" && a.Op.AppID == a.AppID)) } // CompletedAdmission is the retired-incarnation predicate shared by // enrollment, materialization and the purge-request discard. func CompletedAdmission(a AppAdmission) bool { if a.State != "closed" || a.Op != nil || len(a.Registrations) != 0 || a.LastPurge == nil || a.LastPurge.AppID != a.AppID || a.LastPurge.ID == "" { return false } for _, effect := range a.Effects { if effect.State != "complete" { return false } } return true } func validRegistrationOwner(o RegistrationOwner) bool { return (o.Console != "") != (o.Box != "") && o.Enrollment != "" && o.Controller != "" } func RegistrationLive(r Registration, now time.Time) bool { return now.Before(r.Expires.Add(-RegistrationSkew)) } func RegistrationDrained(r Registration, now time.Time) bool { return !now.Before(r.Expires.Add(RegistrationSkew)) } func SavedObservation(o hostclaim.Observed) SavedClaim { if o.Absent { return SavedClaim{Absent: true} } return SavedClaim{ETag: o.ETag, Gen: o.Claim.Gen} } // ---- pure transitions (unexported; the ControlWriter is the only caller) ---- func registerStart(a *AppAdmission, id string, r Registration, now time.Time) error { if id == "" || a.State != "open" || a.Op != nil || r.AppID != a.AppID || r.Epoch != a.Epoch || !validRegistrationOwner(r.Owner) || r.Kind == "" || now.IsZero() { return ErrPending } if r.Kind == registrationPurge || r.Kind == RegistrationIngest { // Ingest and purge reservations have no host or saved claim. if r.Kind == RegistrationIngest && (r.Owner.Console == "" || r.Incarnation != "" || !r.CreatedAt.IsZero()) { return ErrIdentity } if r.Host != "" || r.Claim != (SavedClaim{}) { return ErrIdentity } } else { if !controlname.ValidHostShape(r.Host) { return ErrPending } if (r.Claim.Absent && (r.Claim.ETag != "" || r.Claim.Gen != 0)) || (!r.Claim.Absent && (r.Claim.ETag == "" || r.Claim.Gen == 0)) { return ErrIdentity } } if _, ok := a.Registrations[id]; ok { return ErrIdentity } r.Expires = now.UTC().Add(RegistrationTTL) a.Registrations[id] = r return nil } // renewStart deliberately works after admission closed: an accepted // registration may keep renewing until its own operation settles. func renewStart(a *AppAdmission, id string, owner RegistrationOwner, now time.Time) error { r, ok := a.Registrations[id] if !ok || r.Owner != owner || r.AppID != a.AppID || r.Epoch != a.Epoch || !RegistrationLive(r, now) { return ErrPending } r.Expires = now.UTC().Add(RegistrationTTL) a.Registrations[id] = r return nil } func bindStart(a *AppAdmission, id string, owner RegistrationOwner, created time.Time, incarnation string, now time.Time) error { r, ok := a.Registrations[id] if !ok || r.Owner != owner || r.AppID != a.AppID || r.Epoch != a.Epoch || !RegistrationLive(r, now) || created.IsZero() || incarnation == "" { return ErrPending } if r.Incarnation != "" && (r.Incarnation != incarnation || !r.CreatedAt.Equal(created)) { return ErrIdentity } r.CreatedAt = created.UTC() r.Incarnation = incarnation a.Registrations[id] = r return nil } func exclusiveAdmission(a *AppAdmission, op AppOperation) error { if op.ID == "" || op.Kind == "" || op.AppID != a.AppID { return ErrIdentity } if a.Op != nil { if a.State == "closed" && reflect.DeepEqual(*a.Op, op) { return nil } return ErrPending } // A completed (closed, op-less) admission never reopens here; that is // the dedicated recreation transition in EnrollNewApp. if a.State != "open" { return ErrPending } if op.Intent == nil { op.Intent = map[string]string{} } a.State = "closed" a.Op = &op return nil } func prepareEffect(a *AppAdmission, e Effect) error { if e.ID == "" || e.OperationID == "" || e.AppID != a.AppID || e.Resource == "" || e.Action == "" || e.State != "prepared" || !validRegistrationOwner(e.Owner) { return ErrIdentity } if old, ok := a.Effects[e.ID]; ok { if reflect.DeepEqual(old, e) { return nil } return ErrIdentity } for _, old := range a.Effects { if old.Resource == e.Resource && old.State != "complete" { return ErrPending } } a.Effects[e.ID] = e return nil } func advanceEffect(a *AppAdmission, id string, owner RegistrationOwner, state, result string) error { e, ok := a.Effects[id] if !ok || e.Owner != owner || e.AppID != a.AppID { return ErrIdentity } if e.State == state && e.ResultReference == result { return nil } allowed := e.State == "prepared" && state == "issued" || e.State == "issued" && (state == "uncertain" || state == "complete") || e.State == "uncertain" && state == "complete" if !allowed { return ErrPending } if state == "complete" && result == "" { return ErrIdentity } e.State = state e.ResultReference = result a.Effects[id] = e return nil } func effectsSettled(a *AppAdmission, operation string) bool { for _, e := range a.Effects { if (operation == "" || e.OperationID == operation) && e.State != "complete" { return false } } return true } // nextAdmission copies before through JSON so a refused mutation never // aliases the caller's maps, and enforces the invariants every transition // shares: same version and app_id; epoch changes only through reopen. func nextAdmission(before AdmissionObserved, mutate func(*AppAdmission) error) (AppAdmission, error) { if before.ETag == "" || before.Key == "" || before.Value.Revision == math.MaxUint64 { return AppAdmission{}, ErrIdentity } body, err := json.Marshal(before.Value) if err != nil { return AppAdmission{}, err } var next AppAdmission if err = json.Unmarshal(body, &next); err != nil { return AppAdmission{}, err } if err = mutate(&next); err != nil { return AppAdmission{}, err } reopen := before.Value.State == "closed" && next.State == "open" && before.Value.Op != nil && next.Op == nil && before.Value.Epoch < math.MaxUint64 && next.Epoch == before.Value.Epoch+1 && len(next.Registrations) == 0 if next.AppID != before.Value.AppID || (!reopen && next.Epoch != before.Value.Epoch) || next.V != 1 || !validAdmission(withRevision(next, 1)) { return AppAdmission{}, ErrIdentity } // last_purge is written only by CompletePurge and carried by recreation. if !reflect.DeepEqual(next.LastPurge, before.Value.LastPurge) && !purgeCompletion(before.Value, next) { return AppAdmission{}, ErrIdentity } next.Revision = before.Value.Revision + 1 return next, nil } func withRevision(a AppAdmission, r uint64) AppAdmission { a.Revision = r; return a } func purgeCompletion(before, next AppAdmission) bool { return before.Op != nil && before.Op.Kind == OpPurging && next.Op == nil && next.State == "closed" && next.LastPurge != nil && next.LastPurge.ID == before.Op.ID && next.LastPurge.AppID == before.AppID }