aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/cmd_push.go
blob: c58f75c73724770bbc7a236258dfcddd6727a44d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
package cli

import (
	"context"
	"errors"
	"fmt"
	"io"
	"time"

	"github.com/spf13/pflag"

	"tsne.dev/hopper/internal/bunny"
	"tsne.dev/hopper/internal/log"
	"tsne.dev/hopper/internal/source"
)

// pruneHintFloor and pruneHintMultiple gate the post-push prune hint: the zone
// must hold at least floor files and more than multiple times the current
// release before stale assets are worth flagging. multiple covers the current
// release plus five retained rollbacks (six releases' worth of hashed assets).
const (
	pruneHintFloor    = 1000
	pruneHintMultiple = 6
)

var _ command = (*pushCommand)(nil)

type pushCommand struct {
	flags *pflag.FlagSet
	main  *mainCommand

	concurrency uint
	prefix      string
	root        string
}

func newPushCommand(main *mainCommand) *pushCommand {
	cmd := &pushCommand{
		flags: newFlagset("push"),
		main:  main,
	}
	cmd.flags.UintVar(&cmd.concurrency, "concurrency", 0, "number of concurrent transfers (0 = auto-detect based on CPUs)")
	registerPathPrefixFlag(cmd.flags, &cmd.prefix, "prefix", "", "path prefix within the zone to target")
	registerPathPrefixFlag(cmd.flags, &cmd.root, "root", "", "subtree of the source to upload")
	registerDryRunFlag(cmd.flags)
	return cmd
}

func (cmd *pushCommand) Usage() string {
	return "Usage:" +
		"\n  hopper [global flags] push [flags] ⟨source⟩" +
		"\n" +
		"\nFlags:" +
		"\n" + cmd.flags.FlagUsages()
}

func (cmd *pushCommand) Run(ctx context.Context, args []string) error {
	parseFlags(cmd, cmd.flags, args)

	positionals := cmd.flags.Args()
	if len(positionals) == 0 || positionals[0] == "" {
		return usageError{"missing source"}
	}
	if len(positionals) > 1 {
		return usageError{"only a single source is supported"}
	}
	source := positionals[0]

	if err := cmd.main.lookupAccessKey(); err != nil {
		return err
	}

	api := newStorageAPI(cmd.main.endpoint, cmd.main.zone, cmd.main.accessKey)
	return cmd.push(ctx, api, source)
}

func (cmd *pushCommand) push(ctx context.Context, api storageAPI, sourceArg string) error {
	src, err := source.NewTarGzSource(sourceArg, cmd.root)
	if err != nil {
		return err
	}
	defer src.Close()

	// Drain the complete remote listing before any writes. A List failure is
	// fatal here, so push never proceeds on a partial picture.
	// Index the remote listing by storage key so each source entry's skip /
	// conflict decision is an O(1) lookup rather than a linear scan per file
	// (the walk itself is O(remote)). A file and a directory can never share an
	// exact key in object storage, so keying by path is unambiguous.
	start := time.Now()

	remoteByPath := make(map[string]bunny.Object, 128)
	remoteFiles := 0
	it := api.List(ctx, cmd.prefix)
	for it.Next() {
		o := it.Object()
		if !o.IsDirectory {
			remoteFiles++
		}
		remoteByPath[o.Path] = o
	}
	if err := it.Err(); err != nil {
		return err
	}

	skipped := 0

	// stashedMutables holds mutable entries (e.g. *.html files) read during the
	// asset phase but not uploaded until all immutable assets are successfully
	// uploaded. The stash is in-memory. A release normally has only a handful of
	// mutable files only.
	stashedMutables := make([]mutableEntry, 0, 8)

	// Upload every immutable asset via the pool and stash mutables. The single
	// reader below buffers each entry's bytes and hands them to a fail-fast worker
	// pool. The first asset that fails to upload cancels the pool.
	assetsPool := newWorkerPool(ctx, cmd.concurrency, true)
	var readErr error
	for !assetsPool.cancelled() {
		entry, err := src.Next()
		if err != nil {
			if !errors.Is(err, io.EOF) {
				readErr = err
			}
			break
		}

		// Decide against the remote listing: a directory at this exact path is a
		// hard conflict; a matching-size file is skipped; any other case (absent,
		// size mismatch, 0/missing) falls through to re-upload.
		key := prefixedPath(cmd.prefix, entry.Path)
		remoteObj, remoteExists := remoteByPath[key]
		if remoteExists && remoteObj.IsDirectory {
			readErr = fmt.Errorf("remote path %q is a directory, release has a file there", key)
			break
		}

		if entry.IsMutable {
			data, err := entry.ReadContent(ctx)
			if err != nil {
				readErr = fmt.Errorf("read %q: %w", entry.Path, err)
				break
			}
			stashedMutables = append(stashedMutables, mutableEntry{path: key, data: data})
			continue
		}

		if remoteExists && remoteObj.Size == entry.Size {
			logAction("skipping %s (exists)", key)
			skipped++
			continue
		}

		data, err := entry.ReadContent(ctx)
		if err != nil {
			readErr = fmt.Errorf("read %q: %w", entry.Path, err)
			break
		}

		action := fmt.Sprintf("uploading %s (%s)", key, humanSize(len(data)))
		if !assetsPool.submit(action, uploadTask(api, key, data)) {
			break
		}
	}

	// A reader error is fatal and abandons all in-flight uploads.
	if readErr != nil {
		assetsPool.cancel(errors.New("upload cancelled"))
	}

	assetsUploaded, errs := assetsPool.wait()
	reported := reportFailures(ctx, "failed to upload files", errs)
	if readErr != nil {
		return readErr
	} else if reported {
		return exitError{1}
	}

	// Upload all stashed mutable files. This batch is neither atomic nor ordered.
	// A partial mutable-phase failure can leave an entry point live while a mutable
	// file it references is not yet uploaded. Mitigation: We report an error and
	// a re-run is idempotent.
	mutablesPool := newWorkerPool(ctx, cmd.concurrency, false)
	for _, m := range stashedMutables {
		action := fmt.Sprintf("uploading %s (%s)", m.path, humanSize(len(m.data)))
		if !mutablesPool.submit(action, uploadTask(api, m.path, m.data)) {
			break // the parent context was cancelled
		}
	}

	// Commit phase (HTML) is drain-and-report: every stashed mutable is attempted
	// and all failures are aggregated. Unlike the pre-commit asset phase, uploads
	// here genuinely happened, so we print a summary with counts even on partial
	// failure — but the failure list goes out first, keeping the summary last.
	mutablesUploaded, mutErrs := mutablesPool.wait()
	mutablesFailed := len(mutErrs)

	sourceFiles := assetsUploaded + mutablesUploaded + skipped + mutablesFailed
	uploaded := assetsUploaded + mutablesUploaded

	if sourceFiles == 0 {
		log.Warn("source contains no files to upload (pushed nothing). check --root, --prefix, or the archive contents")
	}

	var cmdErr error
	if reportFailures(ctx, "failed to upload files", mutErrs) {
		cmdErr = exitError{1}
	}

	summary := "uploaded: %d" +
		"\nskipped:  %d" +
		"\nremote:   %d" +
		"\nfailed:   %d" +
		"\nelapsed:  %s"
	if shouldHintPrune(sourceFiles, remoteFiles) {
		summary += "\n\nHint: There are many stale files on the zone. Consider running hopper prune."
	}
	logSummary(
		summary,
		uploaded, skipped, remoteFiles, mutablesFailed, time.Since(start).Round(time.Millisecond),
	)

	return cmdErr
}

func shouldHintPrune(sourceFiles, remoteFiles int) bool {
	return sourceFiles > 0 &&
		remoteFiles >= pruneHintFloor &&
		remoteFiles > pruneHintMultiple*sourceFiles
}

// uploadTask builds a pool task that PUTs data and, on failure, tags the error
// with the path so the report can name the file.
func uploadTask(api storageAPI, path string, data []byte) func(context.Context) error {
	return func(ctx context.Context) error {
		if err := api.Upload(ctx, path, data); err != nil {
			return fileError{path: path, err: err}
		}
		return nil
	}
}

// mutableEntry is a mutable file (current rule: *.html) read during the asset
// phase and held in memory until the commit point. data is owned by this
// struct.
type mutableEntry struct {
	path string
	data []byte
}

func humanSize(n int) string {
	return humanSize64(int64(n))
}

func humanSize64(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}