repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

14.9 KB · 516 lines · Go Raw History
  1package main
  2
  3// The mirror lane. Pushed repositories are the working set and mirrored ones are
  4// the backup, with one browse UI over both. Every call to GitHub is
  5// unauthenticated, so there is no access token anywhere in this site.
  6
  7import (
  8	"bytes"
  9	"context"
 10	"encoding/json"
 11	"fmt"
 12	"log/slog"
 13	"net/http"
 14	"os"
 15	"os/exec"
 16	"path/filepath"
 17	"strings"
 18	"sync"
 19	"sync/atomic"
 20	"time"
 21)
 22
 23// syncTimeout bounds one repository's fetch, whose first run is a full clone.
 24const syncTimeout = 10 * time.Minute
 25
 26// GitHubRepo is the subset of the API response this uses.
 27type GitHubRepo struct {
 28	Name     string   `json:"name"`
 29	FullName string   `json:"full_name"`
 30	CloneURL string   `json:"clone_url"`
 31	Desc     string   `json:"description"`
 32	Homepage string   `json:"homepage"`
 33	Topics   []string `json:"topics"`
 34	Archived bool     `json:"archived"`
 35	Fork     bool     `json:"fork"`
 36	Private  bool     `json:"private"`
 37	Size     int      `json:"size"`
 38	Owner    struct {
 39		Login string `json:"login"`
 40	} `json:"owner"`
 41
 42	// explicit is true when a source named this repository outright. An account
 43	// sweep skips forks; a repository asked for by name is not skipped.
 44	explicit bool
 45}
 46
 47// Mirror keeps the local copies in step with the account.
 48type Mirror struct {
 49	store *Store
 50	db    *DB
 51
 52	// Set while a manual sync is in flight, so a second click is declined rather
 53	// than parked on the mutex below.
 54	running atomic.Bool
 55
 56	// One sync at a time: two `git remote update` runs against one repository
 57	// fight over the lock file.
 58	mu sync.Mutex
 59}
 60
 61func NewMirror(store *Store, db *DB) *Mirror {
 62	return &Mirror{store: store, db: db}
 63}
 64
 65// Run syncs on a ticker until the context is cancelled.
 66func (m *Mirror) Run(ctx context.Context, every time.Duration) {
 67	// A sync at startup too, or a container that restarts daily never syncs.
 68	if err := m.Sync(ctx); err != nil {
 69		slog.Error("initial mirror sync failed", slog.Any("err", err))
 70	}
 71
 72	t := time.NewTicker(every)
 73	defer t.Stop()
 74
 75	for {
 76		select {
 77		case <-ctx.Done():
 78			return
 79		case <-t.C:
 80			if err := m.Sync(ctx); err != nil {
 81				slog.Error("mirror sync failed", slog.Any("err", err))
 82			}
 83		}
 84	}
 85}
 86
 87// Sync lists the account and brings every mirrored repository up to date.
 88func (m *Mirror) Sync(ctx context.Context) error {
 89	m.mu.Lock()
 90	defer m.mu.Unlock()
 91
 92	sources, err := m.db.MirrorSources()
 93	if err != nil {
 94		return err
 95	}
 96	if len(sources) == 0 {
 97		slog.Info("mirror sync skipped: no sources configured")
 98		return nil
 99	}
100
101	remote, err := m.list(ctx, sources)
102	if err != nil {
103		return err
104	}
105	slog.Info("mirror sync starting",
106		slog.Int("sources", len(sources)), slog.Int("upstream_repos", len(remote)))
107
108	seen := make(map[string]bool, len(remote))
109	for _, gh := range remote {
110		// A private repository needs a token this site does not have, and an
111		// account sweep skips forks unless one was named outright.
112		if gh.Private || (gh.Fork && !gh.explicit) {
113			continue
114		}
115		// Ownership of a name is decided by what is on disk, not by a database
116		// row: a bare repository with no mirror row is a pushed one, offered to
117		// adopt and skipped if it declines.
118		if repo, onDisk := m.store.Open(gh.Name); onDisk {
119			if meta, err := m.db.Repo(gh.Name); err != nil || !meta.Mirror {
120				adopted, err := m.adopt(ctx, gh, repo)
121				if err != nil {
122					slog.Error("adopt failed",
123						slog.String("repo", gh.Name), slog.Any("err", err))
124				}
125				if !adopted {
126					seen[gh.Name] = true
127					continue
128				}
129			}
130		}
131		// Directories are named for the repository alone, so two accounts can
132		// collide on one name. First claim wins and the loser is refused.
133		if meta, err := m.db.Repo(gh.Name); err == nil && meta.Mirror &&
134			meta.Upstream != "" && meta.Upstream != gh.CloneURL {
135			slog.Warn("name is already mirrored from a different upstream",
136				slog.String("repo", gh.Name),
137				slog.String("mirroring", meta.Upstream),
138				slog.String("refused", gh.CloneURL))
139			continue
140		}
141		seen[gh.Name] = true
142
143		if err := m.syncOne(ctx, gh); err != nil {
144			slog.Error("mirror repo failed",
145				slog.String("repo", gh.Name), slog.Any("err", err))
146			_ = m.db.RecordSync(gh.Name, err)
147			continue
148		}
149		_ = m.db.RecordSync(gh.Name, nil)
150	}
151
152	// Anything mirrored that upstream no longer lists is gone from GitHub. The
153	// local copy is never deleted.
154	all, err := m.db.AllRepos()
155	if err != nil {
156		return err
157	}
158	for name, meta := range all {
159		if !meta.Mirror {
160			continue
161		}
162		// A repository whose source was removed is not gone from GitHub, only
163		// no longer watched.
164		if !coveredBySource(meta.Upstream, sources) {
165			continue
166		}
167		gone := !seen[name]
168		if gone != meta.UpstreamGone {
169			if gone {
170				slog.Warn("upstream repository is gone; local mirror is now the only copy",
171					slog.String("repo", name))
172			}
173			_ = m.db.MarkUpstreamGone(name, gone)
174		}
175	}
176	return nil
177}
178
179// list reads every configured source. One source failing is logged and the rest
180// still run, so a typo does not stop the other sources being backed up.
181func (m *Mirror) list(ctx context.Context, sources []MirrorSource) ([]GitHubRepo, error) {
182	client := &http.Client{Timeout: 30 * time.Second}
183
184	var all []GitHubRepo
185	// Keyed by full_name, since a sweep and a named repository can reach the same
186	// one. An explicit entry replaces a swept one, overriding the fork skip.
187	seen := make(map[string]int)
188	add := func(gh GitHubRepo) {
189		if i, ok := seen[gh.FullName]; ok {
190			if gh.explicit {
191				all[i] = gh
192			}
193			return
194		}
195		seen[gh.FullName] = len(all)
196		all = append(all, gh)
197	}
198
199	var failed int
200	for _, src := range sources {
201		var err error
202		switch src.Kind {
203		case sourceRepo:
204			var gh GitHubRepo
205			if err = getJSON(ctx, client,
206				fmt.Sprintf("https://api.github.com/repos/%s/%s", src.Owner, src.Name),
207				&gh); err == nil {
208				gh.explicit = true
209				add(gh)
210			}
211		default:
212			var repos []GitHubRepo
213			if repos, err = m.listAccount(ctx, client, src.Owner); err == nil {
214				for _, gh := range repos {
215					add(gh)
216				}
217			}
218		}
219		if err != nil {
220			failed++
221			slog.Error("mirror source failed",
222				slog.String("source", src.Label()), slog.Any("err", err))
223		}
224	}
225
226	if len(all) == 0 && failed > 0 {
227		return nil, fmt.Errorf("every mirror source failed (%d)", failed)
228	}
229	return all, nil
230}
231
232// listAccount pages through one account's public repositories.
233func (m *Mirror) listAccount(ctx context.Context, client *http.Client, owner string) ([]GitHubRepo, error) {
234	var all []GitHubRepo
235	for page := 1; page <= 10; page++ {
236		url := fmt.Sprintf(
237			"https://api.github.com/users/%s/repos?per_page=100&type=owner&page=%d",
238			owner, page)
239
240		var got []GitHubRepo
241		if err := getJSON(ctx, client, url, &got); err != nil {
242			return nil, err
243		}
244		all = append(all, got...)
245		if len(got) < 100 {
246			break
247		}
248	}
249	return all, nil
250}
251
252// getJSON is one unauthenticated GitHub call, rate limited by IP at 60 an hour.
253// The API requires a User-Agent and answers a missing one with a 403.
254func getJSON(ctx context.Context, client *http.Client, url string, out any) error {
255	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
256	if err != nil {
257		return err
258	}
259	req.Header.Set("Accept", "application/vnd.github+json")
260	req.Header.Set("User-Agent", "repos.bythewood.me")
261
262	resp, err := client.Do(req)
263	if err != nil {
264		return err
265	}
266	defer resp.Body.Close()
267
268	if resp.StatusCode != http.StatusOK {
269		return fmt.Errorf("github returned %d for %s", resp.StatusCode, url)
270	}
271	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
272		return fmt.Errorf("decode %s: %w", url, err)
273	}
274	return nil
275}
276
277// coveredBySource reports whether a mirrored repository still belongs to a
278// configured source, which scopes the upstream_gone check.
279func coveredBySource(upstream string, sources []MirrorSource) bool {
280	path := strings.TrimSuffix(strings.TrimPrefix(upstream, "https://github.com/"), ".git")
281	owner, name, ok := strings.Cut(path, "/")
282	if !ok {
283		return false
284	}
285	for _, src := range sources {
286		if !strings.EqualFold(src.Owner, owner) {
287			continue
288		}
289		if src.Kind == sourceAccount || strings.EqualFold(src.Name, name) {
290			return true
291		}
292	}
293	return false
294}
295
296// adopt converts a pushed repository into a mirror in place, but only if upstream
297// already contains every local branch and matches every tag exactly. The probe
298// fetch writes to refs/adopt/*, so a repository that fails is left untouched.
299func (m *Mirror) adopt(ctx context.Context, gh GitHubRepo, repo Repo) (bool, error) {
300	ctx, cancel := context.WithTimeout(ctx, syncTimeout)
301	defer cancel()
302
303	// Removing origin first makes this safe to re-run after a probe that failed
304	// partway.
305	_, _ = run(ctx, repo, "remote", "remove", "origin")
306	if _, err := run(ctx, repo, "remote", "add", "origin", gh.CloneURL); err != nil {
307		return false, err
308	}
309
310	// Without --no-tags the fetch follows tags into refs/tags/*, which are real
311	// refs, and the probe stops being read-only.
312	cmd := gitCmd(ctx, repo, "fetch", "--no-tags", "--prune", "origin",
313		"+refs/heads/*:refs/adopt/heads/*", "+refs/tags/*:refs/adopt/tags/*")
314	var stderr bytes.Buffer
315	cmd.Stderr = &stderr
316	if err := cmd.Run(); err != nil {
317		_, _ = run(ctx, repo, "remote", "remove", "origin")
318		return false, fmt.Errorf("probe fetch %s: %w: %s",
319			gh.Name, err, strings.TrimSpace(stderr.String()))
320	}
321
322	upstream, err := refMap(ctx, repo, "refs/adopt")
323	if err != nil {
324		return false, err
325	}
326	local, err := refMap(ctx, repo, "refs/heads", "refs/tags")
327	if err != nil {
328		return false, err
329	}
330
331	ok := true
332	for name, sha := range local {
333		// refs/heads/main is probed as refs/adopt/heads/main.
334		up, found := upstream["refs/adopt/"+strings.TrimPrefix(name, "refs/")]
335		switch {
336		case !found:
337			slog.Warn("not adopting: ref is not on upstream",
338				slog.String("repo", gh.Name), slog.String("ref", name))
339			ok = false
340		case up == sha:
341		case !strings.HasPrefix(name, "refs/heads/"):
342			slog.Warn("not adopting: tag differs from upstream",
343				slog.String("repo", gh.Name), slog.String("ref", name))
344			ok = false
345		default:
346			// --is-ancestor is a predicate: the exit status is the answer.
347			if err := gitCmd(ctx, repo,
348				"merge-base", "--is-ancestor", sha, up).Run(); err != nil {
349				slog.Warn("not adopting: local commits are not on upstream",
350					slog.String("repo", gh.Name), slog.String("ref", name))
351				ok = false
352			}
353		}
354	}
355
356	// The probe refs come out either way.
357	for name := range upstream {
358		if _, err := run(ctx, repo, "update-ref", "-d", name); err != nil {
359			return false, err
360		}
361	}
362	if !ok {
363		_, _ = run(ctx, repo, "remote", "remove", "origin")
364		return false, nil
365	}
366
367	// Only now does origin become a mirror remote, which is what makes the
368	// `remote update --prune` in syncOne rewrite refs rather than add to them.
369	for _, kv := range [][2]string{
370		{"remote.origin.fetch", "+refs/*:refs/*"},
371		{"remote.origin.mirror", "true"},
372		// The same backup settings clone gives a fresh mirror.
373		{"core.logAllRefUpdates", "true"},
374		{"gc.reflogExpire", "never"},
375		{"gc.reflogExpireUnreachable", "never"},
376		{"receive.autogc", "false"},
377	} {
378		if _, err := run(ctx, repo, "config", kv[0], kv[1]); err != nil {
379			return false, err
380		}
381	}
382
383	slog.Info("adopted pushed repository as a mirror",
384		slog.String("repo", gh.Name), slog.String("upstream", gh.CloneURL))
385	return true, nil
386}
387
388// refMap reads refs under the given prefixes as full refname to object id.
389func refMap(ctx context.Context, repo Repo, prefixes ...string) (map[string]string, error) {
390	args := append([]string{"for-each-ref", "--format=%(objectname)%00%(refname)"}, prefixes...)
391	out, err := run(ctx, repo, args...)
392	if err != nil {
393		return nil, err
394	}
395	refs := make(map[string]string)
396	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
397		if line == "" {
398			continue
399		}
400		sha, name, found := strings.Cut(line, "\x00")
401		if !found {
402			continue
403		}
404		refs[name] = sha
405	}
406	return refs, nil
407}
408
409// syncOne clones or updates one mirror.
410func (m *Mirror) syncOne(ctx context.Context, gh GitHubRepo) error {
411	if !validName(gh.Name) {
412		return fmt.Errorf("upstream name is not usable here: %q", gh.Name)
413	}
414
415	ctx, cancel := context.WithTimeout(ctx, syncTimeout)
416	defer cancel()
417
418	path := filepath.Join(m.store.Root, gh.Name+".git")
419
420	if _, err := os.Stat(path); os.IsNotExist(err) {
421		if err := m.clone(ctx, gh, path); err != nil {
422			return err
423		}
424	} else {
425		repo := Repo{Name: gh.Name, Path: path}
426		// --prune propagates an upstream deletion, which is why every mirror is
427		// created with a never-expiring reflog. gitCmd rather than run, because
428		// run imposes gitTimeout, which is far too short for a fetch.
429		cmd := gitCmd(ctx, repo, "remote", "update", "--prune")
430		var stderr bytes.Buffer
431		cmd.Stderr = &stderr
432		if err := cmd.Run(); err != nil {
433			return fmt.Errorf("remote update %s: %w: %s",
434				gh.Name, err, strings.TrimSpace(stderr.String()))
435		}
436	}
437
438	m.store.InvalidateOverview(gh.Name)
439
440	if err := m.db.MarkMirror(gh.Name, gh.CloneURL, gh.Archived); err != nil {
441		return err
442	}
443	// Upstream owns a mirror's description and topics, and the UI will not edit
444	// them, so overwriting on each sync is correct.
445	return m.db.SetDescription(gh.Name, gh.Desc, gh.Topics, gh.Homepage)
446}
447
448func (m *Mirror) clone(ctx context.Context, gh GitHubRepo, path string) error {
449	slog.Info("cloning mirror",
450		slog.String("repo", gh.Name), slog.Int("upstream_kb", gh.Size))
451
452	cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", gh.CloneURL, path)
453	cmd.Env = append(os.Environ(),
454		"GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "HOME="+os.TempDir())
455
456	if out, err := cmd.CombinedOutput(); err != nil {
457		return fmt.Errorf("clone %s: %w: %s", gh.Name, err, strings.TrimSpace(string(out)))
458	}
459
460	// core.logAllRefUpdates is off by default in a bare repository, and it is what
461	// leaves an upstream force push's old tip recoverable in the reflog.
462	repo := Repo{Name: gh.Name, Path: path}
463	for _, kv := range [][2]string{
464		{"core.logAllRefUpdates", "true"},
465		{"gc.reflogExpire", "never"},
466		{"gc.reflogExpireUnreachable", "never"},
467		{"receive.autogc", "false"},
468	} {
469		if _, err := run(ctx, repo, "config", kv[0], kv[1]); err != nil {
470			return err
471		}
472	}
473	return nil
474}
475
476// RunGC repacks every repository on a ticker, which is what makes
477// receive.autogc=false safe to set.
478func RunGC(ctx context.Context, store *Store, every time.Duration) {
479	t := time.NewTicker(every)
480	defer t.Stop()
481
482	for {
483		select {
484		case <-ctx.Done():
485			return
486		case <-t.C:
487			repos, err := store.Discover()
488			if err != nil {
489				slog.Error("gc: discover failed", slog.Any("err", err))
490				continue
491			}
492			for _, repo := range repos {
493				if err := store.GC(ctx, repo); err != nil {
494					slog.Error("gc failed",
495						slog.String("repo", repo.Name), slog.Any("err", err))
496				}
497			}
498		}
499	}
500}
501
502// TrySync runs a sync in the background and reports whether it started one. It
503// declines rather than queues, so two clicks do not mean two syncs.
504func (m *Mirror) TrySync(ctx context.Context) bool {
505	if !m.running.CompareAndSwap(false, true) {
506		return false
507	}
508	go func() {
509		defer m.running.Store(false)
510		if err := m.Sync(ctx); err != nil {
511			slog.Error("manual mirror sync failed", slog.Any("err", err))
512		}
513	}()
514	return true
515}