mirror of
https://github.com/cooperspencer/gickup.git
synced 2026-10-19 16:16:46 +00:00
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>
This commit is contained in:
co-authored by
Andreas Wachter
parent
dfee42bf30
commit
66eaf7bd2c
@@ -67,6 +67,9 @@ linters:
|
||||
- linters: [gosec]
|
||||
path: github/github_test.go
|
||||
text: "G101:"
|
||||
- linters: [gosec]
|
||||
path: radicle/radicle_test.go
|
||||
text: "G101:"
|
||||
# Global exclusions for staticcheck style suggestions
|
||||
- linters: [staticcheck]
|
||||
text: "SA1019:"
|
||||
|
||||
@@ -52,6 +52,7 @@ You can clone/mirror repositories to:
|
||||
- Gogs
|
||||
- OneDev
|
||||
- Sourcehut
|
||||
- Radicle
|
||||
- Local
|
||||
- S3
|
||||
|
||||
|
||||
@@ -294,6 +294,14 @@ destination:
|
||||
bare: true # clone the repositories as bare
|
||||
mirror: true # create mirror clones
|
||||
lfs: true # clone lfs repos, !! ATTENTION !! git and git-lfs must be installed on the system!
|
||||
radicle:
|
||||
# rad reads the environment itself: RAD_HOME selects which radicle profile to
|
||||
# mirror into (defaults to ~/.radicle), and RAD_PASSPHRASE unlocks its key if
|
||||
# it has a passphrase. Export them before running gickup.
|
||||
- force: true # if set to true, refs that diverged from upstream are overwritten (non-fast-forward updates)
|
||||
prune: true # if set to true, refs that no longer exist upstream are deleted from the mirror
|
||||
visibility: source # public, private or source, source follows the visibility of the source repository. default: source
|
||||
issues: false # [COMING SOON] recreate the source repo's issues as radicle issues (the source must also have issues: true).
|
||||
s3:
|
||||
- endpoint: somewhere:9000 # whatever your s3 endpoint is
|
||||
structured: true # checks repos out like hostersite/user|organization/repo
|
||||
|
||||
@@ -859,6 +859,37 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"radicle": {
|
||||
"type": "array",
|
||||
"description": "Mirror repositories into the storage of a local Radicle node. Requires the `git`, `rad` and `git-remote-rad` binaries.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "overwrite refs on the mirror that have diverged from upstream (non-fast-forward updates)"
|
||||
},
|
||||
"prune": {
|
||||
"type": "boolean",
|
||||
"description": "delete refs on the mirror that no longer exist upstream"
|
||||
},
|
||||
"visibility": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"public",
|
||||
"private",
|
||||
"source"
|
||||
],
|
||||
"description": "visibility of the mirrored repository, `source` follows the source repository. default: source"
|
||||
},
|
||||
"issues": {
|
||||
"type": "boolean",
|
||||
"description": "[COMING SOON] recreate the source repository's issues."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/cooperspencer/gickup/metrics/ntfy"
|
||||
"github.com/cooperspencer/gickup/metrics/prometheus"
|
||||
"github.com/cooperspencer/gickup/onedev"
|
||||
"github.com/cooperspencer/gickup/radicle"
|
||||
"github.com/cooperspencer/gickup/s3"
|
||||
"github.com/cooperspencer/gickup/sourcehut"
|
||||
"github.com/cooperspencer/gickup/types"
|
||||
@@ -912,6 +913,69 @@ func backup(repos []types.Repo, conf *types.Conf) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, d := range conf.Destination.Radicle {
|
||||
repotime := time.Now()
|
||||
status := 0
|
||||
|
||||
radhome, _ := radicle.Home()
|
||||
|
||||
log.Info().
|
||||
Str("stage", "radicle").
|
||||
Str("home", radhome).
|
||||
Msgf("mirroring %s to %s", types.Blue(r.Name), radhome)
|
||||
|
||||
if !cli.Dry {
|
||||
func() {
|
||||
tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("radicle-%x", repotime))
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("stage", "tempclone").
|
||||
Str("url", r.URL).
|
||||
Msg(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
defer os.RemoveAll(tempdir)
|
||||
_, err = local.TempClone(r, tempdir)
|
||||
if err != nil {
|
||||
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
log.Info().
|
||||
Str("stage", "radicle").
|
||||
Str("url", r.URL).
|
||||
Msg(err.Error())
|
||||
} else {
|
||||
log.Error().
|
||||
Str("stage", "tempclone").
|
||||
Str("url", r.URL).
|
||||
Str("git", "clone").
|
||||
Msg(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rid, err := radicle.Mirror(r, d, tempdir)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("stage", "radicle").
|
||||
Str("url", r.URL).
|
||||
Msg(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("stage", "radicle").
|
||||
Str("rid", fmt.Sprintf("rad:%s", rid)).
|
||||
Msgf("mirrored %s", types.Green(r.Name))
|
||||
|
||||
prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(time.Since(repotime).Seconds())
|
||||
status = 1
|
||||
}()
|
||||
|
||||
prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(float64(status))
|
||||
prometheus.DestinationBackupsComplete.WithLabelValues("radicle").Inc()
|
||||
}
|
||||
}
|
||||
|
||||
prometheus.SourceBackupsComplete.WithLabelValues(r.Name).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package radicle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cooperspencer/gickup/types"
|
||||
)
|
||||
|
||||
func TestHome(t *testing.T) {
|
||||
// Home delegates to `rad self --home`; stub rad and check we return what
|
||||
// it reports, verbatim.
|
||||
bin := t.TempDir()
|
||||
script := "#!/bin/sh\n[ \"$1 $2\" = \"self --home\" ] && echo /stub/radhome\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "rad"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", fmt.Sprintf("%s%c%s", bin, os.PathListSeparator, os.Getenv("PATH")))
|
||||
|
||||
home, err := Home()
|
||||
if err != nil {
|
||||
t.Fatalf("Home() error: %v", err)
|
||||
}
|
||||
if home != "/stub/radhome" {
|
||||
t.Errorf("expected /stub/radhome from rad self --home, got %q", home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibilityFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
private := types.Repo{Private: true}
|
||||
public := types.Repo{Private: false}
|
||||
|
||||
for _, tc := range []struct {
|
||||
visibility string
|
||||
repo types.Repo
|
||||
expected string
|
||||
}{
|
||||
{"public", private, "--public"},
|
||||
{"private", public, "--private"},
|
||||
{"", private, "--private"},
|
||||
{"", public, "--public"},
|
||||
{"source", private, "--private"},
|
||||
{"source", public, "--public"},
|
||||
} {
|
||||
flag, err := visibilityFlag(types.Radicle{Visibility: tc.visibility}, tc.repo)
|
||||
if err != nil {
|
||||
t.Errorf("visibility %q: %v", tc.visibility, err)
|
||||
}
|
||||
|
||||
if flag != tc.expected {
|
||||
t.Errorf("visibility %q private=%v: expected %s, got %s", tc.visibility, tc.repo.Private, tc.expected, flag)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := visibilityFlag(types.Radicle{Visibility: "invisible"}, public); err == nil {
|
||||
t.Error("expected an error for an invalid visibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := pushArgs(types.Radicle{}, "zRid", "zNid")
|
||||
expected := "push rad://zRid/zNid refs/heads/*:refs/heads/* refs/tags/*:refs/tags/*"
|
||||
|
||||
if strings.Join(args, " ") != expected {
|
||||
t.Errorf("expected %q, got %q", expected, strings.Join(args, " "))
|
||||
}
|
||||
|
||||
args = pushArgs(types.Radicle{Force: true, Prune: true}, "zRid", "zNid")
|
||||
expected = "push --prune rad://zRid/zNid +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/*"
|
||||
|
||||
if strings.Join(args, " ") != expected {
|
||||
t.Errorf("expected %q, got %q", expected, strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
const testNID = "zTestNodeId"
|
||||
|
||||
type fakePayload struct {
|
||||
name string
|
||||
// source is the upstream URL in the dev.gickup payload; empty simulates
|
||||
// a repository that was not created by gickup.
|
||||
source string
|
||||
// owned repos list this profile among the identity delegates; unowned ones
|
||||
// list a foreign delegate, simulating a repo merely seeded from the network.
|
||||
owned bool
|
||||
}
|
||||
|
||||
// fakeRad puts a rad stub on PATH answering `rad self --home`, `rad inspect
|
||||
// <rid> --payload` and `rad inspect <rid> --delegates` per configured rid, and
|
||||
// creates an (empty) storage directory per rid so payloadsFor can enumerate
|
||||
// them — letting scanStorage be tested without a radicle installation.
|
||||
func fakeRad(t *testing.T, radhome string, repos map[string]fakePayload) {
|
||||
t.Helper()
|
||||
|
||||
bin := t.TempDir()
|
||||
ourDID := "did:key:" + testNID
|
||||
|
||||
var payloadCases, delegateCases string
|
||||
for rid, p := range repos {
|
||||
gickup := ""
|
||||
if p.source != "" {
|
||||
gickup = fmt.Sprintf("\"dev.gickup\": {\"source\": \"%s\"}, ", p.source)
|
||||
}
|
||||
payloadCases += fmt.Sprintf("rad:%s) echo '{%s\"xyz.radicle.project\": {\"name\": \"%s\", \"defaultBranch\": \"main\"}}' ;;\n", rid, gickup, p.name)
|
||||
|
||||
delegate := "did:key:zForeignNode"
|
||||
if p.owned {
|
||||
delegate = ourDID
|
||||
}
|
||||
delegateCases += fmt.Sprintf("rad:%s) echo '%s (alias)' ;;\n", rid, delegate)
|
||||
}
|
||||
|
||||
script := "#!/bin/sh\n" +
|
||||
"if [ \"$1 $2\" = \"self --home\" ]; then echo \"$RAD_HOME\"; exit 0; fi\n" +
|
||||
"case \"$3\" in\n" +
|
||||
"--payload) case \"$2\" in\n" + payloadCases + "*) echo 'unknown repository' >&2; exit 1 ;;\nesac ;;\n" +
|
||||
"--delegates) case \"$2\" in\n" + delegateCases + "*) echo 'unknown repository' >&2; exit 1 ;;\nesac ;;\n" +
|
||||
"esac\n"
|
||||
|
||||
if err := os.WriteFile(filepath.Join(bin, "rad"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", fmt.Sprintf("%s%c%s", bin, os.PathListSeparator, os.Getenv("PATH")))
|
||||
|
||||
for rid := range repos {
|
||||
if err := os.MkdirAll(filepath.Join(radhome, "storage", rid), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanStorage(t *testing.T) {
|
||||
radhome := t.TempDir()
|
||||
fakeRad(t, radhome, map[string]fakePayload{
|
||||
// same repo name upstream, distinguished purely by recorded source
|
||||
"zRepoOne": {"website", "https://github.com/alice/website", true},
|
||||
"zRepoTwo": {"website", "https://gitea.local/bob/website", true},
|
||||
"zOther": {"other", "https://github.com/alice/other", true},
|
||||
// not created by gickup: no dev.gickup payload
|
||||
"zForeign": {"website", "", true},
|
||||
})
|
||||
|
||||
t.Setenv("RAD_HOME", radhome)
|
||||
|
||||
rid, err := scanStorage(types.Repo{Name: "website", URL: "https://gitea.local/bob/website"}, testNID, os.Environ())
|
||||
if err != nil {
|
||||
t.Fatalf("scan storage: %v", err)
|
||||
}
|
||||
|
||||
if rid != "zRepoTwo" {
|
||||
t.Errorf("expected zRepoTwo, got %s", rid)
|
||||
}
|
||||
|
||||
rid, err = scanStorage(types.Repo{Name: "website", URL: "https://github.com/carol/website"}, testNID, os.Environ())
|
||||
if err != nil {
|
||||
t.Fatalf("scan storage: %v", err)
|
||||
}
|
||||
|
||||
if rid != "" {
|
||||
t.Errorf("expected no match for an unmirrored upstream, got %s", rid)
|
||||
}
|
||||
|
||||
// credentials in the configured URL don't prevent matching the
|
||||
// credential-free recorded source
|
||||
rid, err = scanStorage(types.Repo{Name: "other", URL: "https://user:token@github.com/alice/other"}, testNID, os.Environ())
|
||||
if err != nil {
|
||||
t.Fatalf("scan storage: %v", err)
|
||||
}
|
||||
|
||||
if rid != "zOther" {
|
||||
t.Errorf("expected zOther, got %s", rid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanStorageAmbiguous(t *testing.T) {
|
||||
radhome := t.TempDir()
|
||||
url := "https://github.com/alice/same"
|
||||
fakeRad(t, radhome, map[string]fakePayload{
|
||||
"zRepoOne": {"same", url, true},
|
||||
"zRepoTwo": {"same", url, true},
|
||||
})
|
||||
|
||||
t.Setenv("RAD_HOME", radhome)
|
||||
|
||||
if _, err := scanStorage(types.Repo{Name: "same", URL: url}, testNID, os.Environ()); err == nil {
|
||||
t.Error("expected an error for an ambiguous source match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanStorageRejectsForgedPayloads(t *testing.T) {
|
||||
radhome := t.TempDir()
|
||||
victim := "https://github.com/victim/foo"
|
||||
fakeRad(t, radhome, map[string]fakePayload{
|
||||
// a repo seeded from the network whose crafted identity document
|
||||
// claims the victim's source, but whose delegates don't include this
|
||||
// profile — the delegate check must reject it
|
||||
"zSeeded": {"foo", victim, false},
|
||||
})
|
||||
|
||||
t.Setenv("RAD_HOME", radhome)
|
||||
|
||||
rid, err := scanStorage(types.Repo{Name: "foo", URL: victim}, testNID, os.Environ())
|
||||
if err != nil {
|
||||
t.Fatalf("scan storage: %v", err)
|
||||
}
|
||||
|
||||
if rid != "" {
|
||||
t.Errorf("forged payload was adopted: %s", rid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerStripsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := marker("https://user:secret-token@github.com/alice/repo.git")
|
||||
|
||||
if strings.Contains(m, "secret-token") || strings.Contains(m, "user") {
|
||||
t.Errorf("marker leaks credentials: %s", m)
|
||||
}
|
||||
|
||||
if m != "(mirror of https://github.com/alice/repo.git)" {
|
||||
t.Errorf("unexpected marker: %s", m)
|
||||
}
|
||||
|
||||
// non-URL remotes pass through untouched
|
||||
if marker("git@github.com:alice/repo.git") != "(mirror of git@github.com:alice/repo.git)" {
|
||||
t.Error("scp-style remote was altered")
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -31,6 +31,7 @@ type Destination struct {
|
||||
Sourcehut []GenRepo `yaml:"sourcehut"`
|
||||
S3 []S3Repo `yaml:"s3"`
|
||||
AzureBlob []AzureBlob `yaml:"azureblob"`
|
||||
Radicle []Radicle `yaml:"radicle"`
|
||||
}
|
||||
|
||||
// Count TODO.
|
||||
@@ -43,7 +44,20 @@ func (dest Destination) Count() int {
|
||||
len(dest.OneDev) +
|
||||
len(dest.Sourcehut) +
|
||||
len(dest.S3) +
|
||||
len(dest.AzureBlob)
|
||||
len(dest.AzureBlob) +
|
||||
len(dest.Radicle)
|
||||
}
|
||||
|
||||
// Radicle : The configuration for mirroring to Radicle.
|
||||
// Repositories are initialized with `rad init` and pushed into the local
|
||||
// Radicle storage through the git-remote-rad helper, signed with the key of
|
||||
// the profile in RAD_HOME. When that key has a passphrase, export it in the
|
||||
// RAD_PASSPHRASE environment variable, which rad reads itself.
|
||||
type Radicle struct {
|
||||
Force bool `yaml:"force"` // overwrite diverged refs on the mirror (non-fast-forward updates)
|
||||
Prune bool `yaml:"prune"` // delete refs on the mirror that no longer exist upstream
|
||||
Visibility string `yaml:"visibility"` // public, private or source, default: source
|
||||
Issues bool `yaml:"issues"` // [NOT YET SUPPORTED] recreate upstream issues as radicle issues (source must also fetch them with issues: true)
|
||||
}
|
||||
|
||||
// Local TODO.
|
||||
|
||||
Reference in New Issue
Block a user