Files
66eaf7bd2c Adds Radicle destination (#413)
* Adds Radicle destination

This destination allows mirroring repositories into the storage of a local
[Radicle](https://radicle.network) node.

Requires `git` + Radicle Command Line Interface (CLI) tools installed,
i.e. `rad` and `git-remote-rad` binaries. See https://radicle.network/install .

The repository is initialized with `rad init` on the first run and pushed
through the `git-remote-rad` helper afterwards, so the refs are signed with
the key of the Radicle profile.

Because every backup run works on a fresh temporary clone, each run has to
find the Repository ID (RID) created for an upstream on an earlier run:
every mirror records its upstream URL in a "dev.gickup" extension of the
signed Radicle identity document `payload`, and the storage is scanned for a
repository whose recorded source matches and whose refs are signed by this
profile's key — comparable to the existence lookups the forge destinations
make against their APIs, just against local storage. (Local-first, For The Win!)

Unlike other forge destinations we cannot use go-git for pushing,
because Radicle repositories are only reachable through the `git-remote-rad`
remote helper, which go-git does not support. All storage-touching
operations therefore shell out to the system `git` and `rad` binaries, so
identity creation and signed-refs handling stay inside the Radicle tooling.

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

* fixup! Adds Radicle destination

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

* fixup! Adds Radicle destination

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>

---------

Signed-off-by: Yorgos Saslis <yorgos.work@proton.me>
Co-authored-by: Andreas Wachter <andreas.wachter@buddyspencer.monster>
2026-07-29 15:41:13 +02:00

430 lines
12 KiB
Go

// Package radicle mirrors repositories into the local storage of a Radicle
// node.
package radicle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/cooperspencer/gickup/logger"
"github.com/cooperspencer/gickup/types"
"github.com/rs/zerolog"
)
var sub zerolog.Logger
// payloadCache memoizes the storage scan so scanStorage inspects the storage
// only once per RAD_HOME, instead of once per mirrored repository. It is
// populated on the first scan and kept in sync as initRepo creates mirrors,
// so this process never re-inspects storage it already knows.
var payloadCache = map[string]map[string]payload{}
// payload is the identity document `payload` section, as returned by `rad inspect --payload`.
type payload struct {
Project struct {
Name string `json:"name"`
Description string `json:"description"`
DefaultBranch string `json:"defaultBranch"`
} `json:"xyz.radicle.project"`
// Gickup is the provenance extension this package adds to the identity
// document of every mirror it initializes.
Gickup struct {
Source string `json:"source"`
} `json:"dev.gickup"`
}
// Mirror pushes the repository cloned at tempdir into the Radicle storage
// configured by d, initializing it with `rad init` on first sight. It returns
// the RID of the mirror.
func Mirror(repo types.Repo, dest types.Radicle, tempdir string) (string, error) {
home, _ := Home()
sub = logger.CreateSubLogger("stage", "radicle", "home", home)
if err := probe(); err != nil {
return "", err
}
// rad and git-remote-rad read RAD_HOME and RAD_PASSPHRASE from the
// environment themselves, so the ambient environment is all they need.
env := os.Environ()
nid, err := nodeID(env)
if err != nil {
return "", err
}
rid, err := scanStorage(repo, nid, env)
if err != nil {
return "", err
}
if rid == "" {
rid, err = initRepo(repo, dest, tempdir, env)
if err != nil {
return "", err
}
sub.Info().
Str("rid", fmt.Sprintf("rad:%s", rid)).
Msgf("initialized %s", types.Green(repo.Name))
} else if dest.Force || dest.Prune {
if err := fetchMirrorState(tempdir, rid, nid, env); err != nil {
return "", err
}
}
if err := push(dest, tempdir, rid, nid, env); err != nil {
return "", err
}
// Issues coming soon
if dest.Issues {
sub.Warn().Msg("Issue migration to Radicle not yet supported.")
}
return rid, nil
}
// probe checks that all binaries required for mirroring to Radicle are available.
func probe() error {
for _, bin := range []string{"git", "rad", "git-remote-rad"} {
if _, err := exec.LookPath(bin); err != nil {
return fmt.Errorf("%s is not installed", bin)
}
}
return nil
}
// fetchMirrorState fetches the current refs of the mirror into the temporary
// clone. When upstream history was rewritten or a branch was deleted, the old
// ref targets no longer exist in a fresh clone, and the remote helper then
// fails to re-sign rad/sigrefs after the ref update, leaving the signed refs
// pointing at the old state. Fetching first keeps the old objects resolvable
// so refs and signature update together. Pushes without force and prune are
// fast-forward only, where the old targets are always present, so the fetch
// is skipped.
func fetchMirrorState(tempdir, rid, nid string, env []string) error {
_, err := run(tempdir, env, "git", "fetch", "--no-tags",
fmt.Sprintf("rad://%s/%s", rid, nid),
"+refs/heads/*:refs/gickup/heads/*",
"+refs/tags/*:refs/gickup/tags/*")
return err
}
// Home returns the profile's Radicle home as reported by `rad self --home`.
// Propagates error accordingly, so it can be handled at call sites.
func Home() (string, error) {
return run("", os.Environ(), "rad", "self", "--home")
}
// run executes name with args in dir, returning trimmed stdout. Stderr is
// folded into the returned error.
func run(dir string, env []string, name string, args ...string) (string, error) {
cmd := exec.CommandContext(context.Background(), name, args...)
cmd.Dir = dir
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// nodeID returns the node ID of the configured profile, which namespaces the
// push URL. `rad self --nid` is deprecated but still works without a running
// node, so it is used as the fallback.
func nodeID(env []string) (string, error) {
nid, err := run("", env, "rad", "node", "status", "--only", "nid")
if err != nil {
nid, err = run("", env, "rad", "self", "--nid")
if err != nil {
return "", err
}
}
if nid == "" {
return "", fmt.Errorf("couldn't determine the node id of the radicle profile")
}
return nid, nil
}
// marker returns the human-readable provenance note that initRepo appends to
// the mirror's description. Any credentials embedded in the URL are stripped, because
// the description becomes part of the signed identity payload that is
// replicated to the network.
func marker(upstream string) string {
return fmt.Sprintf("(mirror of %s)", stripCredentials(upstream))
}
// stripCredentials removes the userinfo part from a URL, so tokens or
// passwords configured inline (https://user:token@host/...) never leave the
// local configuration. Non-URL remotes (e.g. scp-style ssh) pass through. This
// is useful because the identity document, where this URL is stored, is signed
// and published to the network.
func stripCredentials(upstream string) string {
if !strings.Contains(upstream, "://") {
return upstream
}
parsed, err := url.Parse(upstream)
if err != nil || parsed.User == nil {
return upstream
}
parsed.User = nil
return parsed.String()
}
// scanStorage searches the Radicle storage for the repository that mirrors
// the upstream URL. A candidate must record the URL as its source in the
// dev.gickup `payload` of its identity document, and list this profile among
// that identity's delegates. The second check is what makes adoption
// unforgeable: the source claim lives in the identity document, whose contents
// are only authoritative because its delegates signed them.
func scanStorage(repo types.Repo, nid string, env []string) (string, error) {
inspected, err := payloadsFor(env)
if err != nil {
return "", err
}
matches := []string{}
source := stripCredentials(repo.URL)
for rid, p := range inspected {
if p.Gickup.Source == source && isDelegate(rid, nid, env) {
matches = append(matches, rid)
}
}
switch len(matches) {
case 0:
return "", nil
case 1:
return matches[0], nil
default:
return "", fmt.Errorf(
"found %d repositories in storage mirroring %s - remove the duplicates so only one remains",
len(matches), repo.URL)
}
}
// isDelegate reports whether this profile's key (nid) is a delegate of the
// stored repository's identity. We consider this enough evidence that the
// information we read from the identity doc can be trusted.
func isDelegate(rid, nid string, env []string) bool {
out, err := run("", env, "rad", "inspect", fmt.Sprintf("rad:%s", rid), "--delegates")
if err != nil {
return false
}
// Each line looks like "did:key:<nid> (alias)".
did := "did:key:" + nid
for _, line := range strings.Split(out, "\n") {
if fields := strings.Fields(line); len(fields) > 0 && fields[0] == did {
return true
}
}
return false
}
// payloadsFor returns the identity payloads of all repositories in the Radicle
// profile storage. The storage is inspected the first time a given RAD_HOME is asked
// about and the result is cached, so the repeated scanStorage calls across a
// run's repositories don't each re-inspect it.
func payloadsFor(env []string) (map[string]payload, error) {
home, err := Home()
if err != nil {
return nil, err
}
if cached, ok := payloadCache[home]; ok {
return cached, nil
}
entries, err := os.ReadDir(filepath.Join(home, "storage"))
if err != nil {
if os.IsNotExist(err) {
payloadCache[home] = map[string]payload{}
return payloadCache[home], nil
}
return nil, err
}
current := map[string]payload{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
out, err := run("", env, "rad", "inspect", fmt.Sprintf("rad:%s", entry.Name()), "--payload")
if err != nil {
sub.Debug().
Str("rid", fmt.Sprintf("rad:%s", entry.Name())).
Msg(err.Error())
continue
}
var p payload
if err := json.Unmarshal([]byte(out), &p); err != nil {
continue
}
current[entry.Name()] = p
}
payloadCache[home] = current
return current, nil
}
// initRepo runs `rad init` on the temporary clone, creating the repository
// identity in storage, and returns the new RID. The upstream URL is recorded
// in a dev.gickup extension of the identity document `payload`, which is what
// scanStorage matches on, on the next run.
func initRepo(repo types.Repo, dest types.Radicle, tempdir string, env []string) (string, error) {
branch, err := run(tempdir, env, "git", "symbolic-ref", "--short", "HEAD")
if err != nil {
return "", err
}
description := strings.TrimSpace(fmt.Sprintf("%s %s", repo.Description, marker(repo.URL)))
visibility, err := visibilityFlag(dest, repo)
if err != nil {
return "", err
}
_, err = run(tempdir, env, "rad", "init",
fmt.Sprintf("--name=%s", repo.Name),
fmt.Sprintf("--description=%s", description),
fmt.Sprintf("--default-branch=%s", branch),
visibility,
"--no-confirm")
if err != nil {
return "", err
}
rid, err := run(tempdir, env, "rad", ".")
if err != nil {
return "", err
}
rid = strings.TrimPrefix(rid, "rad:")
source, err := json.Marshal(stripCredentials(repo.URL))
if err != nil {
return rid, err
}
if err := recordMirrorSource(tempdir, env, source, rid); err != nil {
return rid, err
}
updatePayloadCache(repo, rid)
return rid, nil
}
// updatePayloadCache Remember the new mirror so a later run in this process finds it without
// re-inspecting storage — and never tries to initialize it a second time.
func updatePayloadCache(repo types.Repo, rid string) {
home, err := Home()
if err != nil {
return
}
if payloadCache[home] == nil {
payloadCache[home] = map[string]payload{}
}
var newPayload payload
newPayload.Gickup.Source = stripCredentials(repo.URL)
payloadCache[home][rid] = newPayload
}
// recordMirrorSource Captures the upstream source, so an existing mirror can be reused,
// avoiding duplicates. A failure here fails the overall
// backup, and the error names the repository because the storage offers
// no rollback for a created identity: the operator has to record the
// source or remove the repository by hand.
func recordMirrorSource(tempdir string, env []string, source []byte, rid string) error {
if _, err := run(tempdir, env, "rad", "id", "update",
"--payload", "dev.gickup", "source", string(source),
"--title", "Record mirror source",
"--no-confirm"); err != nil {
return fmt.Errorf(
"repository rad:%s was created but recording its mirror source failed - record dev.gickup.source manually or remove the repository: %w",
rid, err)
}
return nil
}
// visibilityFlag maps the configured visibility to the rad init flag.
func visibilityFlag(dest types.Radicle, repo types.Repo) (string, error) {
switch dest.Visibility {
case "public":
return "--public", nil
case "private":
return "--private", nil
case "", "source":
if repo.Private {
return "--private", nil
}
return "--public", nil
default:
return "", fmt.Errorf("invalid radicle visibility %s, must be public, private or source", dest.Visibility)
}
}
// pushArgs builds the git arguments that mirror all branches and tags into
// the profile's namespace of the stored repository.
func pushArgs(dest types.Radicle, rid, nid string) []string {
prefix := ""
if dest.Force {
prefix = "+"
}
args := []string{"push"}
if dest.Prune {
args = append(args, "--prune")
}
return append(args,
fmt.Sprintf("rad://%s/%s", rid, nid),
fmt.Sprintf("%srefs/heads/*:refs/heads/*", prefix),
fmt.Sprintf("%srefs/tags/*:refs/tags/*", prefix))
}
// push mirrors all branches and tags of the temporary clone into the profile's
// namespace of the stored repository. The remote helper updates and signs
// rad/sigrefs and announces to the node, so nothing else has to touch storage.
func push(dest types.Radicle, tempdir, rid, nid string, env []string) error {
_, err := run(tempdir, env, "git", pushArgs(dest, rid, nid)...)
return err
}