validatedfile.go

v0.3.0
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
}
28
29
// Name returns the step name.
30
func (f ValidatedFile) Name() string { return "validated-file:" + f.Path }
31
32
// Check reuses File's content+mode hash idempotency: a remote file already
33
// matching the desired content and mode needs no change (and so no validation).
34
func (f ValidatedFile) Check(fc *legatus.FlowCtx) (bool, error) {
35
	match, err := fileMatches(fc, f.Path, f.Content, f.Mode)
36
	if err != nil {
37
		return false, err
38
	}
39
	return !match, nil
40
}
41
42
// Apply stages the content beside the target, validates the staged path, and on
43
// success moves it into place; on validator failure it removes the staged file
44
// and errors, leaving the live target unchanged.
45
func (f ValidatedFile) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
46
	if f.Validator == nil {
47
		return legatus.Outcome{}, culpa.WithCode(
48
			culpa.Errorf("validated-file %s: no Validator set", f.Path), "VALIDATED_FILE")
49
	}
50
	staged := stagedPath(f.Path)
51
52
	var putOpts []legatus.PutOption
53
	if f.Sudo {
54
		putOpts = append(putOpts, legatus.WithPutSudo())
55
	}
56
	if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(f.Content), staged, f.Mode, putOpts...); err != nil {
57
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "stage %s", staged), "VALIDATED_FILE")
58
	}
59
60
	vcmd := f.Validator(staged)
61
	vcmd.Sudo = vcmd.Sudo || f.Sudo
62
	res, err := fc.Conn().Exec(fc.Ctx(), vcmd)
63
	if err != nil {
64
		f.cleanup(fc, staged)
65
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "validate %s", f.Path), "VALIDATED_FILE")
66
	}
67
	if res.ExitCode != 0 {
68
		f.cleanup(fc, staged)
69
		return legatus.Outcome{}, culpa.WithCode(
70
			culpa.Errorf("validated-file %s: validation failed: exit %d: %s",
71
				f.Path, res.ExitCode, strings.TrimSpace(string(res.Combined()))), "VALIDATED_FILE")
72
	}
73
74
	if err := f.run(fc, legatus.Cmd{Path: "mv", Args: []string{staged, f.Path}, Sudo: f.Sudo}); err != nil {
75
		f.cleanup(fc, staged)
76
		return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "commit %s", f.Path), "VALIDATED_FILE")
77
	}
78
	if f.Owner != "" {
79
		if err := f.run(fc, legatus.Cmd{Path: "chown", Args: []string{f.Owner, f.Path}, Sudo: f.Sudo}); err != nil {
80
			return legatus.Outcome{}, culpa.WithCode(culpa.Wrapf(err, "chown %s", f.Path), "VALIDATED_FILE")
81
		}
82
	}
83
	return legatus.Outcome{Changed: true, Message: "validated + wrote " + f.Path}, nil
84
}
85
86
// run executes an Apply-path command that must succeed, turning a non-zero exit
87
// into an error.
88
func (f ValidatedFile) run(fc *legatus.FlowCtx, cmd legatus.Cmd) error {
89
	res, err := fc.Conn().Exec(fc.Ctx(), cmd)
90
	if err != nil {
91
		return err
92
	}
93
	if res.ExitCode != 0 {
94
		return culpa.Errorf("%s exit %d: %s", cmd.Path, res.ExitCode, strings.TrimSpace(string(res.Combined())))
95
	}
96
	return nil
97
}
98
99
// cleanup removes the staged file after a failed validation or commit. A cleanup
100
// error is deliberately not returned so the original failure is the one surfaced
101
// (mirrors the transport's staging-cleanup on error paths).
102
func (f ValidatedFile) cleanup(fc *legatus.FlowCtx, staged string) {
103
	_, _ = fc.Conn().Exec(fc.Ctx(), legatus.Cmd{Path: "rm", Args: []string{"-f", staged}, Sudo: f.Sudo})
104
}
105
106
// stagedPath derives a dotfile staging path beside p — same directory, so the
107
// commit move is atomic on the target filesystem.
108
func stagedPath(p string) string {
109
	dir, base := path.Split(p)
110
	return dir + "." + base + ".legatus-new"
111
}
112

Source Files