mirror of
https://github.com/hanzoai/cd.git
synced 2026-10-30 14:56:45 +00:00
go fix applied the modernizers: promoted-field keys in struct literals,
strings.Cut, SplitSeq, new(expr), slices and maps helpers, wg.Go, reflect.TypeFor
and errors.AsType. omitzero is left out, since omitting a zero struct such as
time.Time{} is a per-field wire decision and not a mechanical one.
Two changes the modernizers could not make for themselves. AsType needs its type
argument to be an error, so the git exit-status reader embeds error in the
interface it asks for. And an error built by concatenating a tag list into its
own format string now says so with a verb, which is what vet was pointing at.
Co-authored-by: Hanzo Dev <[email protected]>
198 lines
6.8 KiB
Go
198 lines
6.8 KiB
Go
// Package commands reconciles which repositories live on git.hanzo.ai and which
|
|
// direction each one syncs.
|
|
//
|
|
// It replaces hanzoai/mirrors' reconcile.py + sync.py. The behaviour it preserves
|
|
// is deliberate and each part was learned the hard way; the behaviour it drops is
|
|
// the part that made those lessons necessary:
|
|
//
|
|
// - The direction table is CONFIG, read from a file, not a list literal in
|
|
// source. Adding a repo was a code edit, which is not what declared state
|
|
// means. hanzoai/cloud was added that way, and until then it was in neither
|
|
// sync leg: is_mirror=0 so nothing pulled in, absent from the table so nothing
|
|
// pushed in, and its CI therefore ran on a commit four behind for a day while
|
|
// looking merely "red".
|
|
// - Names are RESOLVED, never matched against a listing. A listing reports a
|
|
// repo only under its current name in its current org, so six entries whose
|
|
// repos had been renamed or transferred were skipped silently — an absent name
|
|
// raises nothing.
|
|
// - Fast-forward is git's guarantee, not this program's: no leading '+' in the
|
|
// refspec. Divergence is reported and left alone.
|
|
//
|
|
// Exit status is 0 when every declared repo is in sync or was moved forward, and 1
|
|
// when any diverged or failed — so a scheduler can tell "nothing to do" from
|
|
// "someone needs to look", which a script that only printed could not.
|
|
package commands
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/hanzoai/cd/common"
|
|
"github.com/hanzoai/cd/mirror"
|
|
)
|
|
|
|
// NewCommand returns the mirror reconciler.
|
|
//
|
|
// A subcommand of the one CD binary rather than a program of its own: the fleet
|
|
// already builds, pins and ships exactly one image, and a second executable would
|
|
// be a second thing to version and a second chance for the two to disagree about
|
|
// which repos exist.
|
|
func NewCommand() *cobra.Command {
|
|
var (
|
|
table string
|
|
work string
|
|
forge string
|
|
dryRun bool
|
|
timeout time.Duration
|
|
)
|
|
|
|
cmd := &cobra.Command{
|
|
Use: common.CommandMirror,
|
|
Short: "Reconcile git.hanzo.ai against github.com",
|
|
// The one binary reports its own errors (cmd/main.go), so cobra must not
|
|
// also print them — otherwise every failure is logged twice and a reader
|
|
// counting them sees two problems where there is one.
|
|
SilenceErrors: true,
|
|
SilenceUsage: true, // past argument parsing, a failure is not a usage error
|
|
RunE: func(_ *cobra.Command, _ []string) error {
|
|
|
|
// Fail on an ABSENT credential rather than proceeding unauthenticated. An
|
|
// empty token produces a 401 that reads like a permissions problem, which
|
|
// is the failure mode that hid a broken npm publish for weeks — every
|
|
// request unauthenticated because a name resolved to "" and nothing said
|
|
// so.
|
|
forgeTok, githubTok := os.Getenv("FORGE_TOKEN"), os.Getenv("GITHUB_TOKEN")
|
|
if forgeTok == "" {
|
|
return fmt.Errorf("FORGE_TOKEN is empty — refusing to reconcile unauthenticated")
|
|
}
|
|
if githubTok == "" {
|
|
return fmt.Errorf("GITHUB_TOKEN is empty — refusing to resolve names unauthenticated")
|
|
}
|
|
|
|
entries, err := load(table)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("%s is empty: that is a config mistake, not an instruction to do nothing", table)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
gh := mirror.NewGitHub(githubTok)
|
|
plan, planErrs := mirror.Plan(ctx, gh, entries)
|
|
for _, e := range planErrs {
|
|
fmt.Fprintf(os.Stderr, "!! %v\n", e)
|
|
}
|
|
fmt.Printf("== %d of %d entries resolved\n", len(plan), len(entries))
|
|
for _, p := range plan {
|
|
if p.Moved {
|
|
// Printed loudly: a moved repo is exactly what the predecessor could not see.
|
|
fmt.Printf(" %s <- %s (renamed or transferred)\n", p.Entry, p.GitHub.FullName)
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
// A dry run must exit non-zero on a plan error, or "it printed fine"
|
|
// reads as "the config is good".
|
|
if len(planErrs) > 0 {
|
|
return failed(len(planErrs))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
results := mirror.Reconcile(ctx, mirror.NewForge(forge, forgeTok), gh, mirror.RealGit, forge, work, plan)
|
|
for _, r := range results {
|
|
switch r.Outcome {
|
|
case mirror.Moved:
|
|
fmt.Printf("++ %s: %s -> %s\n", r.Entry, short(r.From), short(r.To))
|
|
case mirror.Diverged, mirror.Failed:
|
|
fmt.Fprintf(os.Stderr, "!! %s: %s — %s\n", r.Entry, r.Outcome, r.Detail)
|
|
case mirror.Skipped:
|
|
fmt.Printf("-- %s: %s\n", r.Entry, r.Detail)
|
|
case mirror.InSync:
|
|
}
|
|
}
|
|
|
|
counts, needsHuman := mirror.Summary(results)
|
|
fmt.Printf("== %s\n", format(counts))
|
|
if n := len(planErrs) + len(needsHuman); n > 0 {
|
|
return failed(n)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVar(&table, "table", common.DefaultMirrorTable, "repository table (JSON)")
|
|
cmd.Flags().StringVar(&work, "work", filepath.Join(os.TempDir(), "cd-mirror"), "where git object stores are kept between runs")
|
|
cmd.Flags().StringVar(&forge, "forge", envOr("FORGE_URL", "http://hanzo-git.hanzo.svc"), "forge base URL")
|
|
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "resolve and report the plan; change nothing")
|
|
cmd.Flags().DurationVar(&timeout, "timeout", 100*time.Minute, "overall budget")
|
|
return cmd
|
|
}
|
|
|
|
// failed reports how many repos need a person, as an error so cobra sets the exit
|
|
// status. The count is the message: a scheduler reads the status, a person reads
|
|
// the line above it.
|
|
func failed(n int) error { return fmt.Errorf("%d need a person", n) }
|
|
|
|
// load reads the table. Direction is never defaulted, because the default decides
|
|
// which side may overwrite the other — and a table that silently means "native"
|
|
// makes every unlisted-direction entry a push into a repo we do not own.
|
|
func load(path string) ([]mirror.Entry, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read table: %w", err)
|
|
}
|
|
var entries []mirror.Entry
|
|
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
return nil, fmt.Errorf("parse table: %w", err)
|
|
}
|
|
for i, e := range entries {
|
|
if e.Org == "" || e.Name == "" {
|
|
return nil, fmt.Errorf("entry %d: org and name are both required", i)
|
|
}
|
|
if e.Direction == "" {
|
|
return nil, fmt.Errorf("%s: direction is required (%q or %q) — it decides which side may overwrite the other",
|
|
e, mirror.Native, mirror.Mirror)
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func format(counts map[mirror.Outcome]int) string {
|
|
keys := make([]string, 0, len(counts))
|
|
for k := range counts {
|
|
keys = append(keys, string(k))
|
|
}
|
|
sort.Strings(keys)
|
|
var out strings.Builder
|
|
for _, k := range keys {
|
|
out.WriteString(fmt.Sprintf("%s=%d ", k, counts[mirror.Outcome(k)]))
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func short(s string) string {
|
|
if len(s) > 8 {
|
|
return s[:8]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|