package hostops import ( "bytes" "context" "encoding/json" "errors" "io" "os" "strings" "time" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) var ( ErrPending = errors.New("hostops: pending") ErrContract = errors.New("hostops: required contract unavailable") ErrIdentity = errors.New("hostops: identity mismatch") ) type Record struct { VerifiedOwnership bool // Set only after current DNS/platform proof; never decoded from JSON. Holder hostclaim.NewHolder Body []byte ETag string } func decode(body []byte, out any) error { d := json.NewDecoder(bytes.NewReader(body)) if err := d.Decode(out); err != nil { return err } if _, err := d.Token(); err != io.EOF { return ErrIdentity } return nil } func ReadRecord(ctx context.Context, s release.Store, key string) (Record, error) { parts := strings.Split(key, "/") if len(parts) != 5 || parts[0] != "apps" || !controlname.ValidAccount(parts[1]) || !controlname.ValidApp(parts[2]) || !strings.HasSuffix(parts[4], ".json") { return Record{}, ErrIdentity } body, etag, err := s.Get(ctx, key) if err != nil { return Record{}, err } if etag == "" { return Record{}, ErrIdentity } var binding struct { Acquisition string `json:"acq"` } if err := decode(body, &binding); err != nil { return Record{}, err } n := hostclaim.NewHolder{Acquisition: binding.Acquisition, Key: key, Account: parts[1], App: parts[2]} switch parts[3] { case "instances": var r provision.Record if err := decode(body, &r); err != nil { return Record{}, err } if r.Account != n.Account || r.App != n.App || key != provision.RecordKey(r.Account, r.App, r.Host) || !controlname.ValidHostShape(r.Host) { return Record{}, ErrIdentity } // DecodeRecord is also the ordinary instance reader's shape validator. if _, err := provision.DecodeRecord(body); err != nil { return Record{}, err } n.Family, n.Host, n.RecordRegion, n.CreatedAt = "instance", r.Host, r.Region, r.CreatedAt case "sidecars": var r provision.SidecarRecord if err := decode(body, &r); err != nil { return Record{}, err } if r.Account != n.Account || r.App != n.App || !controlname.ValidLabel(r.Name) || key != provision.SidecarRecordKey(r.Account, r.App, r.Name) || r.Host != provision.SidecarHost(r.Name, r.App, r.Account) { return Record{}, ErrIdentity } n.Family, n.Host, n.Name, n.RecordRegion, n.CreatedAt = "sidecar", r.Host, r.Name, r.Region, r.CreatedAt default: return Record{}, ErrIdentity } return Record{Holder: n, Body: body, ETag: etag}, nil } func (r Record) Incarnation() (string, error) { n := r.Holder return hostclaim.Incarnation(n.Account, n.App, n.Host, n.CreatedAt) } func matches(r Record, h *hostclaim.Holder) bool { if h == nil { return false } n := r.Holder inc, err := r.Incarnation() return err == nil && n.Acquisition == h.Acquisition && n.Key == h.Key && n.Family == h.Family && n.Account == h.Account && n.App == h.App && n.Name == h.Name && n.CreatedAt.Equal(h.CreatedAt) && inc == h.Incarnation } // The caller reads the record first. This function performs the later claim read. func RemoveOrphan(ctx context.Context, s release.Store, r Record) (bool, error) { if err := CheckHostQuarantine(ctx, s, r.Holder.Host); err != nil { return false, err } inc, err := r.Incarnation() if err != nil { return false, err } obs, err := hostclaim.Read(ctx, s, r.Holder.Host) if err != nil { return false, err } if !hostclaim.IsOrphan(inc, obs) { return false, nil } if err := release.DeleteIfMatch(ctx, s, r.Holder.Key, r.ETag); err != nil { return false, err } return true, nil } func Records(ctx context.Context, s release.Store, acct, app string) ([]Record, error) { if !controlname.ValidAccount(acct) || !controlname.ValidApp(app) { return nil, ErrIdentity } var out []Record for _, prefix := range []string{provision.RecordsPrefix(acct, app), provision.SidecarsPrefix(acct, app)} { keys, err := s.List(ctx, prefix) if err != nil { return nil, err } for _, key := range keys { tail, ok := strings.CutPrefix(key, prefix) if !ok { return nil, ErrIdentity } if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".json") { continue } r, err := ReadRecord(ctx, s, key) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, err } out = append(out, r) } } return out, nil } // PrepareInstance accepts only an unstamped internal request, never a caller time. func PrepareInstance(r provision.Record, now time.Time, region string) (Record, error) { if !r.CreatedAt.IsZero() || now.IsZero() || region == "" || len(r.Maintenance) != 0 { return Record{}, ErrIdentity } r.CreatedAt = now.UTC() body, err := json.Marshal(r) if err != nil { return Record{}, err } n := hostclaim.NewHolder{Acquisition: r.Acquisition, Family: "instance", Key: provision.RecordKey(r.Account, r.App, r.Host), Account: r.Account, App: r.App, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt} return Record{Holder: n, Body: body}, nil } func PrepareSidecar(r provision.SidecarRecord, now time.Time, region string) (Record, error) { if !r.CreatedAt.IsZero() || now.IsZero() || region == "" { return Record{}, ErrIdentity } r.CreatedAt = now.UTC() body, err := json.Marshal(r) if err != nil { return Record{}, err } n := hostclaim.NewHolder{Acquisition: r.Acquisition, Family: "sidecar", Key: provision.SidecarRecordKey(r.Account, r.App, r.Name), Account: r.Account, App: r.App, Name: r.Name, Host: r.Host, RecordRegion: r.Region, Region: region, CreatedAt: r.CreatedAt} return Record{Holder: n, Body: body}, nil }