| 1 | package steps |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "io/fs" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | |
| 10 | "go.bigb.es/auxilia/culpa" |
| 11 | "go.bigb.es/auxilia/legatus" |
| 12 | ) |
| 13 | |
| 14 | // LocalMint mints a LOCAL file once from a remote command, then reuses it. Every |
| 15 | // other step in this package manages a REMOTE file; LocalMint is the inverse — its |
| 16 | // target lives on the machine running the Flow. It models "derive a local artifact |
| 17 | // from something only the remote host can produce" (a generated key, a fetched |
| 18 | // certificate, an exported config): expensive to produce, cheap to keep. |
| 19 | // |
| 20 | // - Check is a local stat: apply is needed when Path is missing, or when a Stale |
| 21 | // predicate flags the existing bytes (e.g. an always-true predicate for an |
| 22 | // artifact that must be regenerated every run, or a content test for a back-fill). |
| 23 | // - Apply reads any existing bytes, hands them to Produce (so a back-fill producer |
| 24 | // can preserve them), runs the producer over the Conn, and writes the result with |
| 25 | // an atomic temp+rename at Mode. A Produce error aborts with nothing written. |
| 26 | // |
| 27 | // Path, Mode, Stale, and Produce are all caller-supplied; Mode must be set (a zero |
| 28 | // Mode writes a 0000 file). LocalMint itself is transport- and content-agnostic. |
| 29 | type LocalMint struct { |
| 30 | Path string |
| 31 | Mode legatus.FileMode |
| 32 | Stale func(existing []byte) bool |
| 33 | Produce func(ctx context.Context, conn legatus.Conn, existing []byte) ([]byte, error) |
| 34 | } |
| 35 | |
| 36 | // Name returns the step name (the local path it owns). |
| 37 | func (m LocalMint) Name() string { return "mint:" + m.Path } |
| 38 | |
| 39 | // Check reports apply-needed when Path is missing or Stale flags the existing |
| 40 | // bytes. A read error other than not-exist is a hard failure (no regenerate over an |
| 41 | // unreadable file). |
| 42 | func (m LocalMint) Check(*legatus.FlowCtx) (bool, error) { |
| 43 | b, err := os.ReadFile(m.Path) |
| 44 | if errors.Is(err, fs.ErrNotExist) { |
| 45 | return true, nil |
| 46 | } |
| 47 | if err != nil { |
| 48 | return false, culpa.WithCode(culpa.Wrapf(err, "mint: read %s", m.Path), "LOCAL_MINT") |
| 49 | } |
| 50 | if m.Stale != nil && m.Stale(b) { |
| 51 | return true, nil |
| 52 | } |
| 53 | return false, nil |
| 54 | } |
| 55 | |
| 56 | // Apply reads any existing bytes, runs Produce over the Conn, and writes the result |
| 57 | // atomically at Mode. |
| 58 | func (m LocalMint) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 59 | if m.Produce == nil { |
| 60 | return legatus.Outcome{}, culpa.WithCode( |
| 61 | culpa.Errorf("mint %s: no Produce set", m.Path), "LOCAL_MINT") |
| 62 | } |
| 63 | |
| 64 | var existing []byte |
| 65 | if b, err := os.ReadFile(m.Path); err == nil { |
| 66 | existing = b |
| 67 | } else if !errors.Is(err, fs.ErrNotExist) { |
| 68 | return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "mint: read %s", m.Path), "LOCAL_MINT") |
| 69 | } |
| 70 | |
| 71 | out, err := m.Produce(fc.Ctx(), fc.Conn(), existing) |
| 72 | if err != nil { |
| 73 | return legatus.Outcome{}, err |
| 74 | } |
| 75 | if err := mintWrite(m.Path, out, m.Mode); err != nil { |
| 76 | return legatus.Outcome{}, err |
| 77 | } |
| 78 | return legatus.Outcome{Changed: true, Message: "minted " + filepath.Base(m.Path)}, nil |
| 79 | } |
| 80 | |
| 81 | // mintWrite writes data to path via a temp file in the same dir + rename, so a |
| 82 | // crash mid-write never leaves a half-written artifact. Mirrors legatus/local's |
| 83 | // atomicWrite (CreateTemp → write → close → chmod-by-path → rename). |
| 84 | func mintWrite(path string, data []byte, mode legatus.FileMode) error { |
| 85 | dir := filepath.Dir(path) |
| 86 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 87 | return culpa.WithCode(culpa.Wrapf(err, "mint: mkdir %s", dir), "LOCAL_MINT") |
| 88 | } |
| 89 | tmp, err := os.CreateTemp(dir, ".legatus-mint-*") |
| 90 | if err != nil { |
| 91 | return culpa.WithCode(culpa.Wrapf(err, "mint: temp in %s", dir), "LOCAL_MINT") |
| 92 | } |
| 93 | tmpName := tmp.Name() |
| 94 | if _, err := tmp.Write(data); err != nil { |
| 95 | _ = tmp.Close() |
| 96 | _ = os.Remove(tmpName) |
| 97 | return culpa.WithCode(culpa.Wrapf(err, "mint: write %s", tmpName), "LOCAL_MINT") |
| 98 | } |
| 99 | if err := tmp.Close(); err != nil { |
| 100 | _ = os.Remove(tmpName) |
| 101 | return culpa.WithCode(culpa.Wrapf(err, "mint: close %s", tmpName), "LOCAL_MINT") |
| 102 | } |
| 103 | if err := os.Chmod(tmpName, mode); err != nil { |
| 104 | _ = os.Remove(tmpName) |
| 105 | return culpa.WithCode(culpa.Wrapf(err, "mint: chmod %s", tmpName), "LOCAL_MINT") |
| 106 | } |
| 107 | if err := os.Rename(tmpName, path); err != nil { |
| 108 | _ = os.Remove(tmpName) |
| 109 | return culpa.WithCode(culpa.Wrapf(err, "mint: rename into %s", path), "LOCAL_MINT") |
| 110 | } |
| 111 | return nil |
| 112 | } |
| 113 | |