| 1 | // Package steps provides a battery of common legatus steps: file, validated-file, |
| 2 | // template, service, exec, package, command, and local-mint (the inverse of the |
| 3 | // others — derive a LOCAL artifact from a remote command), plus the When/OnChanged |
| 4 | // gating wrappers (run a step only when an earlier step changed). Each type |
| 5 | // satisfies legatus.Step and keeps its idempotency logic inside Check (PC4) so the |
| 6 | // engine never guesses. |
| 7 | package steps |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "context" |
| 12 | "crypto/sha256" |
| 13 | "encoding/hex" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | |
| 17 | "go.bigb.es/auxilia/culpa" |
| 18 | "go.bigb.es/auxilia/legatus" |
| 19 | ) |
| 20 | |
| 21 | // File ensures a remote file has the given content, mode, and owner. Check |
| 22 | // compares the remote SHA-256 hash and octal mode against the desired state; |
| 23 | // Apply uploads the content and applies mode/owner. |
| 24 | type File struct { |
| 25 | Path string |
| 26 | Content []byte |
| 27 | Mode legatus.FileMode |
| 28 | Owner string |
| 29 | // Sudo controls privilege-wrapping of the upload (and the Normalize read); the |
| 30 | // zero value (SudoDefault) is no sudo, matching the previous behavior. |
| 31 | Sudo SudoPolicy |
| 32 | // Normalize, when set, changes Check to compare the remote file and Content |
| 33 | // AFTER applying it to both — so a byte-different but semantically-equal file |
| 34 | // (e.g. reformatted) reports no change. nil ⇒ raw content+mode hash idempotency. |
| 35 | Normalize func([]byte) []byte |
| 36 | } |
| 37 | |
| 38 | // Name returns the step name. |
| 39 | func (f File) Name() string { return "file:" + f.Path } |
| 40 | |
| 41 | // Check reports a change is needed unless the remote file already matches the |
| 42 | // desired state — by Normalize-equality when set, else content hash + mode. |
| 43 | func (f File) Check(fc *legatus.FlowCtx) (bool, error) { |
| 44 | if f.Normalize != nil { |
| 45 | eq, err := normalizedMatch(fc, f.Path, f.Content, f.Normalize, f.Sudo.resolve(fc.Host(), false)) |
| 46 | if err != nil { |
| 47 | return false, err |
| 48 | } |
| 49 | if eq { |
| 50 | return false, nil |
| 51 | } |
| 52 | // Not equal OR unreadable: fall through to the hash compare, which reports |
| 53 | // apply-needed when the file is absent (no silent skip). |
| 54 | } |
| 55 | match, err := fileMatches(fc, f.Path, f.Content, f.Mode) |
| 56 | if err != nil { |
| 57 | return false, err |
| 58 | } |
| 59 | return !match, nil |
| 60 | } |
| 61 | |
| 62 | // Apply uploads the content and reports the change. |
| 63 | func (f File) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 64 | if err := putFile(fc, f.Path, f.Content, f.Mode, f.Owner, f.Sudo.resolve(fc.Host(), false)); err != nil { |
| 65 | return legatus.Outcome{}, err |
| 66 | } |
| 67 | return legatus.Outcome{Changed: true, Message: "wrote " + f.Path}, nil |
| 68 | } |
| 69 | |
| 70 | // fileMatches reports whether the remote file at path already has the wanted |
| 71 | // content hash and mode. A missing file (probe exit != 0) reports no match. |
| 72 | func fileMatches(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode) (bool, error) { |
| 73 | wantHash := sha256hex(content) |
| 74 | |
| 75 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "sha256sum", Args: []string{path}}) |
| 76 | if err != nil { |
| 77 | return false, culpa.WithCode(culpa.Wrap(err, "probe file hash"), "FILE_PROBE") |
| 78 | } |
| 79 | if res.ExitCode != 0 { |
| 80 | return false, nil // absent or unreadable: change needed. |
| 81 | } |
| 82 | gotHash := firstField(string(res.Stdout)) |
| 83 | if gotHash != wantHash { |
| 84 | return false, nil |
| 85 | } |
| 86 | |
| 87 | stat, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "stat", Args: []string{"-c", "%a", path}}) |
| 88 | if err != nil { |
| 89 | return false, culpa.WithCode(culpa.Wrap(err, "probe file mode"), "FILE_PROBE") |
| 90 | } |
| 91 | if stat.ExitCode != 0 { |
| 92 | return false, nil |
| 93 | } |
| 94 | gotMode := strings.TrimSpace(string(stat.Stdout)) |
| 95 | wantMode := strconv.FormatInt(int64(mode.Perm()), 8) |
| 96 | return gotMode == wantMode, nil |
| 97 | } |
| 98 | |
| 99 | // putFile uploads content and applies mode/owner via Put options, sudo-staging |
| 100 | // when sudo is set (non-root write to a privileged path). |
| 101 | func putFile(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode, owner string, sudo bool) error { |
| 102 | var opts []legatus.PutOption |
| 103 | if owner != "" { |
| 104 | opts = append(opts, legatus.WithPutOwner(owner)) |
| 105 | } |
| 106 | if sudo { |
| 107 | opts = append(opts, legatus.WithPutSudo()) |
| 108 | } |
| 109 | if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(content), path, mode, opts...); err != nil { |
| 110 | return culpa.WithCode(culpa.Wrapf(err, "put %s", path), "FILE_PUT") |
| 111 | } |
| 112 | return nil |
| 113 | } |
| 114 | |
| 115 | // normalizedMatch reports whether the remote file at path equals content after |
| 116 | // applying normalize to both sides. A missing or unreadable remote file returns |
| 117 | // (false, nil) so the caller falls back to the hash compare (apply-needed). |
| 118 | func normalizedMatch(fc *legatus.FlowCtx, path string, content []byte, normalize func([]byte) []byte, sudo bool) (bool, error) { |
| 119 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "cat", Args: []string{path}, Sudo: sudo}) |
| 120 | if err != nil { |
| 121 | return false, culpa.WithCode(culpa.Wrapf(err, "read %s", path), "FILE_PROBE") |
| 122 | } |
| 123 | if res.ExitCode != 0 { |
| 124 | return false, nil |
| 125 | } |
| 126 | return bytes.Equal(normalize(res.Stdout), normalize(content)), nil |
| 127 | } |
| 128 | |
| 129 | func sha256hex(b []byte) string { |
| 130 | sum := sha256.Sum256(b) |
| 131 | return hex.EncodeToString(sum[:]) |
| 132 | } |
| 133 | |
| 134 | func firstField(s string) string { |
| 135 | fields := strings.Fields(s) |
| 136 | if len(fields) == 0 { |
| 137 | return "" |
| 138 | } |
| 139 | return fields[0] |
| 140 | } |
| 141 | |
| 142 | // runGuard runs a shell guard command and returns its exit code. It is used by |
| 143 | // Exec for Unless/OnlyIf/Creates semantics. ctx threads cancellation. |
| 144 | func runGuard(ctx context.Context, conn legatus.Conn, shell string, sudo bool) (int, error) { |
| 145 | res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", shell}, Sudo: sudo}) |
| 146 | if err != nil { |
| 147 | return 0, culpa.WithCode(culpa.Wrapf(err, "guard %q", shell), "EXEC_GUARD") |
| 148 | } |
| 149 | return res.ExitCode, nil |
| 150 | } |
| 151 | |