- setup: with RUNNER_DIR, fetch the runner registration token from Gitea's admin API (POST /admin/actions/runners/registration-token) instead of the gitea CLI on Gitea's volume; record secrets/setup-status (ok, unreachable, bad-credentials) and exit cleanly when Gitea cannot be used. - mirror: Radicle is optional. Repositories wait until a node is there (key and control socket in RAD_HOME), re-checked every reconcile; RADICLE=off. - page: /status.json; hide the Radicle parts without a node, banner when Gitea is unreachable or refuses the login. - example workflow: runs-on: ipcr, the forge's own runner label. - docs: two listings, split (IPCR-Forge) and all in one (IPCR-Forge-AIO).
200 lines
6.2 KiB
Go
200 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// What is copied, both ways of the bare mirror: branches and tags. Not refs/pull/* (Gitea's PR
|
|
// heads), not notes or anything else. Branches and tags are forced: Gitea is the truth, and a
|
|
// force push or a moved tag there is mirrored as such. --prune makes deletions follow too.
|
|
var refspecs = []string{"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"}
|
|
|
|
type mirror struct {
|
|
gitea *gitea
|
|
state *state
|
|
repoDir string // one bare repository per Gitea repository: <id>.git
|
|
radHome string
|
|
// RADICLE=off: never mirror, whatever is mounted.
|
|
radicleOff bool
|
|
// Called after each reconcile pass (the admin pages keep IPCR's allowlist in step).
|
|
onReconcile func()
|
|
}
|
|
|
|
// sync brings one Gitea repository's Radicle copy up to date, and records the outcome.
|
|
func (m *mirror) sync(id int64) {
|
|
prev := m.state.get(id)
|
|
r, err := m.gitea.repoByID(id)
|
|
if err != nil {
|
|
log.Printf("sync %d: %v", id, err)
|
|
return
|
|
}
|
|
if r == nil || !r.public() {
|
|
// Private now, or deleted. Only a repository that was mirrored has anything to record.
|
|
if m.state.known(id) && prev.State != stFrozen {
|
|
prev.State = stFrozen
|
|
prev.Error = "no longer public in Gitea: not synced any more (what was published stays on the Radicle network)"
|
|
m.save(prev)
|
|
log.Printf("sync %s: frozen, no longer public", prev.FullName)
|
|
}
|
|
return
|
|
}
|
|
if r.Fork || r.Mirror {
|
|
// Did not originate here: a fork's upstream or a mirror's source is the one to publish.
|
|
return
|
|
}
|
|
cur := prev
|
|
cur.FullName = r.FullName
|
|
if r.Empty {
|
|
if cur.RID == "" {
|
|
cur.State, cur.Error = stWaiting, ""
|
|
m.save(cur)
|
|
}
|
|
return
|
|
}
|
|
if !m.radicleReady() {
|
|
// No node (the split listing without the Radicle app, or the node not started yet). Nothing
|
|
// is fetched; the reconcile loop looks again, so installing Radicle later starts the mirror.
|
|
if cur.State != stWaiting || cur.Error != errNoRadicle {
|
|
cur.State, cur.Error = stWaiting, errNoRadicle
|
|
m.save(cur)
|
|
}
|
|
return
|
|
}
|
|
if err := m.publish(r, &cur); err != nil {
|
|
cur.State, cur.Error = stError, err.Error()
|
|
log.Printf("sync %s: %v", r.FullName, err)
|
|
} else {
|
|
cur.State, cur.Error, cur.LastSync = stSynced, "", time.Now().UTC()
|
|
}
|
|
m.save(cur)
|
|
}
|
|
|
|
const errNoRadicle = "no Radicle node: mirrored once the Radicle app is installed and running"
|
|
|
|
// radicleReady reports whether a Radicle node is there to mirror into: its key (rad auth ran) and
|
|
// its control socket (the node runs) in RAD_HOME.
|
|
func (m *mirror) radicleReady() bool {
|
|
if m.radicleOff {
|
|
return false
|
|
}
|
|
for _, p := range []string{"keys/radicle", "node/control.sock"} {
|
|
if _, err := os.Stat(filepath.Join(m.radHome, p)); err != nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m *mirror) save(r repoState) {
|
|
if err := m.state.put(r); err != nil {
|
|
log.Printf("state: %v", err)
|
|
}
|
|
}
|
|
|
|
// publish fetches the repository from Gitea into its bare mirror, creates its Radicle repository on
|
|
// first sight, and pushes what changed.
|
|
func (m *mirror) publish(r *repo, cur *repoState) error {
|
|
dir := filepath.Join(m.repoDir, fmt.Sprintf("%d.git", r.ID))
|
|
if _, err := os.Stat(dir); err != nil {
|
|
if _, err := m.git("", "init", "--quiet", "--bare", dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
src := m.gitea.base + "/" + r.FullName + ".git"
|
|
args := append([]string{"-c", "http.extraHeader=" + m.gitea.gitAuth(), "fetch", "--quiet", "--prune", "--no-tags", src}, refspecs...)
|
|
if _, err := m.git(dir, args...); err != nil {
|
|
return fmt.Errorf("fetch from Gitea: %w", err)
|
|
}
|
|
// rad init and the project's default branch read HEAD.
|
|
if _, err := m.git(dir, "symbolic-ref", "HEAD", "refs/heads/"+r.DefaultBranch); err != nil {
|
|
return err
|
|
}
|
|
digest, err := m.refsDigest(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if cur.RID == "" {
|
|
// A previous attempt may have created it and failed afterwards: rad init leaves the remote.
|
|
if rid := m.remoteRID(dir); rid != "" {
|
|
cur.RID = rid
|
|
} else {
|
|
rid, err := m.radInit(dir, r)
|
|
if err != nil {
|
|
return fmt.Errorf("rad init: %w", err)
|
|
}
|
|
cur.RID = rid
|
|
log.Printf("sync %s: created %s", r.FullName, rid)
|
|
}
|
|
cur.DefaultBranch = r.DefaultBranch
|
|
m.save(*cur) // before anything else can fail: never init the same repository twice
|
|
} else if m.remoteRID(dir) == "" {
|
|
// The mirror was deleted (it is disposable): point a fresh one at the existing repository.
|
|
if err := m.addRemote(dir, cur.RID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !cur.Crefs {
|
|
if err := m.radCanonicalTags(cur.RID); err != nil {
|
|
log.Printf("sync %s: canonical tags rule: %v (tags stay under the node's namespace)", r.FullName, err)
|
|
} else {
|
|
cur.Crefs = true
|
|
}
|
|
}
|
|
|
|
if digest == cur.Refs && cur.State == stSynced && cur.DefaultBranch == r.DefaultBranch {
|
|
return nil
|
|
}
|
|
out, err := m.git(dir, append([]string{"push", "--prune", "--porcelain", "-o", "no-sync", "rad"}, refspecs...)...)
|
|
if err != nil {
|
|
return fmt.Errorf("push to Radicle: %w", err)
|
|
}
|
|
if changed := pushedRefs(out); len(changed) > 0 {
|
|
log.Printf("sync %s → %s: %s", r.FullName, cur.RID, strings.Join(changed, ", "))
|
|
}
|
|
if cur.DefaultBranch != r.DefaultBranch {
|
|
if err := m.radDefaultBranch(cur.RID, r.DefaultBranch); err != nil {
|
|
return fmt.Errorf("default branch: %w", err)
|
|
}
|
|
cur.DefaultBranch = r.DefaultBranch
|
|
}
|
|
cur.Refs = digest
|
|
m.announce(cur.RID)
|
|
return nil
|
|
}
|
|
|
|
// refsDigest summarises the mirror's branches and tags; equal digests mean nothing to push.
|
|
func (m *mirror) refsDigest(dir string) (string, error) {
|
|
out, err := m.git(dir, "for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256([]byte(out))
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
// pushedRefs reads `git push --porcelain` output and returns the refs that changed, for the log.
|
|
// Porcelain lines are "<flag>\t<from>:<to>\t<summary>"; '=' is up to date.
|
|
func pushedRefs(out string) []string {
|
|
var changed []string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) < 3 || len(f[0]) != 1 || f[0] == "=" {
|
|
continue
|
|
}
|
|
to := f[1]
|
|
if i := strings.LastIndexByte(to, ':'); i >= 0 {
|
|
to = to[i+1:]
|
|
}
|
|
changed = append(changed, strings.TrimPrefix(strings.TrimPrefix(to, "refs/heads/"), "refs/")+" "+f[2])
|
|
}
|
|
return changed
|
|
}
|