file.go

v0.5.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
	// 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

Source Files