Files
ipcr-forge/forge/main.go
T
Worph ca8f87050b
image / vet (push) Successful in 1m51s
image / image (push) Failing after 29s
Split listing support: Gitea and Radicle as separate apps
- 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).
2026-10-05 16:11:24 +00:00

217 lines
7.9 KiB
Go

// ipcr-forge — the glue of IPCR Forge: Gitea is where people work, Radicle is where the code
// is replicated, and this service keeps the second following the first. One way only.
//
// serve (default) webhook receiver + mirror worker + the forge's page
// setup install step: Gitea token, system webhook, example repository (idempotent)
// health exit 0 when `serve` answers, for the container healthcheck
// gate the staging registry's front door: per-repository push credentials (gate.go)
//
// What gets mirrored: every public, non-fork, non-mirror Gitea repository, as seen by a token
// scoped `public-only` — a private repository is invisible to the service, not filtered by it.
// Each one is `rad init`ed by this node on first sight (the node's key is its only delegate) and
// then receives its branches and tags with `git push rad`. A repository that turns private or is
// deleted is frozen: no further sync, and nothing is taken back from the network (it cannot be).
//
// Triggers: Gitea's system webhook (push, create, delete, repository) for latency, and a full
// reconcile every RECONCILE for whatever a lost delivery, a rename or a visibility change missed.
//
// Environment:
//
// GITEA_URL Gitea, internal address (default http://gitea:3000)
// GITEA_OWNER the admin account (default gitea_admin)
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
// SECRETS_DIR gitea-token and hook-secret (default /secrets)
// STATE_DIR repos.json and the bare mirrors (default /forge)
// RAD_HOME the node's Radicle home (default /radicle-home). Optional: without a node
// there (no key, or no control socket) nothing is mirrored until one appears
// RADICLE off: never mirror to Radicle (default auto)
// RUNNER_DIR setup only: act_runner's data folder; when set and the runner is not
// registered, setup leaves a registration token there (the split listing)
// WEB_LISTEN the page (default :8080)
// HOOK_LISTEN the webhook, internal only (default :8081)
// HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea)
// EXAMPLE_REPO setup only: example repository to create under GITEA_OWNER; empty = none
// (default ipcr-hello)
// IPCR_PUBLISHED IPCR's published.json, served as /images.json
// (default /srv/ipcr-state/published.json)
// RECONCILE full pass interval (default 10m)
//
// Admin pages (/admin, auth.go and admin.go), on when IPCR_ADMIN is set:
//
// PUBLIC_HOSTS the hosts the page is served on (ipcr-forge-<domain>, …), comma separated;
// setup registers one OAuth2 redirect URI per host
// IPCR_ADMIN ipcrd's admin API (e.g. http://ipcr-gateway:4769)
// IPCR_ADMIN_TOKEN_FILE its token (default /srv/ipcr-state/admin-token)
// KUBO_KEYSTORE Kubo's keystore, read-only, for key backups (default /srv/ipcr-keystore)
// IMPORT_PUBLISHER the publisher key's name (default forge)
// IPCR_ROOT_ORG organisation published at the root of the forge's name, until changed in
// the admin page (default: none)
// GATE_CREDENTIALS the gate's credentials.json (default /forge/gate/credentials.json)
// IPCR_STAGING_AUTH IPCR's own staging credential (default /srv/ipcr-state/staging-auth)
package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
var err error
switch cmd {
case "serve":
err = serve()
case "setup":
err = setup()
case "health":
err = health()
case "gate":
err = gateMain()
default:
err = fmt.Errorf("usage: %s serve | setup | health | gate", os.Args[0])
}
if err != nil {
log.Fatal(err)
}
}
func health() error {
c := &http.Client{Timeout: 5 * time.Second}
r, err := c.Get("http://127.0.0.1" + env("WEB_LISTEN", ":8080") + "/healthz")
if err != nil {
return err
}
r.Body.Close()
if r.StatusCode != http.StatusOK {
return fmt.Errorf("healthz: %s", r.Status)
}
return nil
}
func serve() error {
every, err := time.ParseDuration(env("RECONCILE", "10m"))
if err != nil {
return fmt.Errorf("RECONCILE: %w", err)
}
stateDir := env("STATE_DIR", "/forge")
st, err := loadState(stateDir + "/repos.json")
if err != nil {
return err
}
secrets := env("SECRETS_DIR", "/secrets")
m := &mirror{
gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"),
state: st,
repoDir: stateDir + "/repos",
radHome: env("RAD_HOME", "/radicle-home"),
radicleOff: env("RADICLE", "auto") == "off",
}
if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
return err
}
q := newQueue()
go q.run(m.sync)
hook := &hookHandler{secretFile: secrets + "/hook-secret", enqueue: q.add}
hmux := http.NewServeMux()
hmux.Handle("POST /hooks/gitea", hook)
go func() {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}()
mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json"), func() siteStatus {
return siteStatus{Gitea: readSetupStatus(secrets), Radicle: m.radicleReady()}
})
if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil {
return err
}
adminGitea := newGitea(m.gitea.base, m.gitea.owner, secrets+"/gitea-admin-token")
adm := &adminAPI{
ipcr: strings.TrimRight(ipcr, "/"), tokenFile: env("IPCR_ADMIN_TOKEN_FILE", "/srv/ipcr-state/admin-token"),
keystore: env("KUBO_KEYSTORE", "/srv/ipcr-keystore"), publisher: env("IMPORT_PUBLISHER", "forge"),
settings: stateDir + "/admin.json", gitea: m.gitea, admin: adminGitea, rootOrg: os.Getenv("IPCR_ROOT_ORG"),
creds: &pushCreds{file: env("GATE_CREDENTIALS", stateDir+"/gate/credentials.json"),
stagingAuth: env("IPCR_STAGING_AUTH", "/srv/ipcr-state/staging-auth"), gitea: adminGitea},
http: &http.Client{Timeout: 3 * time.Minute},
}
adminRoutes(mux, au, adm)
// IPCR publishes nothing until it has the list (IMPORT_ALLOW_REQUIRED): send it as soon as
// both ends are up, then after every reconcile.
go func() {
for {
if !m.gitea.hasToken() {
time.Sleep(15 * time.Second)
continue
}
err := adm.pushAllowlist()
if err == nil {
return
}
log.Printf("allowlist: %v (retrying)", err)
time.Sleep(30 * time.Second)
}
}()
m.onReconcile = func() {
if err := adm.pushAllowlist(); err != nil {
log.Printf("allowlist: %v", err)
}
}
log.Printf("forge: admin pages on, IPCR admin API %s", ipcr)
}
go reconcileLoop(m, q, every)
log.Printf("forge: %s → Radicle (%s), reconcile every %s", m.gitea.base, m.radHome, every)
return http.ListenAndServe(env("WEB_LISTEN", ":8080"), mux)
}
// reconcileLoop queues every repository Gitea shows as public, plus every one already known (so one
// that turned private or was deleted is noticed and frozen). The first pass runs at start-up.
func reconcileLoop(m *mirror, q *queue, every time.Duration) {
for {
if !m.gitea.hasToken() {
// The setup step runs after the stack is up, so on a first install this is normal.
log.Printf("reconcile: waiting for the Gitea token (written by the setup step)")
time.Sleep(15 * time.Second)
continue
}
repos, err := m.gitea.publicRepos()
if err != nil {
// Gitea restarting, typically: try again soon rather than in a full interval.
log.Printf("reconcile: %v", err)
time.Sleep(time.Minute)
continue
}
seen := map[int64]bool{}
for _, r := range repos {
seen[r.ID] = true
q.add(r.ID)
}
for _, id := range m.state.ids() {
if !seen[id] {
q.add(id)
}
}
if m.onReconcile != nil {
m.onReconcile()
}
time.Sleep(every)
}
}