push.go

v0.6.0
Doc Versions Source
1
package sync
2
3
import (
4
	"bytes"
5
	"crypto/sha256"
6
	"encoding/hex"
7
	"errors"
8
	"io"
9
	"io/fs"
10
	"os"
11
	"path"
12
	"path/filepath"
13
	"sort"
14
15
	"go.bigb.es/auxilia/culpa"
16
	"go.bigb.es/auxilia/legatus"
17
)
18
19
// Push uploads local files to the remote, comparing by content hash so unchanged
20
// files are skipped (PC4: Check reports need=false when every file already
21
// matches). When Delete is set, remote-only files under the synced Dirs are
22
// removed — but only after the prompter confirms (PC2); the non-interactive
23
// default declines, so a stale file is kept, never force-deleted.
24
type Push struct {
25
	// Files are individual paths relative to LocalRoot/RemoteRoot. Exclude does
26
	// not apply to these: listing a file explicitly is the intent.
27
	Files []string
28
	// Dirs are directory paths (relative) synced recursively.
29
	Dirs []string
30
	// LocalRoot is the base of the local tree.
31
	LocalRoot string
32
	// RemoteRoot is the base of the remote tree.
33
	RemoteRoot string
34
	// Delete removes remote-only files under Dirs (gated by the prompter).
35
	Delete bool
36
	// Exclude holds gitignore-style patterns (see matchIgnore) scoping the Dirs
37
	// walk. A matching path is neither uploaded nor considered remote-only, so
38
	// remote runtime state living inside a synced dir — a database, a log — is
39
	// never shipped over and never offered for deletion.
40
	Exclude []string
41
}
42
43
// Name identifies the step.
44
func (p *Push) Name() string { return "sync.push" }
45
46
// Check reports whether any file needs uploading. need=false when all match.
47
func (p *Push) Check(fc *legatus.FlowCtx) (bool, error) {
48
	plan, err := p.plan(fc)
49
	if err != nil {
50
		return false, err
51
	}
52
	return len(plan.upload) > 0, nil
53
}
54
55
// Apply uploads changed files and, when Delete is set, removes confirmed
56
// remote-only files. Outcome.Data is the sorted list of uploaded relative paths.
57
// Dry-run is handled by the engine (IV4): Apply only runs for real.
58
func (p *Push) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
59
	plan, err := p.plan(fc)
60
	if err != nil {
61
		return legatus.Outcome{}, err
62
	}
63
64
	for _, rel := range plan.upload {
65
		localPath := filepath.Join(p.LocalRoot, filepath.FromSlash(rel))
66
		remotePath := path.Join(p.RemoteRoot, rel)
67
		f, err := os.Open(localPath)
68
		if err != nil {
69
			return legatus.Outcome{}, culpa.Wrapf(err, "sync: open local %s", localPath)
70
		}
71
		mode := fs.FileMode(0o644)
72
		if info, statErr := f.Stat(); statErr == nil {
73
			mode = info.Mode().Perm()
74
		}
75
		err = fc.Conn().Put(fc.Ctx(), f, remotePath, mode)
76
		_ = f.Close()
77
		if err != nil {
78
			return legatus.Outcome{}, culpa.Wrapf(err, "sync: upload %s", rel)
79
		}
80
		fc.Log().Info("uploaded", "file", rel)
81
	}
82
83
	deleted, err := p.applyDeletes(fc, plan.remoteOnly)
84
	if err != nil {
85
		return legatus.Outcome{}, err
86
	}
87
88
	changed := len(plan.upload) > 0 || len(deleted) > 0
89
	return legatus.Outcome{
90
		Changed: changed,
91
		Message: uploadSummary(plan.upload),
92
		Data:    plan.upload,
93
	}, nil
94
}
95
96
// pushPlan is the diff between local and remote computed once per Check/Apply.
97
type pushPlan struct {
98
	upload     []string // relative paths whose content differs (or is missing remotely)
99
	remoteOnly []string // relative paths present remotely but not locally (Dirs only)
100
}
101
102
func (p *Push) plan(fc *legatus.FlowCtx) (pushPlan, error) {
103
	ctx := fc.Ctx()
104
	conn := fc.Conn()
105
106
	local := map[string]string{} // rel -> local abs path
107
	for _, rel := range p.Files {
108
		local[filepath.ToSlash(rel)] = filepath.Join(p.LocalRoot, filepath.FromSlash(rel))
109
	}
110
111
	ignore := newIgnoreSet(p.Exclude)
112
113
	var remoteOnly []string
114
	for _, dir := range p.Dirs {
115
		dirLocals, err := walkLocalDir(p.LocalRoot, dir)
116
		if err != nil {
117
			return pushPlan{}, err
118
		}
119
		localSet := map[string]bool{}
120
		for rel, abs := range dirLocals {
121
			if ignore.match(rel) {
122
				continue
123
			}
124
			local[rel] = abs
125
			localSet[rel] = true
126
		}
127
		if p.Delete {
128
			remoteRels, err := listRemoteFiles(ctx, conn, path.Join(p.RemoteRoot, filepath.ToSlash(dir)))
129
			if err != nil {
130
				return pushPlan{}, err
131
			}
132
			dirSlash := filepath.ToSlash(dir)
133
			for _, r := range remoteRels {
134
				rel := r
135
				if dirSlash != "." && dirSlash != "" {
136
					rel = path.Join(dirSlash, r)
137
				}
138
				// An excluded path is remote-only by construction (it was never
139
				// uploaded), so it must not be offered for deletion.
140
				if ignore.match(rel) {
141
					continue
142
				}
143
				if !localSet[joinRel(dirSlash, r)] && !localSet[rel] {
144
					remoteOnly = append(remoteOnly, rel)
145
				}
146
			}
147
		}
148
	}
149
150
	var upload []string
151
	rels := make([]string, 0, len(local))
152
	for rel := range local {
153
		rels = append(rels, rel)
154
	}
155
	sort.Strings(rels)
156
	for _, rel := range rels {
157
		localPath := local[rel]
158
		lh, err := localSHA256(localPath)
159
		if err != nil {
160
			return pushPlan{}, err
161
		}
162
		rh, err := remoteSHA256(ctx, conn, path.Join(p.RemoteRoot, rel))
163
		if err != nil {
164
			return pushPlan{}, err
165
		}
166
		if lh != rh {
167
			upload = append(upload, rel)
168
		}
169
	}
170
	sort.Strings(remoteOnly)
171
	return pushPlan{upload: upload, remoteOnly: remoteOnly}, nil
172
}
173
174
func (p *Push) applyDeletes(fc *legatus.FlowCtx, remoteOnly []string) ([]string, error) {
175
	var deleted []string
176
	for _, rel := range remoteOnly {
177
		ok, err := fc.Prompter().Confirm(fc.Ctx(),
178
			"sync: delete remote-only file "+rel+"?")
179
		// No input at all (stdin closed / not a TTY) is the non-interactive
180
		// case, not a failure: honour the documented default of declining, so a
181
		// push from a script or CI keeps the stale file instead of aborting
182
		// mid-flow with the upload already done. Any other prompter error is
183
		// real and still raises.
184
		if errors.Is(err, io.EOF) {
185
			fc.Log().Info("kept remote-only file (non-interactive, declined)", "file", rel)
186
			continue
187
		}
188
		if err != nil {
189
			return nil, culpa.Wrapf(err, "sync: confirm delete %s", rel)
190
		}
191
		if !ok {
192
			fc.Log().Info("kept remote-only file (deletion declined)", "file", rel)
193
			continue
194
		}
195
		remotePath := path.Join(p.RemoteRoot, rel)
196
		if _, err := runShell(fc.Ctx(), fc.Conn(), "", "rm -f "+shellQuote(remotePath)); err != nil {
197
			return nil, err
198
		}
199
		fc.Log().Info("deleted remote-only file", "file", rel)
200
		deleted = append(deleted, rel)
201
	}
202
	return deleted, nil
203
}
204
205
// joinRel joins a directory prefix and a relative entry, slash-style.
206
func joinRel(dir, rel string) string {
207
	if dir == "" || dir == "." {
208
		return rel
209
	}
210
	return path.Join(dir, rel)
211
}
212
213
// walkLocalDir returns rel(slash) -> abs path for every regular file under
214
// LocalRoot/dir. rel is keyed relative to LocalRoot.
215
func walkLocalDir(localRoot, dir string) (map[string]string, error) {
216
	base := filepath.Join(localRoot, filepath.FromSlash(dir))
217
	out := map[string]string{}
218
	err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
219
		if err != nil {
220
			return err
221
		}
222
		if d.IsDir() {
223
			return nil
224
		}
225
		rel, rerr := filepath.Rel(localRoot, p)
226
		if rerr != nil {
227
			return rerr
228
		}
229
		out[filepath.ToSlash(rel)] = p
230
		return nil
231
	})
232
	if err != nil {
233
		if os.IsNotExist(err) {
234
			return out, nil
235
		}
236
		return nil, culpa.Wrapf(err, "sync: walk local dir %s", base)
237
	}
238
	return out, nil
239
}
240
241
func localSHA256(p string) (string, error) {
242
	b, err := os.ReadFile(p)
243
	if err != nil {
244
		return "", culpa.Wrapf(err, "sync: read local %s", p)
245
	}
246
	sum := sha256.Sum256(b)
247
	return hex.EncodeToString(sum[:]), nil
248
}
249
250
func uploadSummary(upload []string) string {
251
	if len(upload) == 0 {
252
		return "no changes"
253
	}
254
	var b bytes.Buffer
255
	b.WriteString("upload: ")
256
	for i, f := range upload {
257
		if i > 0 {
258
			b.WriteString(", ")
259
		}
260
		b.WriteString(f)
261
	}
262
	return b.String()
263
}
264

Source Files