Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Radicle peer onboarding; fix dynamic peers vanishing on restart

New /add-radicle-peer: connects a new Radicle node to the mesh and
seeds an existing federation's RID (rad node connect + rad seed on
graft's own node), gated by the same admin password + rate limiter as
the Forgejo onboarding forms. No DB row needed — it's a one-time
operational action, not an ongoing sync pair. Cross-linked with
/add-peer; dashboard gets a third button.

Fixes a real bug caught live: federation-x-f3 (added via /add-peer
earlier) silently dropped out of the sync loop on every graft restart
since then — main() only rebuilt live RepoSyncers for dynamic_repo rows
still unmaterialized, never for ones a previous run had already
materialized, so a dynamic peer's syncer simply ceased to exist after
any deploy. Confirmed via f3's README staying stale for ~50 minutes
across several restarts while f2/bimr updated normally. Fixed with
loadAllDynamicRepos, called once at startup regardless of the
materialized flag — deliberately non-fatal on a load failure (unlike a
bad static config.yaml pair), since the self-service path exists
specifically to tolerate transient failures, not turn one peer's
hiccup into a full outage. Verified live: f3 reloaded on restart,
caught up on a real README change within one sync pass.

Also: dropped three redundant/stale sentences from the onboarding page
copy (a leftover "not entered here" clause, a since-superseded
auto-init warning now covered by the Adding-a-peer docs instead, and a
"no restart" hint repeated between the form and its own success page),
and "Pair name" -> "Peer name" for consistency with the rest of the UI.

+185 -15
+51 -11
cmd/sync/main.go
··· 81 81 } 82 82 live.addSyncer(rs) 83 83 } 84 + // dynamic_repo rows from a previous run persist in the database, but 85 + // live.syncers starts empty every process start regardless of each 86 + // row's "materialized" flag — that flag only means "graft has synced 87 + // this at least once ever", not "this process already has a live 88 + // RepoSyncer for it". Load all of them now, not just ones still 89 + // pending their first sync. 90 + loadAllDynamicRepos(st, live, stateDir, log) 84 91 detectAuthorizedIntegrations(live.pairsBySeriesSnapshot(), log) 85 92 live.rebuildTopology(cfg) 86 93 ··· 310 317 return out 311 318 } 312 319 320 + func dynamicRepoToPair(d state.DynamicRepo) config.RepoPair { 321 + return config.RepoPair{ 322 + Name: d.Name, 323 + Series: d.Series, 324 + Forgejo: config.ForgejoTarget{ 325 + BaseURL: d.ForgejoBaseURL, Owner: d.ForgejoOwner, Repo: d.ForgejoRepo, TokenFile: d.ForgejoTokenFile, 326 + }, 327 + Radicle: config.RadicleTarget{ 328 + RID: d.RadicleRID, HTTPBaseURL: d.RadicleHTTPBaseURL, RadHome: d.RadicleRadHome, ExplorerURL: d.RadicleExplorerURL, 329 + }, 330 + Sync: config.SyncScope{Git: d.SyncGit, Issues: d.SyncIssues, Patches: d.SyncPatches}, 331 + } 332 + } 333 + 334 + // loadAllDynamicRepos rebuilds live RepoSyncers for every dynamic_repo row 335 + // ever created, materialized or not — called once at startup, since 336 + // live.syncers is always empty at process start no matter what the 337 + // database's "materialized" flag says. Deliberately non-fatal (unlike a 338 + // bad static config.yaml pair, which does exit): the whole point of the 339 + // self-service onboarding path is resilience to transient failures (the 340 + // peer's Forgejo temporarily unreachable, say) — refusing to start graft 341 + // entirely over one dynamic peer would be a worse outcome than the bug 342 + // this function fixes. A row that fails here just stays out of the sync 343 + // loop until the next successful restart. 344 + func loadAllDynamicRepos(st *state.Store, live *liveState, stateDir string, log *slog.Logger) { 345 + rows, err := st.AllDynamicRepos() 346 + if err != nil { 347 + log.Error("list dynamic repos", "err", err) 348 + return 349 + } 350 + for _, d := range rows { 351 + pair := dynamicRepoToPair(d) 352 + rs, err := gsync.New(pair, st, gsync.WorkDirFor(stateDir, pair.Name)) 353 + if err != nil { 354 + log.Error("load dynamic repo", "name", d.Name, "err", err) 355 + continue 356 + } 357 + live.addSyncer(rs) 358 + live.addPair(pair) 359 + log.Info("loaded dynamic repo", "name", d.Name, "series", d.Series) 360 + } 361 + } 362 + 313 363 // materializeDynamicRepos turns every dynamic_repo row nobody has 314 364 // activated yet into a live RepoSyncer, joining the sync loop on this 315 365 // very pass — no restart, matching the "shows up next pass" model the ··· 323 373 } 324 374 changed := false 325 375 for _, d := range rows { 326 - pair := config.RepoPair{ 327 - Name: d.Name, 328 - Series: d.Series, 329 - Forgejo: config.ForgejoTarget{ 330 - BaseURL: d.ForgejoBaseURL, Owner: d.ForgejoOwner, Repo: d.ForgejoRepo, TokenFile: d.ForgejoTokenFile, 331 - }, 332 - Radicle: config.RadicleTarget{ 333 - RID: d.RadicleRID, HTTPBaseURL: d.RadicleHTTPBaseURL, RadHome: d.RadicleRadHome, ExplorerURL: d.RadicleExplorerURL, 334 - }, 335 - Sync: config.SyncScope{Git: d.SyncGit, Issues: d.SyncIssues, Patches: d.SyncPatches}, 336 - } 376 + pair := dynamicRepoToPair(d) 337 377 rs, err := gsync.New(pair, st, gsync.WorkDirFor(stateDir, pair.Name)) 338 378 if err != nil { 339 379 // Left unmaterialized — retried next pass rather than
+1
internal/admin/admin.go
··· 84 84 mux.HandleFunc("/share/new", h.shareNew) 85 85 mux.HandleFunc("/share/", h.shareClaim) // also serves /share/<id>/qr.png 86 86 mux.HandleFunc("/add-peer", h.addPeer) 87 + mux.HandleFunc("/add-radicle-peer", h.addRadiclePeer) 87 88 mux.HandleFunc("/new-repo", h.newRepo) 88 89 } 89 90
+2 -3
internal/admin/onboard.go
··· 45 45 </div> 46 46 <label for="repo">Repository</label> 47 47 <input type="text" id="repo" name="repo" required value="{{.Repo}}"> 48 - <div class="hint">Create this repo empty on the peer — no auto-init. An auto-init commit diverges from the federation's history and blocks the sync.</div> 49 48 50 49 <label for="token">Forgejo token</label> 51 50 <textarea id="token" name="token" required placeholder="scoped to write:repository + write:issue"></textarea> ··· 60 59 61 60 <label for="password">Admin password</label> 62 61 <input type="password" id="password" name="password" required autocomplete="off"> 63 - <div class="hint">Active within one sync interval. No restart.</div> 64 62 65 63 <button type="submit">Add peer</button> 66 64 </form> 67 65 </div> 66 + <p><a class="nav-link" href="/add-radicle-peer">Add a Radicle peer instead &rarr;</a></p> 68 67 `)) 69 68 70 69 var newRepoTmpl = template.Must(template.New("new-repo").Parse(` ··· 128 127 129 128 var onboardOKTmpl = template.Must(template.New("onboard-ok").Parse(` 130 129 <h1>{{.Title}}</h1> 131 - <div class="notice good">{{.Name}} saved. Active within one sync interval. No restart.</div> 130 + <div class="notice good">{{.Name}} saved.</div> 132 131 <p><a class="nav-link" href="/">&larr; Back to the dashboard</a></p> 133 132 `)) 134 133
+129
internal/admin/radicle_peer.go
··· 1 + package admin 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "html/template" 8 + "net/http" 9 + "os" 10 + "os/exec" 11 + "regexp" 12 + "time" 13 + ) 14 + 15 + // radExecTimeout bounds "rad node connect" / "rad seed" — a network 16 + // handshake with a node that may never answer shouldn't hang the request. 17 + const radExecTimeout = 60 * time.Second 18 + 19 + var validNodeID = regexp.MustCompile(`^[a-zA-Z0-9]{1,80}$`) 20 + var validNodeAddress = regexp.MustCompile(`^[a-zA-Z0-9.\-]+:[0-9]{1,5}$`) 21 + 22 + // runRad runs the rad CLI against graft's own local Radicle identity — 23 + // the same one every pair (static or dynamic) already pushes as. Never 24 + // scoped to a single repo (unlike internal/radicle.Client), since 25 + // "connect" operates on the node itself, not a repository. 26 + func runRad(radHome string, args ...string) (string, error) { 27 + ctx, cancel := context.WithTimeout(context.Background(), radExecTimeout) 28 + defer cancel() 29 + cmd := exec.CommandContext(ctx, "rad", args...) 30 + cmd.Env = append(os.Environ(), "RAD_HOME="+radHome) 31 + var out bytes.Buffer 32 + cmd.Stdout = &out 33 + cmd.Stderr = &out 34 + if err := cmd.Run(); err != nil { 35 + return out.String(), fmt.Errorf("rad %v: %w: %s", args, err, out.String()) 36 + } 37 + return out.String(), nil 38 + } 39 + 40 + var addRadiclePeerTmpl = template.Must(template.New("add-radicle-peer").Parse(` 41 + <h1>Add a Radicle peer</h1> 42 + <p class="lead">Connects a new Radicle node to the mesh and has it seed an existing federation.</p> 43 + {{if .Error}}<div class="notice bad">{{.Error}}</div>{{end}} 44 + <div class="card"> 45 + <form method="POST" action="/add-radicle-peer"> 46 + <label for="series">Federation (series)</label> 47 + <select id="series" name="series" required> 48 + <option value="">— choose —</option> 49 + {{range .Series}}<option value="{{.}}" {{if eq . $.SelectedSeries}}selected{{end}}>{{.}}</option>{{end}} 50 + </select> 51 + 52 + <label for="node_id">Node ID</label> 53 + <input type="text" id="node_id" name="node_id" required value="{{.NodeID}}" placeholder="z6Mk..."> 54 + 55 + <label for="address">Address</label> 56 + <input type="text" id="address" name="address" required value="{{.Address}}" placeholder="host:8776"> 57 + 58 + <label for="password">Admin password</label> 59 + <input type="password" id="password" name="password" required autocomplete="off"> 60 + 61 + <button type="submit">Connect &amp; seed</button> 62 + </form> 63 + </div> 64 + <p><a class="nav-link" href="/add-peer">Add a Forgejo peer instead &rarr;</a></p> 65 + `)) 66 + 67 + func (h *Handler) addRadiclePeer(w http.ResponseWriter, r *http.Request) { 68 + switch r.Method { 69 + case http.MethodGet: 70 + h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series())}) 71 + case http.MethodPost: 72 + h.submitAddRadiclePeer(w, r) 73 + default: 74 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 75 + } 76 + } 77 + 78 + func (h *Handler) renderAddRadiclePeer(w http.ResponseWriter, data map[string]any) { 79 + render(w, "add a radicle peer", "SELF-SERVICE ONBOARDING", renderFragment(addRadiclePeerTmpl, data)) 80 + } 81 + 82 + func (h *Handler) submitAddRadiclePeer(w http.ResponseWriter, r *http.Request) { 83 + ip := clientIP(r) 84 + if !h.passwordLimiter.allowed(ip) { 85 + h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "too many attempts from your address — try again later"}) 86 + return 87 + } 88 + if err := r.ParseForm(); err != nil { 89 + h.renderAddRadiclePeer(w, map[string]any{"Series": seriesNames(h.cfg.Series()), "Error": "couldn't read the form"}) 90 + return 91 + } 92 + form := formData(r, "series", "node_id", "address", "password") 93 + retry := func(msg string) { 94 + h.renderAddRadiclePeer(w, map[string]any{ 95 + "Series": seriesNames(h.cfg.Series()), "Error": msg, "SelectedSeries": form["series"], 96 + "NodeID": form["node_id"], "Address": form["address"], 97 + }) 98 + } 99 + 100 + if !checkPassword(h.cfg, form["password"]) { 101 + h.passwordLimiter.recordFailure(ip) 102 + retry("wrong admin password") 103 + return 104 + } 105 + series := findSeries(h.cfg.Series(), form["series"]) 106 + if series == nil { 107 + retry("pick a federation to join") 108 + return 109 + } 110 + if !validNodeID.MatchString(form["node_id"]) { 111 + retry("that doesn't look like a valid node id") 112 + return 113 + } 114 + if !validNodeAddress.MatchString(form["address"]) { 115 + retry("address must be host:port") 116 + return 117 + } 118 + 119 + if _, err := runRad(h.cfg.DefaultRadHome, "node", "connect", form["node_id"]+"@"+form["address"]); err != nil { 120 + retry("could not connect to that node: " + err.Error()) 121 + return 122 + } 123 + if _, err := runRad(h.cfg.DefaultRadHome, "seed", series.RadicleRID); err != nil { 124 + retry("connected, but could not seed the repository: " + err.Error()) 125 + return 126 + } 127 + 128 + render(w, "radicle peer added", "SELF-SERVICE ONBOARDING", renderFragment(onboardOKTmpl, map[string]string{"Title": "Radicle peer added", "Name": form["node_id"]})) 129 + }
+2 -1
internal/status/status.go
··· 871 871 <span><i class="sw" data-kind="comment"></i>Comment</span> 872 872 </div> 873 873 <div class="fed-actions"> 874 - <a class="btn-outline" href="/add-peer">+ Add peer</a> 874 + <a class="btn-outline" href="/add-peer">+ Add Forgejo peer</a> 875 + <a class="btn-outline" href="/add-radicle-peer">+ Add Radicle peer</a> 875 876 <a class="btn-outline" href="/new-repo">+ New repo</a> 876 877 </div> 877 878 </div>