Split listing support: Gitea and Radicle as separate apps
image / vet (push) Successful in 1m51s
image / image (push) Failing after 29s

- 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).
This commit is contained in:
Worph
2026-10-05 16:11:24 +00:00
parent eb80042f13
commit ca8f87050b
10 changed files with 317 additions and 27 deletions
+8 -5
View File
@@ -15,9 +15,12 @@ registry gateway, IPFS import, IPNS naming and admin API), used here as the imag
`ghcr.io/yundera/ipcr`. `ghcr.io/yundera/ipcr`.
- **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified. - **[docs/forge.md](docs/forge.md)**: how it works, the trust model, findings, what was verified.
- **[Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)** (in the IPCR repository, with the - **[Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)** and
rest of the IPCR app store): the Yundera AppStore listing (compose, rationale, seed). It pins this **[Apps/IPCR-Forge-AIO](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge-AIO)** (in the IPCR repository,
repository's image and the engine's. with the rest of the IPCR app store): the Yundera AppStore listings (compose, rationale, seed). The
first uses the store's Gitea app (and its Radicle app, if installed); the second bundles both.
Both pin this repository's image and the engine's. See
[docs/forge.md](docs/forge.md#two-listings-split-and-all-in-one).
- **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only): - **[forge/](forge/)**: the forge service, `ghcr.io/yundera/ipcr-forge` (Go, standard library only):
the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's the Gitea → Radicle mirror, the forge's page and admin pages (Gitea login), the staging registry's
gate and per-repository push credentials, and the install step. gate and per-repository push credentials, and the install step.
@@ -33,5 +36,5 @@ docker build -t ghcr.io/yundera/ipcr-forge:dev forge
Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z` Releases: a `vX.Y.Z` tag publishes `ghcr.io/yundera/ipcr-forge:X.Y.Z`
([.github/workflows/image.yml](.github/workflows/image.yml)). Then bump the version in the ([.github/workflows/image.yml](.github/workflows/image.yml)). Then bump the version in the
listing, `Apps/IPCR-Forge/docker-compose.yml` in the IPCR repository (the service, `forge-gate` listings, `Apps/IPCR-Forge/docker-compose.yml` and `Apps/IPCR-Forge-AIO/docker-compose.yml` in
and the `forge-setup` step). the IPCR repository (the service, `forge-gate` and the `forge-setup` step, in each).
+45 -3
View File
@@ -6,9 +6,11 @@ naming, the admin API). The engine knows nothing about Gitea, Radicle or this fo
the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its the engine's image (`ghcr.io/yundera/ipcr`) and the contract listed in its
[design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons). [design notes](https://github.com/Yundera/ipcr/blob/main/docs/design.md#contract-with-add-ons).
How the forge (store listing: [Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge), in the IPCR repository) goes from `git push` to `docker pull`, why it How the forge (store listings: [Apps/IPCR-Forge](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge)
is shaped this way, and what was learned building it. The listing's own security argument is in and [Apps/IPCR-Forge-AIO](https://github.com/Yundera/ipcr/tree/main/Apps/IPCR-Forge-AIO), in the IPCR repository)
[rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge/rationale.md). goes from `git push` to `docker pull`, why it is shaped this way, and what was learned building it.
The listings' own security argument is in
[rationale.md](https://github.com/Yundera/ipcr/blob/main/Apps/IPCR-Forge-AIO/rationale.md).
## Goal ## Goal
@@ -60,6 +62,42 @@ anyone: docker pull ipcr.localhost:4767/ipns/<forge k51… | example.eth>/<owner
The two legs do not depend on each other: a repository can be built without being mirrored The two legs do not depend on each other: a repository can be built without being mirrored
(private repositories), and mirrored without being built (no workflow). (private repositories), and mirrored without being built (no workflow).
## Two listings: split and all in one
The same image runs in both; only the compose differs.
| | **IPCR-Forge** (split) | **IPCR-Forge-AIO** (all in one) |
| --- | --- | --- |
| Gitea | the store's **Gitea** app, required, reached at `gitea:3000` on `pcs` | bundled |
| Radicle | the store's **Radicle** app, optional, its home mounted from `/DATA/AppData/radicle/home` | bundled |
| Runner labels | `ipcr` only | `ipcr` and `ubuntu-latest`/`-24.04`/`-22.04` |
| Runner registration token | the setup step, through Gitea's API (`RUNNER_DIR`) | an init step on Gitea's volume |
| Beside the Gitea/Radicle apps | yes, that is the point | no: same container names, domains, ports |
Neither sits beside the IPCR app (the engine is bundled in both: its configuration is the forge's).
What the split one relies on, and how each part fails when missing:
- **Gitea**: the forge service calls `http://gitea:3000` and Gitea calls the forge's webhook at
`http://ipcr-forge:8081`, which the Gitea listing allows (`webhook.ALLOWED_HOST_LIST:
external,ipcr-forge`). The setup step signs in once as `FORGE_GITEA_ADMIN` / `FORGE_GITEA_PASSWORD`
(default `gitea_admin` and the server's default password). Gitea mints tokens only for a password,
never for another token, so these are what to change if the password was changed. Setup leaves
`secrets/setup-status` (`ok`, `unreachable`, `bad-credentials`), and the page shows a banner
for the last two. Setup exits cleanly in either case, so the install never hangs on a missing Gitea.
- **The runner**: the Gitea app has its own runner, `gitea-runner`, with the usual labels. Its jobs
run in its own daemon, where `localhost:5000` is nothing. So the forge's runner takes `ipcr` only,
and publishing workflows say `runs-on: ipcr` (the example does; it works in both listings).
- **Radicle**: the forge service needs the node's home itself (its key, its storage, its control
socket), not an API: Radicle's HTTP API is read-only. A repository is mirrored only when
`keys/radicle` and `node/control.sock` are there; otherwise it shows *waiting* and the page hides
the Radicle parts. The reconcile loop looks again every pass, so installing Radicle later starts
the mirror. `RADICLE=off` turns it off for good. The split listing creates
`/DATA/AppData/radicle/home` itself, owned by `$PUID`: left to Docker it would be created as
root, and the Radicle app's `rad auth` (as `$PUID`) could not write its key there later.
- **Versions**: the forge image carries the `rad` CLI at the version the Radicle listing pins
(`RADICLE_VERSION` in [forge/Dockerfile](../forge/Dockerfile)). Bump both together.
## The forge service ## The forge service
[forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`. [forge/](../forge/): one Go binary, `ipcr-forge`, image `ghcr.io/yundera/ipcr-forge`.
@@ -248,6 +286,8 @@ A repository opts in by committing a workflow under `.gitea/workflows/`. The tem
- `images: localhost:5000/${{ github.repository }}`: the repository's own name at the staging - `images: localhost:5000/${{ github.repository }}`: the repository's own name at the staging
registry, the only one its token may push. `docker/metadata-action` lowercases it. registry, the only one its token may push. `docker/metadata-action` lowercases it.
- `context: .` and `driver-opts: network=host`: see findings 2 and 3. - `context: .` and `driver-opts: network=host`: see findings 2 and 3.
- `runs-on: ipcr`: the forge's own runner, the only one whose jobs reach the staging registry (see
[Two listings](#two-listings-split-and-all-in-one)).
- The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`. - The image is published as `/ipns/<forge>/<owner>/<repo>:<tag>`.
## Findings ## Findings
@@ -408,3 +448,5 @@ New in v3 (tested locally with Gitea 1.27.3 and rad 1.10.1, 2026-10-04):
- **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on. - **act_runner** is pinned at 0.6.1, proven with Gitea 1.27.3. Upstream has moved on.
- **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built. - **Radicle → Gitea:** patches opened on Radicle could become pull requests. Not built.
- **Store install:** test through the store, not only as a hand install. - **Store install:** test through the store, not only as a hand install.
- **Split listing:** the store has no dependencies between apps; removing the Gitea app leaves the
forge reporting `unreachable` on its page, nothing more.
+94 -1
View File
@@ -180,7 +180,9 @@ func TestExampleEmbedded(t *testing.T) {
func TestWeb(t *testing.T) { func TestWeb(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
st, _ := loadState(filepath.Join(dir, "repos.json")) st, _ := loadState(filepath.Join(dir, "repos.json"))
h := webHandler(st, filepath.Join(dir, "published.json")) h := webHandler(st, filepath.Join(dir, "published.json"), func() siteStatus {
return siteStatus{Gitea: readSetupStatus(dir), Radicle: false}
})
get := func(p string) (int, string) { get := func(p string) (int, string) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", p, nil)) h.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
@@ -199,11 +201,102 @@ func TestWeb(t *testing.T) {
if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" { if c, b := get("/repos.json"); c != 200 || strings.TrimSpace(b) != "[]" {
t.Errorf("/repos.json: %d %q", c, b) t.Errorf("/repos.json: %d %q", c, b)
} }
if c, b := get("/status.json"); c != 200 || strings.TrimSpace(b) != `{"gitea":"unknown","radicle":false}` {
t.Errorf("/status.json before setup: %d %q", c, b)
}
writeSetupStatus(dir, setupBadCredentials)
if _, b := get("/status.json"); strings.TrimSpace(b) != `{"gitea":"bad-credentials","radicle":false}` {
t.Errorf("/status.json after setup: %q", b)
}
if c, _ := get("/nothing"); c != 404 { if c, _ := get("/nothing"); c != 404 {
t.Errorf("/nothing: %d", c) t.Errorf("/nothing: %d", c)
} }
} }
// fakeGitea answers the few API calls a test needs; anything else is a 404.
func fakeGitea(t *testing.T, routes map[string]http.HandlerFunc) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h, ok := routes[r.Method+" "+r.URL.Path]; ok {
h(w, r)
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
return srv
}
// Without a Radicle node, a public repository waits (and nothing is fetched); with one, the mirror
// would run (radicleReady is what gates it).
func TestSyncWithoutRadicle(t *testing.T) {
dir := t.TempDir()
srv := fakeGitea(t, map[string]http.HandlerFunc{
"GET /api/v1/repositories/7": func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"id":7,"name":"app","full_name":"metadec/app","default_branch":"main"}`))
},
})
st, _ := loadState(filepath.Join(dir, "repos.json"))
rad := filepath.Join(dir, "radicle-home")
m := &mirror{gitea: newGitea(srv.URL, "gitea_admin", filepath.Join(dir, "token")), state: st,
repoDir: filepath.Join(dir, "repos"), radHome: rad}
m.sync(7)
got := st.get(7)
if got.State != stWaiting || got.Error != errNoRadicle || got.FullName != "metadec/app" {
t.Errorf("without Radicle: %+v", got)
}
if _, err := os.Stat(m.repoDir); err == nil {
t.Error("fetched a mirror with no Radicle node to push it to")
}
os.MkdirAll(filepath.Join(rad, "keys"), 0o755)
os.WriteFile(filepath.Join(rad, "keys", "radicle"), []byte("k"), 0o600)
if m.radicleReady() {
t.Error("ready with a key but no running node")
}
os.MkdirAll(filepath.Join(rad, "node"), 0o755)
os.WriteFile(filepath.Join(rad, "node", "control.sock"), nil, 0o600)
if !m.radicleReady() {
t.Error("not ready with a key and a control socket")
}
m.radicleOff = true
if m.radicleReady() {
t.Error("RADICLE=off ignored")
}
}
func TestRunnerToken(t *testing.T) {
dir := t.TempDir()
calls := 0
srv := fakeGitea(t, map[string]http.HandlerFunc{
"POST /api/v1/admin/actions/runners/registration-token": func(w http.ResponseWriter, r *http.Request) {
calls++
if u, p, ok := r.BasicAuth(); !ok || u != "gitea_admin" || p != "pw" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Write([]byte(`{"token":"reg-123"}`))
},
})
g := newGitea(srv.URL, "gitea_admin", filepath.Join(dir, "gitea-token"))
g.password = "pw"
if err := ensureRunnerToken(g, dir); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(dir, "token")); string(b) != "reg-123" {
t.Errorf("token file %q", b)
}
// Registered: left alone.
os.WriteFile(filepath.Join(dir, ".runner"), []byte(`{"id":1}`), 0o600)
if err := ensureRunnerToken(g, dir); err != nil || calls != 1 {
t.Errorf("registered runner: err %v, %d calls", err, calls)
}
g.password = "wrong"
os.Remove(filepath.Join(dir, ".runner"))
if err := ensureRunnerToken(g, dir); err == nil {
t.Error("refused token request not reported")
}
}
// The same vector as gateway/admin_test.go: a Kubo key and its name, and a backup of it that ipcrd // The same vector as gateway/admin_test.go: a Kubo key and its name, and a backup of it that ipcrd
// must be able to open. // must be able to open.
const ( const (
+3 -1
View File
@@ -7,13 +7,15 @@
# the forge sets on every repository allowed to publish # the forge sets on every repository allowed to publish
# images: localhost:5000/… this repository's own name there (no other is accepted) # images: localhost:5000/… this repository's own name there (no other is accepted)
# network=host so buildx's builder can reach that registry # network=host so buildx's builder can reach that registry
# runs-on: ipcr the forge's own runner, the one that sees that registry (a Gitea app's
# own runner, if there is one, does not)
name: image name: image
on: on:
push: push:
tags: ['v*'] tags: ['v*']
jobs: jobs:
image: image:
runs-on: ubuntu-latest runs-on: ipcr
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/login-action@v3 - uses: docker/login-action@v3
+13 -6
View File
@@ -22,7 +22,11 @@
// GITEA_PASSWORD setup only: its password, used once to mint the token and the hook // GITEA_PASSWORD setup only: its password, used once to mint the token and the hook
// SECRETS_DIR gitea-token and hook-secret (default /secrets) // SECRETS_DIR gitea-token and hook-secret (default /secrets)
// STATE_DIR repos.json and the bare mirrors (default /forge) // STATE_DIR repos.json and the bare mirrors (default /forge)
// RAD_HOME the node's Radicle home (default /radicle-home) // 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) // WEB_LISTEN the page (default :8080)
// HOOK_LISTEN the webhook, internal only (default :8081) // HOOK_LISTEN the webhook, internal only (default :8081)
// HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea) // HOOK_URL setup only: how Gitea reaches HOOK_LISTEN (default http://ipcr-forge:8081/hooks/gitea)
@@ -111,10 +115,11 @@ func serve() error {
} }
secrets := env("SECRETS_DIR", "/secrets") secrets := env("SECRETS_DIR", "/secrets")
m := &mirror{ m := &mirror{
gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"), gitea: newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), secrets+"/gitea-token"),
state: st, state: st,
repoDir: stateDir + "/repos", repoDir: stateDir + "/repos",
radHome: env("RAD_HOME", "/radicle-home"), radHome: env("RAD_HOME", "/radicle-home"),
radicleOff: env("RADICLE", "auto") == "off",
} }
if err := os.MkdirAll(m.repoDir, 0o755); err != nil { if err := os.MkdirAll(m.repoDir, 0o755); err != nil {
return err return err
@@ -129,7 +134,9 @@ func serve() error {
log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux)) log.Fatal(http.ListenAndServe(env("HOOK_LISTEN", ":8081"), hmux))
}() }()
mux := webHandler(st, env("IPCR_PUBLISHED", "/srv/ipcr-state/published.json")) 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 != "" { if ipcr := os.Getenv("IPCR_ADMIN"); ipcr != "" {
au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key") au, err := newAuth(splitList(env("PUBLIC_HOSTS", "")), m.gitea.base, secrets, stateDir+"/session-key")
if err != nil { if err != nil {
+27
View File
@@ -21,6 +21,8 @@ type mirror struct {
state *state state *state
repoDir string // one bare repository per Gitea repository: <id>.git repoDir string // one bare repository per Gitea repository: <id>.git
radHome string 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). // Called after each reconcile pass (the admin pages keep IPCR's allowlist in step).
onReconcile func() onReconcile func()
} }
@@ -56,6 +58,15 @@ func (m *mirror) sync(id int64) {
} }
return 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 { if err := m.publish(r, &cur); err != nil {
cur.State, cur.Error = stError, err.Error() cur.State, cur.Error = stError, err.Error()
log.Printf("sync %s: %v", r.FullName, err) log.Printf("sync %s: %v", r.FullName, err)
@@ -65,6 +76,22 @@ func (m *mirror) sync(id int64) {
m.save(cur) 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) { func (m *mirror) save(r repoState) {
if err := m.state.put(r); err != nil { if err := m.state.put(r); err != nil {
log.Printf("state: %v", err) log.Printf("state: %v", err)
+65 -2
View File
@@ -5,6 +5,7 @@ import (
"embed" "embed"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -58,9 +59,16 @@ func ensureOrg(g *gitea, org string) error {
// the root organisation when there is one. // the root organisation when there is one.
// an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs); // an OAuth2 application the admin pages' Gitea login (PUBLIC_HOSTS → its redirect URIs);
// SECRETS_DIR/oauth-client-id and oauth-client-secret. // SECRETS_DIR/oauth-client-id and oauth-client-secret.
// RUNNER_DIR/token when RUNNER_DIR is set and the runner there is not registered yet: a
// global runner registration token (the split listing, which cannot run
// `gitea actions generate-runner-token` on Gitea's own volume).
// SECRETS_DIR/setup-status how the last run went with Gitea (ok, unreachable, bad-credentials),
// for the forge's page.
// //
// The admin password is used here only and never stored. If it was changed since install, the // The admin password is used here only and never stored. If it was changed since install, the
// step leaves the existing token and hook alone instead of failing the start. // step leaves the existing token and hook alone instead of failing the start. When Gitea cannot be
// used at all (not installed, or the password is wrong and there is no token yet), the step records
// why and exits cleanly: the page says what to do, and the next start tries again.
func setup() error { func setup() error {
secrets := env("SECRETS_DIR", "/secrets") secrets := env("SECRETS_DIR", "/secrets")
g := newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), filepath.Join(secrets, "gitea-token")) g := newGitea(env("GITEA_URL", "http://gitea:3000"), env("GITEA_OWNER", "gitea_admin"), filepath.Join(secrets, "gitea-token"))
@@ -68,6 +76,11 @@ func setup() error {
if g.password == "" { if g.password == "" {
return errors.New("GITEA_PASSWORD is required") return errors.New("GITEA_PASSWORD is required")
} }
status := func(s string) {
if err := writeSetupStatus(secrets, s); err != nil {
log.Printf("setup: status: %v", err)
}
}
// Gitea may still be migrating, and its admin account may not exist yet. Bounded: an install // Gitea may still be migrating, and its admin account may not exist yet. Bounded: an install
// step that blocks forever holds the install open forever. // step that blocks forever holds the install open forever.
@@ -79,12 +92,20 @@ func setup() error {
} }
if code == http.StatusUnauthorized && g.hasToken() { if code == http.StatusUnauthorized && g.hasToken() {
log.Printf("setup: the admin password no longer works; keeping the existing token and webhook") log.Printf("setup: the admin password no longer works; keeping the existing token and webhook")
status(setupOK)
return nil return nil
} }
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
} }
if err != nil { if err != nil {
return fmt.Errorf("Gitea not ready after 240s: %w", err) if code == http.StatusUnauthorized {
log.Printf("setup: Gitea refuses %s's password, and there is no token yet: set the forge's Gitea admin credentials (%v)", g.owner, err)
status(setupBadCredentials)
} else {
log.Printf("setup: Gitea not reachable at %s after 240s, is the Gitea app installed? (%v)", g.base, err)
status(setupUnreachable)
}
return nil
} }
if !g.hasToken() { if !g.hasToken() {
@@ -153,12 +174,54 @@ func setup() error {
} }
} }
if dir := os.Getenv("RUNNER_DIR"); dir != "" {
if err := ensureRunnerToken(g, dir); err != nil {
return fmt.Errorf("runner registration token: %w", err)
}
}
if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" { if name := env("EXAMPLE_REPO", "ipcr-hello"); name != "-" && name != "" {
// Not fatal: the forge works without its example. // Not fatal: the forge works without its example.
if err := ensureExample(g, owner, name); err != nil { if err := ensureExample(g, owner, name); err != nil {
log.Printf("setup: example repository: %v", err) log.Printf("setup: example repository: %v", err)
} }
} }
status(setupOK)
return nil
}
// What the last setup run found, in SECRETS_DIR/setup-status (read by the page, see web.go).
const (
setupOK = "ok"
setupUnreachable = "unreachable"
setupBadCredentials = "bad-credentials"
)
func writeSetupStatus(secrets, s string) error {
b, _ := json.Marshal(map[string]string{"gitea": s})
return writeSecret(filepath.Join(secrets, "setup-status"), string(b))
}
// ensureRunnerToken leaves a global runner registration token in dir/token for act_runner, unless
// the runner there is already registered (dir/.runner). Gitea returns the same token until it is
// used, so a repeated call before registration is harmless.
func ensureRunnerToken(g *gitea, dir string) error {
if b, err := os.ReadFile(filepath.Join(dir, ".runner")); err == nil && len(b) > 0 {
return nil
}
var t struct {
Token string `json:"token"`
}
if _, err := g.call("POST", "/admin/actions/runners/registration-token", nil, &t); err != nil {
return err
}
if t.Token == "" {
return errors.New("Gitea returned no token")
}
if err := writeSecret(filepath.Join(dir, "token"), t.Token); err != nil {
return err
}
log.Printf("setup: runner registration token written")
return nil return nil
} }
+1 -1
View File
@@ -15,7 +15,7 @@ import (
const ( const (
stSynced = "synced" // Radicle has what Gitea has stSynced = "synced" // Radicle has what Gitea has
stError = "error" // the last sync failed; retried on the next event or reconcile stError = "error" // the last sync failed; retried on the next event or reconcile
stWaiting = "waiting" // empty repository: nothing to publish yet stWaiting = "waiting" // empty repository, or no Radicle node yet: nothing to publish to
stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted stFrozen = "frozen" // no longer public in Gitea (or deleted): no further sync, nothing retracted
) )
+26 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os" "os"
"path/filepath"
) )
//go:embed web //go:embed web
@@ -13,7 +14,7 @@ var webFiles embed.FS
// webHandler serves the forge's public page. Everything on it is public anyway: links, the // webHandler serves the forge's public page. Everything on it is public anyway: links, the
// documentation, IPCR's list of published images (public on IPFS) and the list of mirrored // documentation, IPCR's list of published images (public on IPFS) and the list of mirrored
// repositories (public on Radicle). Nothing here changes anything. // repositories (public on Radicle). Nothing here changes anything.
func webHandler(st *state, publishedFile string) *http.ServeMux { func webHandler(st *state, publishedFile string, status func() siteStatus) *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
page, _ := webFiles.ReadFile("web/index.html") page, _ := webFiles.ReadFile("web/index.html")
icon, _ := webFiles.ReadFile("web/icon.png") icon, _ := webFiles.ReadFile("web/icon.png")
@@ -42,12 +43,36 @@ func webHandler(st *state, publishedFile string) *http.ServeMux {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(st.list()) json.NewEncoder(w).Encode(st.list())
}) })
mux.HandleFunc("GET /status.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(status())
})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok\n")) w.Write([]byte("ok\n"))
}) })
return mux return mux
} }
// siteStatus is what the page needs to know about the apps the forge relies on: Gitea (as the
// setup step last found it) and whether a Radicle node is there to mirror into.
type siteStatus struct {
Gitea string `json:"gitea"` // ok | unreachable | bad-credentials | unknown (setup has not run)
Radicle bool `json:"radicle"`
}
// readSetupStatus reads what the setup step left in SECRETS_DIR/setup-status.
func readSetupStatus(secrets string) string {
var s struct {
Gitea string `json:"gitea"`
}
b, err := os.ReadFile(filepath.Join(secrets, "setup-status"))
if err != nil || json.Unmarshal(b, &s) != nil || s.Gitea == "" {
return "unknown"
}
return s.Gitea
}
// adminRoutes adds the admin pages: the page itself (signed-in Gitea admins only), the login, and // adminRoutes adds the admin pages: the page itself (signed-in Gitea admins only), the login, and
// the API it calls. // the API it calls.
func adminRoutes(mux *http.ServeMux, au *auth, adm *adminAPI) { func adminRoutes(mux *http.ServeMux, au *auth, adm *adminAPI) {
+35 -7
View File
@@ -67,6 +67,8 @@
.scroll { overflow-x: auto; } .scroll { overflow-x: auto; }
.state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; } .state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); } .state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); }
[hidden] { display: none !important; }
.banner { border: 1px solid var(--bad); border-radius: 8px; padding: 10px 14px; margin: 16px 0; }
footer { margin-top: 48px; color: var(--muted); font-size: 13px; } footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
</style> </style>
</head> </head>
@@ -83,11 +85,12 @@
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as <p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
mirrored to the Radicle peer-to-peer network.</p> mirrored to the Radicle peer-to-peer network.</p>
<div class="banner" id="status-banner" hidden></div>
<div class="cards"> <div class="cards">
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a> <a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a>
<a class="card" href="#images"><b>Published images</b><span>What this forge serves from IPFS, with the line to put in a compose file.</span></a> <a class="card" href="#images"><b>Published images</b><span>What this forge serves from IPFS, with the line to put in a compose file.</span></a>
<a class="card" href="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a> <a class="card" href="#repos" data-radicle><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
</div> </div>
<h2 id="how">How it works</h2> <h2 id="how">How it works</h2>
@@ -102,7 +105,7 @@ mirrored to the Radicle peer-to-peer network.</p>
</div> </div>
<p>Steps 2 and 3 are automatic and usually take a few minutes. The very first build on this server <p>Steps 2 and 3 are automatic and usually take a few minutes. The very first build on this server
takes longer (about 5 to 10 minutes) while it downloads its build images.</p> takes longer (about 5 to 10 minutes) while it downloads its build images.</p>
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a> <p data-radicle>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it, the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
and it stays available even when this server is not. Private repositories are never copied.</p> and it stays available even when this server is not. Private repositories are never copied.</p>
@@ -131,7 +134,8 @@ git push -u origin main</code></pre>
Applications</b>).</li> Applications</b>).</li>
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the <li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style <code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
workflow with three differences: it logs in to <code>localhost:5000</code>, this forge's staging workflow with four differences: it runs on <code>ipcr</code>, this forge's own runner (the
only one that reaches the staging registry), it logs in to <code>localhost:5000</code>, this forge's staging
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
every public repository allowed to publish), the image goes there under the repository's own every public repository allowed to publish), the image goes there under the repository's own
name, and buildx's builder runs with <code>network=host</code> so it can reach that registry. name, and buildx's builder runs with <code>network=host</code> so it can reach that registry.
@@ -141,7 +145,7 @@ on:
tags: ['v*'] tags: ['v*']
jobs: jobs:
image: image:
runs-on: ubuntu-latest runs-on: ipcr
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/login-action@v3 - uses: docker/login-action@v3
@@ -200,7 +204,10 @@ image's content address and can never change.</p>
<p class="muted">New releases need no change to either record: they update the publisher name.</p> <p class="muted">New releases need no change to either record: they update the publisher name.</p>
<h2 id="repos">On Radicle</h2> <h2 id="repos">On Radicle</h2>
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node <p id="no-radicle" class="note" hidden>No Radicle node on this server, so nothing is mirrored yet.
Install the <b>Radicle</b> app from the store: every public repository is then mirrored within one
reconcile (ten minutes at most).</p>
<p data-radicle>Public repositories of this forge, as mirrored to the Radicle network by this server's node
(<code id="node-id">…</code>). Clone one with <code>rad clone &lt;id&gt;</code> from any Radicle node.</p> (<code id="node-id">…</code>). Clone one with <code>rad clone &lt;id&gt;</code> from any Radicle node.</p>
<div id="repos-body"><p class="muted">Loading…</p></div> <div id="repos-body"><p class="muted">Loading…</p></div>
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced <p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
@@ -235,8 +242,8 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
<tr><th>Address</th><th>What</th></tr> <tr><th>Address</th><th>What</th></tr>
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr> <tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr>
<tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></tr> <tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></tr>
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr> <tr data-radicle><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr> <tr data-radicle><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr> <tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
<tr><td><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr> <tr><td><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr>
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr> <tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
@@ -363,6 +370,27 @@ for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blan
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted")); imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
}); });
// What the forge relies on: Gitea (as the setup step last found it), and a Radicle node to mirror
// into. The split listing installs neither, so either may be missing.
fetch("status.json", { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (st) {
if (!st.radicle) {
all("[data-radicle]", function (e) { e.hidden = true; });
document.getElementById("no-radicle").hidden = false;
}
var msg = {
"unreachable": "Gitea is not reachable. This forge needs the Gitea app on this server: install it, then restart IPCR Forge.",
"bad-credentials": "Gitea refused the forge's admin login (gitea_admin and the server's default password, unless changed in the app's settings). Set FORGE_GITEA_ADMIN and FORGE_GITEA_PASSWORD to a Gitea site admin, then restart IPCR Forge."
}[st.gitea];
if (msg) {
var b = document.getElementById("status-banner");
b.textContent = msg;
b.hidden = false;
}
})
.catch(function () {});
// Radicle mirrors: the forge service's state. // Radicle mirrors: the forge service's state.
var reposBody = document.getElementById("repos-body"); var reposBody = document.getElementById("repos-body");
fetch("repos.json", { cache: "no-store" }) fetch("repos.json", { cache: "no-store" })