file.go

v0.3.0
Doc Versions Source
1
// Package steps provides a battery of common legatus steps: file, validated-file,
2
// template, service, exec, package, and command, plus the When/OnChanged gating
3
// wrappers (run a step only when an earlier step changed). Each type satisfies
4
// legatus.Step and keeps its idempotency logic inside Check (PC4) so the engine
5
// never guesses.
6
package steps
7
8
import (
9
	"bytes"
10
	"context"
11
	"crypto/sha256"
12
	"encoding/hex"
13
	"strconv"
14
	"strings"
15
16
	"go.bigb.es/auxilia/culpa"
17
	"go.bigb.es/auxilia/legatus"
18
)
19
20
// File ensures a remote file has the given content, mode, and owner. Check
21
// compares the remote SHA-256 hash and octal mode against the desired state;
22
// Apply uploads the content and applies mode/owner.
23
type File struct {
24
	Path    string
25
	Content []byte
26
	Mode    legatus.FileMode
27
	Owner   string
28
}
29
30
// Name returns the step name.
31
func (f File) Name() string { return "file:" + f.Path }
32
33
// Check reports a change is needed unless the remote file's content hash and
34
// mode already match the desired state.
35
func (f File) Check(fc *legatus.FlowCtx) (bool, error) {
36
	match, err := fileMatches(fc, f.Path, f.Content, f.Mode)
37
	if err != nil {
38
		return false, err
39
	}
40
	return !match, nil
41
}
42
43
// Apply uploads the content and reports the change.
44
func (f File) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
45
	if err := putFile(fc, f.Path, f.Content, f.Mode, f.Owner); err != nil {
46
		return legatus.Outcome{}, err
47
	}
48
	return legatus.Outcome{Changed: true, Message: "wrote " + f.Path}, nil
49
}
50
51
// fileMatches reports whether the remote file at path already has the wanted
52
// content hash and mode. A missing file (probe exit != 0) reports no match.
53
func fileMatches(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode) (bool, error) {
54
	wantHash := sha256hex(content)
55
56
	res, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "sha256sum", Args: []string{path}})
57
	if err != nil {
58
		return false, culpa.WithCode(culpa.Wrap(err, "probe file hash"), "FILE_PROBE")
59
	}
60
	if res.ExitCode != 0 {
61
		return false, nil // absent or unreadable: change needed.
62
	}
63
	gotHash := firstField(string(res.Stdout))
64
	if gotHash != wantHash {
65
		return false, nil
66
	}
67
68
	stat, err := fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "stat", Args: []string{"-c", "%a", path}})
69
	if err != nil {
70
		return false, culpa.WithCode(culpa.Wrap(err, "probe file mode"), "FILE_PROBE")
71
	}
72
	if stat.ExitCode != 0 {
73
		return false, nil
74
	}
75
	gotMode := strings.TrimSpace(string(stat.Stdout))
76
	wantMode := strconv.FormatInt(int64(mode.Perm()), 8)
77
	return gotMode == wantMode, nil
78
}
79
80
// putFile uploads content and applies mode/owner via Put options.
81
func putFile(fc *legatus.FlowCtx, path string, content []byte, mode legatus.FileMode, owner string) error {
82
	var opts []legatus.PutOption
83
	if owner != "" {
84
		opts = append(opts, legatus.WithPutOwner(owner))
85
	}
86
	if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(content), path, mode, opts...); err != nil {
87
		return culpa.WithCode(culpa.Wrapf(err, "put %s", path), "FILE_PUT")
88
	}
89
	return nil
90
}
91
92
func sha256hex(b []byte) string {
93
	sum := sha256.Sum256(b)
94
	return hex.EncodeToString(sum[:])
95
}
96
97
func firstField(s string) string {
98
	fields := strings.Fields(s)
99
	if len(fields) == 0 {
100
		return ""
101
	}
102
	return fields[0]
103
}
104
105
// runGuard runs a shell guard command and returns its exit code. It is used by
106
// Exec for Unless/OnlyIf/Creates semantics. ctx threads cancellation.
107
func runGuard(ctx context.Context, conn legatus.Conn, shell string, sudo bool) (int, error) {
108
	res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", shell}, Sudo: sudo})
109
	if err != nil {
110
		return 0, culpa.WithCode(culpa.Wrapf(err, "guard %q", shell), "EXEC_GUARD")
111
	}
112
	return res.ExitCode, nil
113
}
114

Source Files