mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
Replaces hanzoai/mirrors' reconcile.py + sync.py. The port is not a translation:
each thing it does differently is a bug the Python had, and every one of those
bugs failed SILENTLY.
Names are RESOLVED, never matched against a listing. Resolve and List are
separate methods so a call site has to choose. GET /repos/{org}/{name} follows a
rename and a transfer permanently; a listing reports a repo only under its
current name in its current org, so six entries whose repos had moved were never
visited once -- and nothing failed, because an absent name raises nothing. The
dry run against live GitHub now prints all five drifted repos by name:
hanzoai/sign <- hanzoai/esign
hanzoai/kms <- hanzoai/kms-v1
hanzoai/hanzo.{sh,one,network} <- hanzo-apps/*
The table is CONFIG (mirror/repos.json), not a list literal in source. Adding
hanzo-ai/cloud hours ago was a code edit, which is not what declared state means
-- and until then cloud was in NEITHER leg: is_mirror=0 so nothing pulled in,
absent from the table so nothing pushed in. Its CI ran on a commit four behind
for a day while merely looking red. All 24 entries carry over, cloud and crawl
included.
Direction is ONE typed value with one meaning. In the Python `mirror` meant both
a vestigial git config flag and the forge's actual push refspecs, and reading the
wrong one produced a confident wrong answer about whether a push could delete
history.
FAST-FORWARD IS GIT'S GUARANTEE, NOT THIS PROGRAM'S: the refspec carries no
leading '+'. A check written in Go could be reasoned around by a later edit; a
missing '+' cannot. TestFastForwardNeverForces asserts on the ARGUMENT rather
than the outcome, so it fails if anyone ever adds one. A push mirror once pruned
the v1.0-v1.31 tag range off an IAM repo and the objects were then collected.
Divergence is reported and left alone, and exits non-zero. Two people disagreeing
about history is a question for a person; a sync job that settles it destroys
whichever side lost.
Fails CLOSED on an absent credential instead of proceeding unauthenticated. An
empty token yields a 401 that reads like a permissions problem -- the exact shape
that hid a broken npm publish for weeks, every request unauthenticated because a
name resolved to "" and nothing said so. Same for a table entry with no
direction: that field decides which side may overwrite the other, so there is no
safe default.
Git is injectable, so the fast-forward decision is tested without a network. A
safety property that can only be exercised against live remotes is one nobody
exercises. 7 tests, all passing; build and vet clean.
Not yet wired to a schedule, and reconcile.py still runs -- deleting it before
this replaces it would leave a window with neither.
Co-authored-by: Hanzo Dev <[email protected]>
196 lines
6.3 KiB
Go
196 lines
6.3 KiB
Go
// Package mirror reconciles which repositories live on git.hanzo.ai and which
|
|
// direction each one syncs.
|
|
//
|
|
// It replaces a Python script (hanzoai/mirrors reconcile.py + sync.py) that drove
|
|
// the forge over untyped HTTP. That script worked, and its two worst bugs were
|
|
// both consequences of being untyped against a typed API:
|
|
//
|
|
// - The direction table was matched against a GitHub org LISTING rather than
|
|
// resolved per name. 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 never visited even once — and nothing failed, because a name absent
|
|
// from a listing raises nothing. Here Resolve is a distinct method from List,
|
|
// so the two cannot be confused at a call site.
|
|
// - `mirror` meant two different things — a vestigial git config flag and the
|
|
// forge's actual push refspecs — and reading the wrong one produced a
|
|
// confident wrong conclusion about whether a push could delete history.
|
|
// Direction is one typed value here, with one meaning.
|
|
package mirror
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Direction is which way a repository syncs. There are exactly two, and the
|
|
// difference is which side is allowed to be authoritative.
|
|
type Direction string
|
|
|
|
const (
|
|
// Native: the forge is canonical and github.com is a push-mirror. Our own code.
|
|
Native Direction = "native"
|
|
// Mirror: an upstream we do not own. Read-only here, so it keeps following.
|
|
Mirror Direction = "mirror"
|
|
)
|
|
|
|
// ErrNotFound is returned when a name resolves to nothing on the remote.
|
|
var ErrNotFound = errors.New("mirror: not found")
|
|
|
|
// Repo is a repository as the remote reports it. The fields are the ones a
|
|
// reconcile decision actually reads; the wire carries far more, and decoding only
|
|
// what is used keeps a schema change from silently altering behaviour.
|
|
type Repo struct {
|
|
FullName string `json:"full_name"`
|
|
Owner struct {
|
|
Login string `json:"login"`
|
|
} `json:"owner"`
|
|
Name string `json:"name"`
|
|
Private bool `json:"private"`
|
|
Empty bool `json:"empty"`
|
|
Mirror bool `json:"mirror"`
|
|
CloneURL string `json:"clone_url"`
|
|
Desc string `json:"description"`
|
|
Size int `json:"size"`
|
|
Default string `json:"default_branch"`
|
|
}
|
|
|
|
// Direction reports the direction this repo is currently configured for.
|
|
//
|
|
// Derived from the forge's own `mirror` field and nothing else. The git config
|
|
// flag of the same name is vestigial — the push path passes explicit refspecs
|
|
// that override it — so reading the config would answer a different question than
|
|
// the one being asked.
|
|
func (r Repo) Direction() Direction {
|
|
if r.Mirror {
|
|
return Mirror
|
|
}
|
|
return Native
|
|
}
|
|
|
|
// Client talks to one forge or to github.com. Two instances, one type: the APIs
|
|
// are compatible for everything here, and giving them separate clients would mean
|
|
// two places to fix a bug in either.
|
|
type Client struct {
|
|
base string
|
|
token string
|
|
auth string // "token" for Gitea, "Bearer" for GitHub
|
|
http *http.Client
|
|
}
|
|
|
|
// NewForge returns a client for a Gitea-compatible forge.
|
|
func NewForge(base, token string) *Client {
|
|
return &Client{
|
|
base: strings.TrimRight(base, "/") + "/v1",
|
|
token: token,
|
|
auth: "token",
|
|
http: &http.Client{Timeout: 10 * time.Minute},
|
|
}
|
|
}
|
|
|
|
// NewGitHub returns a client for github.com.
|
|
func NewGitHub(token string) *Client {
|
|
return &Client{
|
|
base: "https://api.github.com",
|
|
token: token,
|
|
auth: "Bearer",
|
|
http: &http.Client{Timeout: 2 * time.Minute},
|
|
}
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body any) (int, []byte, error) {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rdr)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", c.auth+" "+c.token)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
// Bounded: a forge answering with an unexpected body must not become a memory
|
|
// problem in a controller that runs forever.
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
|
return resp.StatusCode, raw, err
|
|
}
|
|
|
|
// Resolve answers "what is org/name TODAY", following renames and transfers.
|
|
//
|
|
// This is deliberately NOT List-and-search. GET /repos/{org}/{name} follows a
|
|
// rename and a transfer permanently; a listing does neither, which is how six
|
|
// repos went unreconciled and silent. Keeping these as separate methods means a
|
|
// caller has to choose, rather than reach for whichever is at hand.
|
|
func (c *Client) Resolve(ctx context.Context, org, name string) (*Repo, error) {
|
|
st, raw, err := c.do(ctx, http.MethodGet, "/repos/"+url.PathEscape(org)+"/"+url.PathEscape(name), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if st == http.StatusNotFound {
|
|
return nil, fmt.Errorf("%w: %s/%s", ErrNotFound, org, name)
|
|
}
|
|
if st != http.StatusOK {
|
|
return nil, fmt.Errorf("mirror: resolve %s/%s: http %d", org, name, st)
|
|
}
|
|
var r Repo
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// Branch is the tip of one branch.
|
|
type Branch struct {
|
|
Name string `json:"name"`
|
|
Commit struct {
|
|
ID string `json:"id"`
|
|
SHA string `json:"sha"`
|
|
} `json:"commit"`
|
|
}
|
|
|
|
// Tip returns the commit a branch points at, accepting either field name — Gitea
|
|
// answers `commit.id` and GitHub `commit.sha`, and a reconcile that only read one
|
|
// would silently see an empty tip on the other and treat "unknown" as "equal".
|
|
func (c *Client) Tip(ctx context.Context, org, name, branch string) (string, error) {
|
|
st, raw, err := c.do(ctx, http.MethodGet,
|
|
"/repos/"+url.PathEscape(org)+"/"+url.PathEscape(name)+"/branches/"+url.PathEscape(branch), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if st == http.StatusNotFound {
|
|
return "", fmt.Errorf("%w: %s/%s@%s", ErrNotFound, org, name, branch)
|
|
}
|
|
if st != http.StatusOK {
|
|
return "", fmt.Errorf("mirror: tip %s/%s@%s: http %d", org, name, branch, st)
|
|
}
|
|
var b Branch
|
|
if err := json.Unmarshal(raw, &b); err != nil {
|
|
return "", err
|
|
}
|
|
if b.Commit.ID != "" {
|
|
return b.Commit.ID, nil
|
|
}
|
|
return b.Commit.SHA, nil
|
|
}
|