file.go

v0.4.0
Doc Versions Source
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

Source Files