envsync.go

v0.4.0
Doc Versions Source
1
package sync
2
3
import (
4
	"bytes"
5
	"io"
6
	"os"
7
	"path"
8
	"path/filepath"
9
10
	"go.bigb.es/auxilia/culpa"
11
	"go.bigb.es/auxilia/legatus"
12
)
13
14
// ErrEnvAborted is raised when the operator chooses "abort" on a .env diff, so
15
// the deploy stops before any later step (e.g. post_push).
16
var ErrEnvAborted = culpa.WithCode(culpa.New("sync: .env sync aborted by operator"), "SYNC_ENV_ABORTED")
17
18
// EnvSync syncs one sensitive, untracked file (default ".env") that the normal
19
// Push skips. On a diff it shows the change and asks skip|overwrite|abort; abort
20
// halts the flow. When only the local file exists it asks to upload. The file's
21
// content reaches only the operator-facing sink (fc.Out()) and the prompter,
22
// never the structured logger (it is a secret).
23
type EnvSync struct {
24
	// File is the file name relative to the roots; defaults to ".env".
25
	File string
26
	// LocalRoot is the base of the local tree.
27
	LocalRoot string
28
	// RemoteRoot is the base of the remote tree.
29
	RemoteRoot string
30
	// Sudo stages the upload with sudo (for a root-owned remote .env).
31
	Sudo bool
32
	// Owner, if set, chowns the uploaded file (user or user:group).
33
	Owner string
34
}
35
36
func (e *EnvSync) name() string {
37
	if e.File != "" {
38
		return e.File
39
	}
40
	return ".env"
41
}
42
43
func (e *EnvSync) localPath() string  { return filepath.Join(e.LocalRoot, filepath.FromSlash(e.name())) }
44
func (e *EnvSync) remotePath() string { return path.Join(e.RemoteRoot, e.name()) }
45
46
func (e *EnvSync) putOpts() []legatus.PutOption {
47
	var opts []legatus.PutOption
48
	if e.Sudo {
49
		opts = append(opts, legatus.WithPutSudo())
50
	}
51
	if e.Owner != "" {
52
		opts = append(opts, legatus.WithPutOwner(e.Owner))
53
	}
54
	return opts
55
}
56
57
// Name identifies the step.
58
func (e *EnvSync) Name() string { return "sync.env-sync:" + e.name() }
59
60
// Check reports whether the local file exists and the remote is absent or differs.
61
func (e *EnvSync) Check(fc *legatus.FlowCtx) (bool, error) {
62
	local, ok, err := readLocalFile(e.localPath())
63
	if err != nil || !ok {
64
		return false, err
65
	}
66
	remoteHash, err := remoteSHA256(fc.Ctx(), fc.Conn(), e.remotePath())
67
	if err != nil {
68
		return false, err
69
	}
70
	if remoteHash == "" {
71
		return true, nil // remote absent, local present
72
	}
73
	remote, err := downloadRemote(fc.Ctx(), fc.Conn(), e.remotePath())
74
	if err != nil {
75
		return false, err
76
	}
77
	return !bytes.Equal(local, remote), nil
78
}
79
80
// Apply syncs the file through the prompter. abort returns ErrEnvAborted.
81
func (e *EnvSync) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
82
	local, ok, err := readLocalFile(e.localPath())
83
	if err != nil {
84
		return legatus.Outcome{}, err
85
	}
86
	if !ok {
87
		return legatus.Outcome{Changed: false, Message: "no local " + e.name()}, nil
88
	}
89
90
	remoteHash, err := remoteSHA256(fc.Ctx(), fc.Conn(), e.remotePath())
91
	if err != nil {
92
		return legatus.Outcome{}, err
93
	}
94
95
	if remoteHash == "" {
96
		yes, perr := fc.Prompter().Confirm(fc.Ctx(), "sync: upload "+e.name()+" to remote?")
97
		if perr != nil {
98
			return legatus.Outcome{}, culpa.Wrapf(perr, "sync: confirm upload %s", e.name())
99
		}
100
		if !yes {
101
			fc.Log().Info("kept remote (upload declined)", "file", e.name())
102
			return legatus.Outcome{Changed: false, Message: "skipped " + e.name()}, nil
103
		}
104
		return e.upload(fc, local)
105
	}
106
107
	remote, err := downloadRemote(fc.Ctx(), fc.Conn(), e.remotePath())
108
	if err != nil {
109
		return legatus.Outcome{}, err
110
	}
111
	if bytes.Equal(local, remote) {
112
		return legatus.Outcome{Changed: false, Message: e.name() + " in sync"}, nil
113
	}
114
115
	// The diff carries secret values: it goes only to the operator-facing sink.
116
	_, _ = io.WriteString(fc.Out(), unifiedDiff(e.name(), remote, local))
117
	choice, perr := fc.Prompter().Select(fc.Ctx(),
118
		"sync: "+e.name()+" differs — skip, overwrite, or abort the deploy?",
119
		[]string{"skip", "overwrite", "abort"})
120
	if perr != nil {
121
		return legatus.Outcome{}, culpa.Wrapf(perr, "sync: select %s action", e.name())
122
	}
123
	switch choice {
124
	case 0:
125
		fc.Log().Info("kept remote (overwrite declined)", "file", e.name())
126
		return legatus.Outcome{Changed: false, Message: "skipped " + e.name()}, nil
127
	case 1:
128
		return e.upload(fc, local)
129
	default:
130
		return legatus.Outcome{}, ErrEnvAborted
131
	}
132
}
133
134
// upload writes the local file to the remote with the step's sudo/owner options.
135
func (e *EnvSync) upload(fc *legatus.FlowCtx, local []byte) (legatus.Outcome, error) {
136
	mode := legatus.FileMode(0o600)
137
	if info, serr := os.Stat(e.localPath()); serr == nil {
138
		mode = info.Mode().Perm()
139
	}
140
	if err := fc.Conn().Put(fc.Ctx(), bytes.NewReader(local), e.remotePath(), mode, e.putOpts()...); err != nil {
141
		return legatus.Outcome{}, culpa.Wrapf(err, "sync: upload %s", e.name())
142
	}
143
	fc.Log().Info("uploaded", "file", e.name())
144
	return legatus.Outcome{Changed: true, Message: "uploaded " + e.name(), Data: e.name()}, nil
145
}
146
147
// readLocalFile reads a local file, reporting ok=false (not an error) when absent.
148
func readLocalFile(p string) ([]byte, bool, error) {
149
	data, err := os.ReadFile(p)
150
	if os.IsNotExist(err) {
151
		return nil, false, nil
152
	}
153
	if err != nil {
154
		return nil, false, culpa.Wrapf(err, "sync: read local %s", p)
155
	}
156
	return data, true, nil
157
}
158

Source Files