| 1 | package hostfiles |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // This file holds what a cold admission and an installation share with |
| 15 | // region HA's relocation (stage B, region-ha-1b-next-build 73bab739): the |
| 16 | // one rule for a host that never published files, and the create-only |
| 17 | // prepared manifest an installation writes before any session exists |
| 18 | // (claim rev 6.30 R18-1, moves spec §7.6). Stage B rebases |
| 19 | // relocationPlatformItem onto PlatformItem; the values it reads from its |
| 20 | // files pin are PinnedFiles. |
| 21 | |
| 22 | // PinnedFiles is what a pin says about a host's published files: the |
| 23 | // digest of the pinned manifest and the pinner's retained copy of it. The |
| 24 | // pinner writes both for a host that published files and neither for one |
| 25 | // that never did (a holder no writer was ever admitted to, or a release |
| 26 | // flush that published nothing); a pin with one and not the other is |
| 27 | // half-made and refused before it reaches here (ReadPin, H2). |
| 28 | type PinnedFiles struct { |
| 29 | SHA256 string |
| 30 | Retained string |
| 31 | } |
| 32 | |
| 33 | // NeverPublished reports the pinner's positive evidence that the host |
| 34 | // never published files: neither a digest nor a retained copy. It is the |
| 35 | // only evidence this package accepts for an empty files state; a missing |
| 36 | // manifest, a missing pin or a missing item is never evidence (ruling 68 |
| 37 | // as amended in Task 8c: absence is never evidence). |
| 38 | func (p PinnedFiles) NeverPublished() bool { return p.SHA256 == "" && p.Retained == "" } |
| 39 | |
| 40 | // ErrNoEvidence marks a manifest without a platform item under a pin that |
| 41 | // does not evidence a host that never published files. Platform items have |
| 42 | // no tombstone (StagePlatformDelete drops the item), so a host whose env |
| 43 | // file was deleted reads the same as one whose env file was never |
| 44 | // published, and a manifest no release flush wrote (a failover's) carries |
| 45 | // no wake state however many timers the host had. Only the pin can tell |
| 46 | // them apart. |
| 47 | var ErrNoEvidence = errors.New("hostfiles: the manifest has no such platform item, and the pin does not evidence a host that never published files") |
| 48 | |
| 49 | // ErrTombstone marks a platform item recorded as deleted, which no |
| 50 | // publisher writes. |
| 51 | var ErrTombstone = errors.New("hostfiles: the manifest holds a tombstone for a platform item, which no publisher writes") |
| 52 | |
| 53 | // PlatformItem reports whether m carries the platform item name (the env |
| 54 | // file, the wake state) for a reader to restore, or the host has none. A |
| 55 | // live item is the host's. A manifest without it is "none" only when pin |
| 56 | // evidences a host that never published files (NeverPublished). Any other |
| 57 | // manifest without the item is ErrNoEvidence; a tombstone is ErrTombstone |
| 58 | // under either pin. |
| 59 | func PlatformItem(pin PinnedFiles, m Manifest, name string) (bool, error) { |
| 60 | e, ok := m.Platform[name] |
| 61 | switch { |
| 62 | case ok && !e.Deleted: |
| 63 | return true, nil |
| 64 | case ok: |
| 65 | return false, fmt.Errorf("%w: %s", ErrTombstone, name) |
| 66 | case pin.NeverPublished(): |
| 67 | return false, nil |
| 68 | default: |
| 69 | return false, fmt.Errorf("%w: %s", ErrNoEvidence, name) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // Prepared builds the prepared manifest of a generation no session has |
| 74 | // opened: an installation's initial files state, written under the |
| 75 | // authority of its operation and installation stamp, not a session. Every |
| 76 | // content object is create-only at sequence 0 under the prepared prefix, |
| 77 | // keyed by its digest, and Commit writes one create-only manifest at |
| 78 | // sequence 0. Nothing is ever replaced: a replay finds each object by its |
| 79 | // key (the same digest) and the manifest by its key, reads it back, and |
| 80 | // succeeds only when the bytes are the ones it would have written. The |
| 81 | // session-bound publisher later opens the prefix, takes this manifest as |
| 82 | // its baseline and continues from sequence 1. |
| 83 | // |
| 84 | // The binding to {location, generation, op id, installation stamp} is the |
| 85 | // caller's: the location and generation are the prefix, and the op and |
| 86 | // stamp are journalled beside the manifest's digest by the installation |
| 87 | // that wrote it. The manifest itself carries no such fields: a reader that |
| 88 | // copies it must not reinterpret it, and the pinned bytes of a relocation |
| 89 | // compare equal to the prepared copy entry for entry (stage B). |
| 90 | type Prepared struct { |
| 91 | s release.Store |
| 92 | prefix string |
| 93 | man Manifest |
| 94 | spent bool |
| 95 | } |
| 96 | |
| 97 | // NewPrepared returns a builder for prefix. It reads nothing: a manifest |
| 98 | // already at the prefix is found by Commit. |
| 99 | func NewPrepared(s release.Store, prefix string) (*Prepared, error) { |
| 100 | if s == nil { |
| 101 | return nil, errors.New("hostfiles: prepared manifest: no store") |
| 102 | } |
| 103 | if err := checkPrefix(prefix); err != nil { |
| 104 | return nil, err |
| 105 | } |
| 106 | return &Prepared{s: s, prefix: prefix, man: Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{}}}, nil |
| 107 | } |
| 108 | |
| 109 | // put writes body as a create-only object at sequence 0 under the prepared |
| 110 | // prefix. An object already at that key is the same bytes: the key is the |
| 111 | // digest. |
| 112 | func (p *Prepared) put(ctx context.Context, body []byte, mode uint32) (Entry, error) { |
| 113 | if p.spent { |
| 114 | return Entry{}, errors.New("hostfiles: prepared manifest already committed") |
| 115 | } |
| 116 | if int64(len(body)) > MaxFileBytes { |
| 117 | return Entry{}, fmt.Errorf("hostfiles: %d bytes is over the %d-byte limit", len(body), MaxFileBytes) |
| 118 | } |
| 119 | sha := sum(body) |
| 120 | key := p.prefix + objectKey(0, sha) |
| 121 | if _, err := p.s.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| 122 | return Entry{}, err |
| 123 | } |
| 124 | return Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode & 0o777, Seq: 0}, nil |
| 125 | } |
| 126 | |
| 127 | // Stage records path's content. A file over MaxFileBytes is recorded as |
| 128 | // skipped, as SyncDir records it, so the manifest still names every file. |
| 129 | func (p *Prepared) Stage(ctx context.Context, path string, body []byte, mode uint32) error { |
| 130 | clean, err := CleanPath(path) |
| 131 | if err != nil { |
| 132 | return err |
| 133 | } |
| 134 | if err := withinTreeCap(clean); err != nil { |
| 135 | return err |
| 136 | } |
| 137 | if p.spent { |
| 138 | return errors.New("hostfiles: prepared manifest already committed") |
| 139 | } |
| 140 | if int64(len(body)) > MaxFileBytes { |
| 141 | p.man.Entries[clean] = Entry{Seq: 0, Skipped: fmt.Sprintf("%d bytes is over the %d-byte limit", len(body), MaxFileBytes)} |
| 142 | return nil |
| 143 | } |
| 144 | e, err := p.put(ctx, body, mode) |
| 145 | if err != nil { |
| 146 | return err |
| 147 | } |
| 148 | p.man.Entries[clean] = e |
| 149 | return nil |
| 150 | } |
| 151 | |
| 152 | // StageSkipped records that path exists but was not published, and why: |
| 153 | // a file the builder did not read because its size is over the limit (the |
| 154 | // caller decides from the stat, before any read). |
| 155 | func (p *Prepared) StageSkipped(path, why string) error { |
| 156 | clean, err := CleanPath(path) |
| 157 | if err != nil { |
| 158 | return err |
| 159 | } |
| 160 | if err := withinTreeCap(clean); err != nil { |
| 161 | return err |
| 162 | } |
| 163 | if p.spent { |
| 164 | return errors.New("hostfiles: prepared manifest already committed") |
| 165 | } |
| 166 | p.man.Entries[clean] = Entry{Seq: 0, Skipped: why} |
| 167 | return nil |
| 168 | } |
| 169 | |
| 170 | // StagePlatform records a platform-managed item (the env file, the wake |
| 171 | // state). |
| 172 | func (p *Prepared) StagePlatform(ctx context.Context, name string, body []byte) error { |
| 173 | if name == "" || strings.ContainsAny(name, "/\x00") { |
| 174 | return fmt.Errorf("hostfiles: platform item %q is not a plain name", name) |
| 175 | } |
| 176 | e, err := p.put(ctx, body, 0o600) |
| 177 | if err != nil { |
| 178 | return err |
| 179 | } |
| 180 | p.man.Platform[name] = e |
| 181 | return nil |
| 182 | } |
| 183 | |
| 184 | // Commit writes the one prepared manifest, create-only, and returns it |
| 185 | // with its exact bytes and the version the store answered for them (the |
| 186 | // caller journals the digest and the version, so a later reader takes |
| 187 | // exactly this revision). A manifest already at the key is read back: the |
| 188 | // same bytes are a replay and success, with the version the key holds; |
| 189 | // any other manifest is ErrManifestExists and nothing is written. After |
| 190 | // Commit the builder is spent. |
| 191 | func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, string, error) { |
| 192 | if p.spent { |
| 193 | return Manifest{}, nil, "", errors.New("hostfiles: prepared manifest already committed") |
| 194 | } |
| 195 | p.man.Seq = 0 |
| 196 | p.man.Origins = []string{p.prefix} |
| 197 | body, err := json.Marshal(p.man) |
| 198 | if err != nil { |
| 199 | return Manifest{}, nil, "", fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| 200 | } |
| 201 | version, err := p.s.Put(ctx, ManifestKey(p.prefix), body, "") |
| 202 | if errors.Is(err, release.ErrCASConflict) { |
| 203 | cur, curVersion, gerr := p.s.Get(ctx, ManifestKey(p.prefix)) |
| 204 | if gerr != nil { |
| 205 | return Manifest{}, nil, "", gerr |
| 206 | } |
| 207 | if !bytes.Equal(cur, body) { |
| 208 | return Manifest{}, nil, "", fmt.Errorf("%w: %s", ErrManifestExists, p.prefix) |
| 209 | } |
| 210 | version = curVersion |
| 211 | } else if err != nil { |
| 212 | return Manifest{}, nil, "", err |
| 213 | } |
| 214 | p.spent = true |
| 215 | return p.man, body, version, nil |
| 216 | } |
| 217 | |
| 218 | // NewFromCopy is a publisher for a generation that has no manifest yet, |
| 219 | // seeded with base: a validated manifest of an admitted predecessor, read |
| 220 | // by the caller at its exact version, or an empty one for the claim's own |
| 221 | // empty bootstrap or a host the pin evidences never published files. Every |
| 222 | // entry keeps its origin-qualified object (nothing is uploaded again) and |
| 223 | // the copy carries base's origin set (the caller sets the new one with |
| 224 | // SetOrigins); new objects go under prefix from base.Seq+1; and the first Commit is |
| 225 | // create-only, so a repeat reads its own bytes back and another manifest |
| 226 | // at the prefix is ErrSuperseded, never replaced. It is CopyManifest with |
| 227 | // the caller's own changes in the same revision: a cold admission's |
| 228 | // establishment stages the env file and drops the adoption item before it |
| 229 | // commits (claim §6.2a P6-D3; instance key O1'). |
| 230 | // |
| 231 | // origins are the prefixes base's entries may reference (nil or empty: |
| 232 | // prefix alone). base is validated before anything is written. |
| 233 | func NewFromCopy(s release.Store, prefix string, base Manifest, origins []string) (*Publisher, error) { |
| 234 | if s == nil { |
| 235 | return nil, errors.New("hostfiles: copy: no store") |
| 236 | } |
| 237 | if err := checkPrefix(prefix); err != nil { |
| 238 | return nil, err |
| 239 | } |
| 240 | origins, err := admitted(prefix, origins) |
| 241 | if err != nil { |
| 242 | return nil, err |
| 243 | } |
| 244 | if base.V != ManifestVersion { |
| 245 | return nil, fmt.Errorf("hostfiles: manifest is version %d; this binary reads %d", base.V, ManifestVersion) |
| 246 | } |
| 247 | if err := checkManifest(base, origins); err != nil { |
| 248 | return nil, err |
| 249 | } |
| 250 | man := Manifest{V: ManifestVersion, Seq: base.Seq, Entries: map[string]Entry{}, Platform: map[string]Entry{}, Origins: append([]string(nil), base.Origins...)} |
| 251 | for k, e := range base.Entries { |
| 252 | man.Entries[k] = e |
| 253 | } |
| 254 | for k, e := range base.Platform { |
| 255 | man.Platform[k] = e |
| 256 | } |
| 257 | return &Publisher{s: s, prefix: prefix, man: man, next: base.Seq, dirty: true}, nil |
| 258 | } |
| 259 | |