package hostfiles import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "github.com/carlosframework/platform/internal/release" ) // This file holds the entry points other packages call: region HA's claim // (CopyManifest, at a new generation's admission) and moves (Publish, at // install). Both are dormant: nothing in a production path calls them yet. // ErrManifestExists means CopyManifest's target already holds a different // manifest. The copy wrote nothing. var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest") // ErrSourceMoved means CopyManifest's source is not at the Seq the caller // expected. The copy wrote nothing. var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq") // CopyManifest establishes toPrefix's manifest as an exact copy of // fromPrefix's: the same sequence number and the same entries, each still // naming its object by its origin-qualified key. No object is copied; the // new generation's publisher continues the sequence from the copy and // writes new objects under toPrefix only. // // origins are the prefixes the source's entries may reference, as for // Restore (nil or empty: fromPrefix alone). A source built by earlier // copies references earlier generations, so its caller passes the chain // its pin admits. Every entry is validated before anything is written; an // absent source is os.ErrNotExist, never an empty copy. // // expectSeq, when not 0, is the source revision the caller admitted. A // source at any other Seq is ErrSourceMoved and nothing is written, on a // first attempt or a retry. 0 copies whatever the source holds. // // The write is create-only. A target that already holds exactly the // manifest this call would write (a repeat whose first response was lost) // is success; any other target is ErrManifestExists and is left as it was. // That retry-safety holds only while the source cannot commit between // attempts: the source generation must be fenced or frozen, as the claim's // admission makes it. Against a live source a retry may find the target // holding an older revision and report ErrManifestExists; with expectSeq // set it reports ErrSourceMoved instead, and never copies a revision the // caller did not admit. func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, expectSeq int64, origins []string) (Manifest, error) { if err := checkPrefix(fromPrefix); err != nil { return Manifest{}, err } if err := checkPrefix(toPrefix); err != nil { return Manifest{}, err } if fromPrefix == toPrefix { return Manifest{}, fmt.Errorf("hostfiles: copy %s onto itself", fromPrefix) } origins, err := admitted(fromPrefix, origins) if err != nil { return Manifest{}, err } m, _, found, err := load(ctx, s, fromPrefix) if err != nil { return Manifest{}, err } if !found { return Manifest{}, fmt.Errorf("hostfiles: copy from %s: %w", fromPrefix, os.ErrNotExist) } if expectSeq != 0 && m.Seq != expectSeq { return Manifest{}, fmt.Errorf("%w: %s is at seq %d, not %d", ErrSourceMoved, fromPrefix, m.Seq, expectSeq) } if err := checkManifest(m, origins); err != nil { return Manifest{}, err } body, err := json.Marshal(m) if err != nil { return Manifest{}, fmt.Errorf("hostfiles: marshal manifest: %w", err) } if _, err := s.Put(ctx, ManifestKey(toPrefix), body, ""); errors.Is(err, release.ErrCASConflict) { cur, _, gerr := s.Get(ctx, ManifestKey(toPrefix)) if gerr != nil { return Manifest{}, gerr } if !bytes.Equal(cur, body) { return Manifest{}, fmt.Errorf("%w: %s", ErrManifestExists, toPrefix) } } else if err != nil { return Manifest{}, err } return m, nil } // Publish records dir as prefix's manifest in one revision and returns // that revision's sequence number: one SyncDir and one Commit on a fresh // Publisher. It is the call moves makes at install, after the directory is // in place and before the installation is recorded. // // skip is SyncDir's: it excludes a relative path and, for a directory, // everything under it. On success every regular file in dir that skip does // not exclude is in the manifest: published, or recorded as skipped when // it is larger than MaxFileBytes (st.TooLarge). Anything that is not a // regular file is left out and listed in st.NotRegular. // // Publish is an install-time contract, so it commits a complete and // consistent image or nothing. It returns an error, and commits nothing, // when any file could not be read (st.Unreadable), was replaced between // the look and the open (st.Retry), or changed while it was read // (st.Torn). The manifest would otherwise lack the file or hold bytes that // were never the file's state. The caller stops writers and calls Publish // again. Objects uploaded by a refused attempt are referenced by no // manifest. // // A prefix that already has a manifest is continued, not replaced: a // repeat with nothing changed returns the same sequence number. Another // writer's manifest appearing between Open and Commit is ErrSuperseded. // Platform items (wake state, an env file) are not part of this call; a // caller that needs them in the same revision uses Open, StagePlatform, // SyncDir and Commit itself. func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error) { p, err := Open(ctx, s, prefix) if err != nil { return 0, SyncStats{}, err } st, err := SyncDir(ctx, p, dir, skip) if err != nil { return 0, st, err } if len(st.Unreadable) > 0 { return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) could not be read: %q", dir, len(st.Unreadable), st.Unreadable) } if len(st.Retry) > 0 { return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) were replaced while being looked at: %q", dir, len(st.Retry), st.Retry) } if len(st.Torn) > 0 { return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) changed while being read: %q", dir, len(st.Torn), st.Torn) } seq, err := p.Commit(ctx) return seq, st, err }