repo-sync/mirror/trace.go
James Coleman 5af1dd8928
Some checks failed
Go package / build (push) Has been cancelled
first commit
2026-07-28 19:22:20 -05:00

275 lines
9.4 KiB
Go

package mirror
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
cfg "github.com/grmrgecko/repo-sync/config"
"github.com/grmrgecko/repo-sync/fetch"
log "github.com/sirupsen/logrus"
)
// TraceDir is the directory, relative to a repository, that trace files are
// published in. Debian archives established the location and downstream
// mirror checkers look for it there.
const TraceDir = "project/trace"
// TraceInfo describes the mirror a trace file is written for. It carries
// the same fields as the configuration's trace section, kept as a separate
// type so callers populate it from wherever they hold those details rather
// than this package taking a configuration struct as part of its API.
type TraceInfo struct {
Host string
Maintainer string
Sponsor string
Country string
Location string
Throughput string
}
// traceRun records when a repository's synchronization began, and the
// transfer it accounted for, so the trace can report both. A nil run writes
// nothing, which is how tracing stays off without a condition at every call
// site.
type traceRun struct {
started time.Time
stats *fetch.Counters
}
// newTrace starts timing a repository sync when tracing is enabled.
func newTrace(opts *Options) *traceRun {
if !opts.Trace {
return nil
}
return &traceRun{started: time.Now(), stats: &fetch.Counters{}}
}
// track installs the run's own transfer counters on ctx so the trace
// reports what this synchronization moved rather than what the shared
// collector holds. Without it the mirror server's concurrent crawls would
// report each other's bytes.
func (t *traceRun) track(ctx context.Context) context.Context {
if t == nil {
return ctx
}
return fetch.WithStats(ctx, t.stats)
}
// publish brings a repository's trace directory up to date: upstream's own
// traces are mirrored first, then this run's trace is written, so a name
// collision resolves in favour of the file describing this mirror.
func (t *traceRun) publish(ctx context.Context, root string, src *fetch.Source, keep *fetch.KeepSet, opts *Options) {
if t == nil {
return
}
t.mirrorUpstream(ctx, root, src, keep, opts)
t.write(root, src, keep, opts)
}
// mirrorUpstream copies the traces upstream publishes into this mirror's
// trace directory, so a reader can follow the chain back through every
// mirror to the archive the content came from. Upstream may publish no
// traces at all or may not serve directory indexes, and neither is a
// failure of the synchronization, so problems here are logged and the run
// continues.
func (t *traceRun) mirrorUpstream(ctx context.Context, root string, src *fetch.Source, keep *fetch.KeepSet, opts *Options) {
base := src.Active()
if base == "" {
return
}
dirURL := base + "/" + TraceDir + "/"
listing, err := listDir(ctx, dirURL)
if err != nil {
log.WithError(err).WithField("url", dirURL).Debug("Upstream serves no trace directory.")
return
}
// Skip the name this mirror traces under; write publishes that file
// itself, and upstream's copy of it describes a different mirror.
own, err := traceName(opts.TraceInfo.Host)
if err != nil {
own = ""
}
names := make([]string, 0, len(listing.files))
for name := range listing.files {
if name == own || name == "." || name == ".." {
continue
}
names = append(names, name)
}
sort.Strings(names)
dir := filepath.Join(root, filepath.FromSlash(TraceDir))
var jobs []fetch.Job
for _, name := range names {
dst, err := fetch.LocalJoin(dir, name)
if err != nil {
log.WithError(err).WithField("name", name).Debug("Skipping upstream trace with an unusable name.")
continue
}
// Traces carry no checksums, so they revalidate against their
// modification time rather than being re-fetched every run, and a
// trace withdrawn between the listing and the fetch is not an error.
jobs = append(jobs, fetch.Job{ReqPath: TraceDir + "/" + name, Dst: dst, Optional: true})
}
if len(jobs) == 0 {
return
}
if opts.DryRun {
fetch.PlanJobs(jobs, opts.Verify, keep)
return
}
if _, err := fetch.Many(ctx, src, jobs, opts.Workers, opts.Verify, keep, nil); err != nil {
log.WithError(err).WithField("url", dirURL).Warn("Failed to mirror upstream trace files.")
return
}
log.WithFields(log.Fields{"url": dirURL, "traces": len(jobs)}).Debug("Mirrored upstream trace files.")
}
// traceName returns the file name a mirror is traced under. The value
// becomes a file name, so it is reduced to a single path element and
// rejected if nothing usable is left.
func traceName(host string) (string, error) {
name := filepath.Base(strings.TrimSpace(strings.ReplaceAll(host, "\\", "/")))
if name == "." || name == ".." || name == "/" || name == "" {
return "", fmt.Errorf("invalid trace host %q", host)
}
return name, nil
}
// write publishes the trace for a finished repository synchronization into
// root, and records it in keep so pruning does not treat the file as stale
// and remove it. On a dry run the file is left untouched but still kept,
// so the run does not report the existing trace as about to be pruned.
//
// A trace describes the mirror rather than the packages, so a failure to
// write one is logged and does not fail the synchronization that produced
// it.
func (t *traceRun) write(root string, src *fetch.Source, keep *fetch.KeepSet, opts *Options) {
if t == nil {
return
}
name, err := traceName(opts.TraceInfo.Host)
if err != nil {
log.WithError(err).Warn("Skipping trace file.")
return
}
dir := filepath.Join(root, filepath.FromSlash(TraceDir))
file := filepath.Join(dir, name)
// Keep the trace regardless of whether it is written now, so a dry run
// does not report an existing trace as stale.
keep.Add(file)
if opts.DryRun {
return
}
if err := os.MkdirAll(dir, 0755); err != nil {
log.WithError(err).WithField("path", dir).Warn("Failed to create trace directory.")
return
}
content := t.render(src, opts)
// Write through a temporary file so a reader never sees a partial
// trace, and so an interrupted write cannot truncate the previous one.
tmp, err := os.CreateTemp(dir, ".trace-*")
if err != nil {
log.WithError(err).WithField("path", dir).Warn("Failed to create trace file.")
return
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
os.Remove(tmpName)
log.WithError(err).WithField("path", tmpName).Warn("Failed to write trace file.")
return
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
log.WithError(err).WithField("path", tmpName).Warn("Failed to write trace file.")
return
}
if err := os.Chmod(tmpName, 0644); err != nil {
log.WithError(err).WithField("path", tmpName).Debug("Failed to set trace file mode.")
}
if err := os.Rename(tmpName, file); err != nil {
os.Remove(tmpName)
log.WithError(err).WithField("path", file).Warn("Failed to publish trace file.")
return
}
log.WithField("path", file).Debug("Wrote trace file.")
}
// render builds the trace file's contents. The first line is the plain
// completion date, as the convention expects, followed by one field per
// line. Fields with no configured value are omitted rather than written
// empty.
func (t *traceRun) render(src *fetch.Source, opts *Options) string {
done := time.Now().UTC()
var b strings.Builder
// The opening line matches date(1)'s default UTC output, which is what
// the convention's readers expect; _2 space-pads the day as it does.
fmt.Fprintf(&b, "%s\n", done.Format("Mon Jan _2 15:04:05 UTC 2006"))
fmt.Fprintf(&b, "Date: %s\n", done.Format(time.RFC1123Z))
fmt.Fprintf(&b, "Date-Started: %s\n", t.started.UTC().Format(time.RFC1123Z))
fmt.Fprintf(&b, "Creator: %s %s\n", cfg.Name, cfg.Version)
fmt.Fprintf(&b, "Running on host: %s\n", opts.TraceInfo.Host)
for _, f := range []struct{ key, value string }{
{"Maintainer", opts.TraceInfo.Maintainer},
{"Sponsor", opts.TraceInfo.Sponsor},
{"Country", opts.TraceInfo.Country},
{"Location", opts.TraceInfo.Location},
{"Throughput", opts.TraceInfo.Throughput},
} {
if f.value != "" {
fmt.Fprintf(&b, "%s: %s\n", f.key, f.value)
}
}
fmt.Fprintf(&b, "Repository type: %s\n", opts.Type)
if arch := traceArchitectures(opts); arch != "" {
fmt.Fprintf(&b, "Architectures: %s\n", arch)
}
// Report the mirror actually fetched from. For a mirrorlist or metalink
// that is the mirror that answered, which is the useful fact and is not
// derivable from the configured URL.
if src != nil {
if active := src.Active(); active != "" {
fmt.Fprintf(&b, "Upstream-mirror: %s\n", active)
}
}
elapsed := done.Sub(t.started.UTC())
if elapsed < 0 {
elapsed = 0
}
seconds := int64(elapsed.Round(time.Second) / time.Second)
// The run's own counters, not the shared collector, so a trace written
// by one of the server's concurrent crawls reports that crawl alone.
bytes := t.stats.FetchedBytes.Load()
fmt.Fprintf(&b, "Total bytes received: %d\n", bytes)
fmt.Fprintf(&b, "Total time spent syncing: %d\n", seconds)
if seconds > 0 {
fmt.Fprintf(&b, "Average rate: %d B/s\n", bytes/seconds)
}
return b.String()
}
// traceArchitectures reports the architectures a run was restricted to,
// which only apt repositories can express. An unrestricted run mirrors
// whatever upstream publishes, so no claim is made.
func traceArchitectures(opts *Options) string {
if opts.Type != RepoDeb || len(opts.Architectures) == 0 {
return ""
}
arch := append([]string(nil), opts.Architectures...)
sort.Strings(arch)
return strings.Join(arch, " ")
}