push.go

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

Source Files