| 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 | } |
| 30 | |
| 31 | // Name returns the step name. |
| 32 | func (f File) Name() string { return "file:" + f.Path } |
| 33 | |
| 34 | // Check reports a change is needed unless the remote file's content hash and |
| 35 | // mode already match the desired state. |
| 36 | func (f File) Check(fc *legatus.FlowCtx) (bool, error) { |
| 37 | match, err := fileMatches(fc, f.Path, f.Content, f.Mode) |
| 38 | if err != nil { |
| 39 | return false, err |
| 40 | } |
| 41 | return !match, nil |
| 42 | } |
| 43 | |
| 44 | // Apply uploads the content and reports the change. |
| 45 | func (f File) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 46 | if err := putFile(fc, f.Path, f.Content, f.Mode, f.Owner); err != nil { |
| 47 | return legatus.Outcome{}, err |
| 48 | } |
| 49 | return legatus.Outcome{Changed: true, Message: "wrote " + f.Path}, nil |
| 50 | } |
| 51 | |
| 52 | // fileMatches reports whether the remote file at path already has the wanted |
| 53 | // content hash and mode. A missing file (probe exit != 0) reports no match. |
| 54 | func fileMatches(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode) (bool, error) { |
| 55 | wantHash := sha256hex(content) |
| 56 | |
| 57 | res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "sha256sum", Args: []string{path}}) |
| 58 | if err != nil { |
| 59 | return false, culpa.WithCode(culpa.Wrap(err, "probe file hash"), "FILE_PROBE") |
| 60 | } |
| 61 | if res.ExitCode != 0 { |
| 62 | return false, nil // absent or unreadable: change needed. |
| 63 | } |
| 64 | gotHash := firstField(string(res.Stdout)) |
| 65 | if gotHash != wantHash { |
| 66 | return false, nil |
| 67 | } |
| 68 | |
| 69 | stat, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "stat", Args: []string{"-c", "%a", path}}) |
| 70 | if err != nil { |
| 71 | return false, culpa.WithCode(culpa.Wrap(err, "probe file mode"), "FILE_PROBE") |
| 72 | } |
| 73 | if stat.ExitCode != 0 { |
| 74 | return false, nil |
| 75 | } |
| 76 | gotMode := strings.TrimSpace(string(stat.Stdout)) |
| 77 | wantMode := strconv.FormatInt(int64(mode.Perm()), 8) |
| 78 | return gotMode == wantMode, nil |
| 79 | } |
| 80 | |
| 81 | // putFile uploads content and applies mode/owner via Put options. |
| 82 | func putFile(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode, owner string) error { |
| 83 | var opts []legatus.PutOption |
| 84 | if owner != "" { |
| 85 | opts = append(opts, legatus.WithPutOwner(owner)) |
| 86 | } |
| 87 | if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(content), path, mode, opts...); err != nil { |
| 88 | return culpa.WithCode(culpa.Wrapf(err, "put %s", path), "FILE_PUT") |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | func sha256hex(b []byte) string { |
| 94 | sum := sha256.Sum256(b) |
| 95 | return hex.EncodeToString(sum[:]) |
| 96 | } |
| 97 | |
| 98 | func firstField(s string) string { |
| 99 | fields := strings.Fields(s) |
| 100 | if len(fields) == 0 { |
| 101 | return "" |
| 102 | } |
| 103 | return fields[0] |
| 104 | } |
| 105 | |
| 106 | // runGuard runs a shell guard command and returns its exit code. It is used by |
| 107 | // Exec for Unless/OnlyIf/Creates semantics. ctx threads cancellation. |
| 108 | func runGuard(ctx context.Context, conn legatus.Conn, shell string, sudo bool) (int, error) { |
| 109 | res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", shell}, Sudo: sudo}) |
| 110 | if err != nil { |
| 111 | return 0, culpa.WithCode(culpa.Wrapf(err, "guard %q", shell), "EXEC_GUARD") |
| 112 | } |
| 113 | return res.ExitCode, nil |
| 114 | } |
| 115 | |