validatedfile.go

v0.6.1
Doc Versions Source
1
package steps
2
3
import (
4
	"bytes"
5
	"path"
6
	"strings"
7
8
	"go.bigb.es/auxilia/culpa"
9
	"go.bigb.es/auxilia/legatus"
10
)
11
12
// ValidatedFile writes a file only after a validator command passes against the
13
// exact bytes to be installed. It stages the content beside the target, runs the
14
// validator on the staged path, and commits with an atomic same-directory move
15
// on success; on failure the staged file is removed and the live target is left
16
// untouched. Use it for configs that must pass a checker before a reload
17
// (sing-box check -c, haproxy -c -f). Unlike File, it supports sudo writes.
18
type ValidatedFile struct {
19
	Path    string
20
	Content []byte
21
	Mode    legatus.FileMode
22
	Owner   string
23
	Sudo    bool
24
	// Validator returns the command that validates the staged file at the given
25
	// path; a non-zero exit aborts the write. It runs with Sudo when Sudo is set.
26
	Validator func(stagedPath string) legatus.Cmd
27
	// Normalize, when set, changes Check to compare the remote file and Content
28
	// AFTER applying it to both — so a byte-different but semantically-equal file
29
	// reports no change (and so no rewrite/validation). nil ⇒ content+mode hash.
30
	Normalize func([]byte) []byte
31
}
32
33
// Name returns the step name.
34
func (f ValidatedFile) Name() string { return "validated-file:" + f.Path }
35
36
// Check reuses File's content+mode hash idempotency (or Normalize-equality when
37
// set): a remote file already matching needs no change, and so no validation.
38
func (f ValidatedFile) Check(fc *legatus.FlowCtx) (bool, error) {
39
	if f.Normalize != nil {
40
		eq, err := normalizedMatch(fc, f.Path, f.Content, f.Normalize, f.Sudo)
41
		if err != nil {
42
			return false, err
43
		}
44
		if eq {
45
			return false, nil
46
		}
47
		// Not equal OR unreadable: fall through to the hash compare.
48
	}
49
	match, err := fileMatches(fc, f.Path, f.Content, f.Mode)
50
	if err != nil {
51
		return false, err
52
	}
53
	return !match, nil
54
}
55
56
// Apply stages the content beside the target, validates the staged path, and on
57
// success moves it into place; on validator failure it removes the staged file
58
// and errors, leaving the live target unchanged.
59
func (f ValidatedFile) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
60
	if f.Validator == nil {
61
		return legatus.Outcome{}, culpa.WithCode(
62
			culpa.Errorf("validated-file %s: no Validator set", f.Path), "VALIDATED_FILE")
63
	}
64
	staged := stagedPath(f.Path)
65
66
	var putOpts []legatus.PutOption
67
	if f.Sudo {
68
		putOpts = append(putOpts, legatus.WithPutSudo())
69
	}
70
	if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(f.Content), staged, f.Mode, putOpts...); err != nil {
71
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "stage %s", staged), "VALIDATED_FILE")
72
	}
73
74
	vcmd := f.Validator(staged)
75
	vcmd.Sudo = vcmd.Sudo || f.Sudo
76
	res, err := fc.Conn().Exec(fc.Ctx(), vcmd)
77
	if err != nil {
78
		f.cleanup(fc, staged)
79
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "validate %s", f.Path), "VALIDATED_FILE")
80
	}
81
	if res.ExitCode != 0 {
82
		f.cleanup(fc, staged)
83
		return legatus.Outcome{}, culpa.WithCode(
84
			culpa.Errorf("validated-file %s: validation failed: exit %d: %s",
85
				f.Path, res.ExitCode, strings.TrimSpace(string(res.Combined()))), "VALIDATED_FILE")
86
	}
87
88
	if err := f.run(fc, legatus.Cmd{Path: "mv", Args: []string{staged, f.Path}, Sudo: f.Sudo}); err != nil {
89
		f.cleanup(fc, staged)
90
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "commit %s", f.Path), "VALIDATED_FILE")
91
	}
92
	if f.Owner != "" {
93
		if err := f.run(fc, legatus.Cmd{Path: "chown", Args: []string{f.Owner, f.Path}, Sudo: f.Sudo}); err != nil {
94
			return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "chown %s", f.Path), "VALIDATED_FILE")
95
		}
96
	}
97
	return legatus.Outcome{Changed: true, Message: "validated + wrote " + f.Path}, nil
98
}
99
100
// run executes an Apply-path command that must succeed, turning a non-zero exit
101
// into an error.
102
func (f ValidatedFile) run(fc *legatus.FlowCtx, cmd legatus.Cmd) error {
103
	res, err := fc.Conn().Exec(fc.Ctx(), cmd)
104
	if err != nil {
105
		return err
106
	}
107
	if res.ExitCode != 0 {
108
		return culpa.Errorf("%s exit %d: %s", cmd.Path, res.ExitCode, strings.TrimSpace(string(res.Combined())))
109
	}
110
	return nil
111
}
112
113
// cleanup removes the staged file after a failed validation or commit. A cleanup
114
// error is deliberately not returned so the original failure is the one surfaced
115
// (mirrors the transport's staging-cleanup on error paths).
116
func (f ValidatedFile) cleanup(fc *legatus.FlowCtx, staged string) {
117
	_, _ = fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "rm", Args: []string{"-f", staged}, Sudo: f.Sudo})
118
}
119
120
// stagedPath derives a dotfile staging path beside p — same directory, so the
121
// commit move is atomic on the target filesystem.
122
func stagedPath(p string) string {
123
	dir, base := path.Split(p)
124
	return dir + "." + base + ".legatus-new"
125
}
126

Source Files