| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/controlname" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | var ( |
| 20 | ErrPending = errors.New("hostops: pending") |
| 21 | ErrContract = errors.New("hostops: required contract unavailable") |
| 22 | ErrIdentity = errors.New("hostops: identity mismatch") |
| 23 | ) |
| 24 | |
| 25 | type Record struct { |
| 26 | VerifiedOwnership bool // Set only after current DNS/platform proof; never decoded from JSON. |
| 27 | Holder hostclaim.NewHolder |
| 28 | Body []byte |
| 29 | ETag string |
| 30 | } |
| 31 | |
| 32 | func decode(body []byte, out any) error { |
| 33 | d := json.NewDecoder(bytes.NewReader(body)) |
| 34 | if err := d.Decode(out); err != nil { |
| 35 | return err |
| 36 | } |
| 37 | if _, err := d.Token(); err != io.EOF { |
| 38 | return ErrIdentity |
| 39 | } |
| 40 | return nil |
| 41 | } |
| 42 | |
| 43 | func ReadRecord(ctx context.Context, s release.Store, key string) (Record, error) { |
| 44 | parts := strings.Split(key, "/") |
| 45 | if len(parts) != 5 || parts[0] != "apps" || !controlname.ValidAccount(parts[1]) || !controlname.ValidApp(parts[2]) || !strings.HasSuffix(parts[4], ".json") { |
| 46 | return Record{}, ErrIdentity |
| 47 | } |
| 48 | body, etag, err := s.Get(ctx, key) |
| 49 | if err != nil { |
| 50 | return Record{}, err |
| 51 | } |
| 52 | if etag == "" { |
| 53 | return Record{}, ErrIdentity |
| 54 | } |
| 55 | var binding struct { |
| 56 | Acquisition string `json:"acq"` |
| 57 | } |
| 58 | if err := decode(body, &binding); err != nil { |
| 59 | return Record{}, err |
| 60 | } |
| 61 | n := hostclaim.NewHolder{Acquisition: binding.Acquisition, Key: key, Account: parts[1], App: parts[2]} |
| 62 | switch parts[3] { |
| 63 | case "instances": |
| 64 | var r provision.Record |
| 65 | if err := decode(body, &r); err != nil { |
| 66 | return Record{}, err |
| 67 | } |
| 68 | if r.Account != n.Account || r.App != n.App || key != provision.RecordKey(r.Account, r.App, r.Host) || !controlname.ValidHostShape(r.Host) { |
| 69 | return Record{}, ErrIdentity |
| 70 | } |
| 71 | // DecodeRecord is also the ordinary instance reader's shape validator. |
| 72 | if _, err := provision.DecodeRecord(body); err != nil { |
| 73 | return Record{}, err |
| 74 | } |
| 75 | n.Family, n.Host, n.RecordRegion, n.CreatedAt = "instance", r.Host, r.Region, r.CreatedAt |
| 76 | case "sidecars": |
| 77 | var r provision.SidecarRecord |
| 78 | if err := decode(body, &r); err != nil { |
| 79 | return Record{}, err |
| 80 | } |
| 81 | 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) { |
| 82 | return Record{}, ErrIdentity |
| 83 | } |
| 84 | n.Family, n.Host, n.Name, n.RecordRegion, n.CreatedAt = "sidecar", r.Host, r.Name, r.Region, r.CreatedAt |
| 85 | default: |
| 86 | return Record{}, ErrIdentity |
| 87 | } |
| 88 | return Record{Holder: n, Body: body, ETag: etag}, nil |
| 89 | } |
| 90 | |
| 91 | func (r Record) Incarnation() (string, error) { |
| 92 | n := r.Holder |
| 93 | return hostclaim.Incarnation(n.Account, n.App, n.Host, n.CreatedAt) |
| 94 | } |
| 95 | |
| 96 | func matches(r Record, h *hostclaim.Holder) bool { |
| 97 | if h == nil { |
| 98 | return false |
| 99 | } |
| 100 | n := r.Holder |
| 101 | inc, err := r.Incarnation() |
| 102 | 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 |
| 103 | } |
| 104 | |
| 105 | // The caller reads the record first. This function performs the later claim read. |
| 106 | func RemoveOrphan(ctx context.Context, s release.Store, r Record) (bool, error) { |
| 107 | if err := CheckHostQuarantine(ctx, s, r.Holder.Host); err != nil { |
| 108 | return false, err |
| 109 | } |
| 110 | inc, err := r.Incarnation() |
| 111 | if err != nil { |
| 112 | return false, err |
| 113 | } |
| 114 | obs, err := hostclaim.Read(ctx, s, r.Holder.Host) |
| 115 | if err != nil { |
| 116 | return false, err |
| 117 | } |
| 118 | if !hostclaim.IsOrphan(inc, obs) { |
| 119 | return false, nil |
| 120 | } |
| 121 | if err := release.DeleteIfMatch(ctx, s, r.Holder.Key, r.ETag); err != nil { |
| 122 | return false, err |
| 123 | } |
| 124 | return true, nil |
| 125 | } |
| 126 | |
| 127 | func Records(ctx context.Context, s release.Store, acct, app string) ([]Record, error) { |
| 128 | if !controlname.ValidAccount(acct) || !controlname.ValidApp(app) { |
| 129 | return nil, ErrIdentity |
| 130 | } |
| 131 | var out []Record |
| 132 | for _, prefix := range []string{provision.RecordsPrefix(acct, app), provision.SidecarsPrefix(acct, app)} { |
| 133 | keys, err := s.List(ctx, prefix) |
| 134 | if err != nil { |
| 135 | return nil, err |
| 136 | } |
| 137 | for _, key := range keys { |
| 138 | tail, ok := strings.CutPrefix(key, prefix) |
| 139 | if !ok { |
| 140 | return nil, ErrIdentity |
| 141 | } |
| 142 | if strings.Contains(tail, "/") || !strings.HasSuffix(tail, ".json") { |
| 143 | continue |
| 144 | } |
| 145 | r, err := ReadRecord(ctx, s, key) |
| 146 | if errors.Is(err, os.ErrNotExist) { |
| 147 | continue |
| 148 | } |
| 149 | if err != nil { |
| 150 | return nil, err |
| 151 | } |
| 152 | out = append(out, r) |
| 153 | } |
| 154 | } |
| 155 | return out, nil |
| 156 | } |
| 157 | |
| 158 | // PrepareInstance accepts only an unstamped internal request, never a caller time. |
| 159 | func PrepareInstance(r provision.Record, now time.Time, region string) (Record, error) { |
| 160 | if !r.CreatedAt.IsZero() || now.IsZero() || region == "" || len(r.Maintenance) != 0 { |
| 161 | return Record{}, ErrIdentity |
| 162 | } |
| 163 | r.CreatedAt = now.UTC() |
| 164 | body, err := json.Marshal(r) |
| 165 | if err != nil { |
| 166 | return Record{}, err |
| 167 | } |
| 168 | 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} |
| 169 | return Record{Holder: n, Body: body}, nil |
| 170 | } |
| 171 | |
| 172 | func PrepareSidecar(r provision.SidecarRecord, now time.Time, region string) (Record, error) { |
| 173 | if !r.CreatedAt.IsZero() || now.IsZero() || region == "" { |
| 174 | return Record{}, ErrIdentity |
| 175 | } |
| 176 | r.CreatedAt = now.UTC() |
| 177 | body, err := json.Marshal(r) |
| 178 | if err != nil { |
| 179 | return Record{}, err |
| 180 | } |
| 181 | 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} |
| 182 | return Record{Holder: n, Body: body}, nil |
| 183 | } |
| 184 | |