| 1 | package hostfiles |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // This file holds the entry points other packages call: region HA's claim |
| 15 | // (CopyManifest, at a new generation's admission) and moves (Publish, at |
| 16 | // install). Both are dormant: nothing in a production path calls them yet. |
| 17 | |
| 18 | // ErrManifestExists means CopyManifest's target already holds a different |
| 19 | // manifest. The copy wrote nothing. |
| 20 | var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest") |
| 21 | |
| 22 | // ErrSourceMoved means CopyManifest's source is not at the Seq the caller |
| 23 | // expected. The copy wrote nothing. |
| 24 | var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq") |
| 25 | |
| 26 | // CopyManifest establishes toPrefix's manifest as an exact copy of |
| 27 | // fromPrefix's: the same sequence number and the same entries, each still |
| 28 | // naming its object by its origin-qualified key. No object is copied; the |
| 29 | // new generation's publisher continues the sequence from the copy and |
| 30 | // writes new objects under toPrefix only. |
| 31 | // |
| 32 | // origins are the prefixes the source's entries may reference, as for |
| 33 | // Restore (nil or empty: fromPrefix alone). A source built by earlier |
| 34 | // copies references earlier generations, so its caller passes the chain |
| 35 | // its pin admits. Every entry is validated before anything is written; an |
| 36 | // absent source is os.ErrNotExist, never an empty copy. |
| 37 | // |
| 38 | // expectSeq, when not 0, is the source revision the caller admitted. A |
| 39 | // source at any other Seq is ErrSourceMoved and nothing is written, on a |
| 40 | // first attempt or a retry. 0 copies whatever the source holds. |
| 41 | // |
| 42 | // The write is create-only. A target that already holds exactly the |
| 43 | // manifest this call would write (a repeat whose first response was lost) |
| 44 | // is success; any other target is ErrManifestExists and is left as it was. |
| 45 | // That retry-safety holds only while the source cannot commit between |
| 46 | // attempts: the source generation must be fenced or frozen, as the claim's |
| 47 | // admission makes it. Against a live source a retry may find the target |
| 48 | // holding an older revision and report ErrManifestExists; with expectSeq |
| 49 | // set it reports ErrSourceMoved instead, and never copies a revision the |
| 50 | // caller did not admit. |
| 51 | func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, expectSeq int64, origins []string) (Manifest, error) { |
| 52 | if err := checkPrefix(fromPrefix); err != nil { |
| 53 | return Manifest{}, err |
| 54 | } |
| 55 | if err := checkPrefix(toPrefix); err != nil { |
| 56 | return Manifest{}, err |
| 57 | } |
| 58 | if fromPrefix == toPrefix { |
| 59 | return Manifest{}, fmt.Errorf("hostfiles: copy %s onto itself", fromPrefix) |
| 60 | } |
| 61 | origins, err := admitted(fromPrefix, origins) |
| 62 | if err != nil { |
| 63 | return Manifest{}, err |
| 64 | } |
| 65 | m, _, found, err := load(ctx, s, fromPrefix) |
| 66 | if err != nil { |
| 67 | return Manifest{}, err |
| 68 | } |
| 69 | if !found { |
| 70 | return Manifest{}, fmt.Errorf("hostfiles: copy from %s: %w", fromPrefix, os.ErrNotExist) |
| 71 | } |
| 72 | if expectSeq != 0 && m.Seq != expectSeq { |
| 73 | return Manifest{}, fmt.Errorf("%w: %s is at seq %d, not %d", ErrSourceMoved, fromPrefix, m.Seq, expectSeq) |
| 74 | } |
| 75 | if err := checkManifest(m, origins); err != nil { |
| 76 | return Manifest{}, err |
| 77 | } |
| 78 | body, err := json.Marshal(m) |
| 79 | if err != nil { |
| 80 | return Manifest{}, fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| 81 | } |
| 82 | if _, err := s.Put(ctx, ManifestKey(toPrefix), body, ""); errors.Is(err, release.ErrCASConflict) { |
| 83 | cur, _, gerr := s.Get(ctx, ManifestKey(toPrefix)) |
| 84 | if gerr != nil { |
| 85 | return Manifest{}, gerr |
| 86 | } |
| 87 | if !bytes.Equal(cur, body) { |
| 88 | return Manifest{}, fmt.Errorf("%w: %s", ErrManifestExists, toPrefix) |
| 89 | } |
| 90 | } else if err != nil { |
| 91 | return Manifest{}, err |
| 92 | } |
| 93 | return m, nil |
| 94 | } |
| 95 | |
| 96 | // Publish records dir as prefix's manifest in one revision and returns |
| 97 | // that revision's sequence number: one SyncDir and one Commit on a fresh |
| 98 | // Publisher. It is the call moves makes at install, after the directory is |
| 99 | // in place and before the installation is recorded. |
| 100 | // |
| 101 | // skip is SyncDir's: it excludes a relative path and, for a directory, |
| 102 | // everything under it. On success every regular file in dir that skip does |
| 103 | // not exclude is in the manifest: published, or recorded as skipped when |
| 104 | // it is larger than MaxFileBytes (st.TooLarge). Anything that is not a |
| 105 | // regular file is left out and listed in st.NotRegular. |
| 106 | // |
| 107 | // Publish is an install-time contract, so it commits a complete and |
| 108 | // consistent image or nothing. It returns an error, and commits nothing, |
| 109 | // when any file could not be read (st.Unreadable), was replaced between |
| 110 | // the look and the open (st.Retry), or changed while it was read |
| 111 | // (st.Torn). The manifest would otherwise lack the file or hold bytes that |
| 112 | // were never the file's state. The caller stops writers and calls Publish |
| 113 | // again. Objects uploaded by a refused attempt are referenced by no |
| 114 | // manifest. |
| 115 | // |
| 116 | // A prefix that already has a manifest is continued, not replaced: a |
| 117 | // repeat with nothing changed returns the same sequence number. Another |
| 118 | // writer's manifest appearing between Open and Commit is ErrSuperseded. |
| 119 | // Platform items (wake state, an env file) are not part of this call; a |
| 120 | // caller that needs them in the same revision uses Open, StagePlatform, |
| 121 | // SyncDir and Commit itself. |
| 122 | func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error) { |
| 123 | p, err := Open(ctx, s, prefix) |
| 124 | if err != nil { |
| 125 | return 0, SyncStats{}, err |
| 126 | } |
| 127 | st, err := SyncDir(ctx, p, dir, skip) |
| 128 | if err != nil { |
| 129 | return 0, st, err |
| 130 | } |
| 131 | if len(st.Unreadable) > 0 { |
| 132 | return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) could not be read: %q", dir, len(st.Unreadable), st.Unreadable) |
| 133 | } |
| 134 | if len(st.Retry) > 0 { |
| 135 | return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) were replaced while being looked at: %q", dir, len(st.Retry), st.Retry) |
| 136 | } |
| 137 | if len(st.Torn) > 0 { |
| 138 | return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) changed while being read: %q", dir, len(st.Torn), st.Torn) |
| 139 | } |
| 140 | seq, err := p.Commit(ctx) |
| 141 | return seq, st, err |
| 142 | } |
| 143 | |