vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add remote administration and local workflow runs
3abbe749096f4d7ff76f4d468e4427829b8a6f95 · Keenan Nemetz · 16d ago · parent 520470e1
Add remote administration and local workflow runs Make `vole admin` the single namespace for user, repository, and access management, runnable locally or over SSH by instance admins. Instance-admin grants live in users.db, and the audit log names the actor separately from the account being changed. Add `vole workflow run` to execute workflow jobs in the working tree without a server or runner. Positional arguments select workflows, --job selects jobs, and the command reuses the push-time validator. Update the README and guides for the new command shapes.
cmd/vole/admin.go +167
| @@ -0,0 +1,167 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "os" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "github.com/rodent-software/vole/client" | ||
| 11 | ) | ||
| 12 | |||
| 13 | const adminUsage = `usage: vole admin [--remote <name|ssh-url>] [--config <path>] [--json] <command>... | ||
| 14 | |||
| 15 | Server management: | ||
| 16 | init [--dir <path>] | ||
| 17 | user create|add-key|rm-key|rename|retire|grant-admin|revoke-admin|show|list|backup | ||
| 18 | repo create|rename|delete|list | ||
| 19 | acl show|set|default|protect|approvals|checks|runners | ||
| 20 | gc [--dry-run] [--grace <duration>] | ||
| 21 | index rebuild | ||
| 22 | |||
| 23 | With --remote, the command runs on the instance over SSH and must be run by an | ||
| 24 | instance admin. --json prints the server's JSON response instead of text. | ||
| 25 | --remote accepts a Git remote name or an instance URL such as | ||
| 26 | ssh://git@forge.example.com:2222` | ||
| 27 | |||
| 28 | // cmdAdmin is the single namespace for server management. Without --remote the | ||
| 29 | // command runs against the local vole.toml; with it, the same command is sent to | ||
| 30 | // the instance's SSH admin API. | ||
| 31 | func cmdAdmin(args []string) error { | ||
| 32 | remote, cfgPath, jsonOut, rest, err := splitAdminGlobals(args) | ||
| 33 | if err != nil { | ||
| 34 | return err | ||
| 35 | } | ||
| 36 | if len(rest) == 0 || rest[0] == "-h" || rest[0] == "--help" { | ||
| 37 | fmt.Fprintln(os.Stderr, adminUsage) | ||
| 38 | if len(rest) == 0 { | ||
| 39 | return usageErr{msg: "no admin command given"} | ||
| 40 | } | ||
| 41 | return nil | ||
| 42 | } | ||
| 43 | |||
| 44 | resource, subargs := rest[0], rest[1:] | ||
| 45 | switch resource { | ||
| 46 | case "user", "repo", "acl": | ||
| 47 | case "init", "gc", "index": | ||
| 48 | if remote != "" { | ||
| 49 | return usagef("%s is not available over --remote", resource) | ||
| 50 | } | ||
| 51 | default: | ||
| 52 | return usagef("unknown admin command %q", resource) | ||
| 53 | } | ||
| 54 | |||
| 55 | if remote == "" { | ||
| 56 | if jsonOut { | ||
| 57 | return usagef("--json is only supported with --remote") | ||
| 58 | } | ||
| 59 | local := append([]string{}, subargs...) | ||
| 60 | if cfgPath != "" { | ||
| 61 | local = append(local, "--config", cfgPath) | ||
| 62 | } | ||
| 63 | switch resource { | ||
| 64 | case "init": | ||
| 65 | return cmdInit(local) | ||
| 66 | case "user": | ||
| 67 | return cmdUser(local) | ||
| 68 | case "repo": | ||
| 69 | return cmdRepo(local) | ||
| 70 | case "acl": | ||
| 71 | return cmdACL(local) | ||
| 72 | case "gc": | ||
| 73 | return cmdGC(local) | ||
| 74 | case "index": | ||
| 75 | return cmdIndex(local) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | if cfgPath != "" { | ||
| 80 | return usagef("--config and --remote cannot be used together") | ||
| 81 | } | ||
| 82 | remoteArgs, stdin, err := prepareRemoteAdmin(resource, subargs) | ||
| 83 | if err != nil { | ||
| 84 | return err | ||
| 85 | } | ||
| 86 | remoteArgs = append([]string{resource}, remoteArgs...) | ||
| 87 | gitDir, _ := client.CurrentGitDir() | ||
| 88 | host, port, err := client.AdminTarget(gitDir, remote) | ||
| 89 | if err != nil { | ||
| 90 | return err | ||
| 91 | } | ||
| 92 | return client.RunAdmin(host, port, remoteArgs, jsonOut, stdin) | ||
| 93 | } | ||
| 94 | |||
| 95 | // splitAdminGlobals removes the flags that belong to the admin namespace, so | ||
| 96 | // the command's own flags can be handed to the old local parser or sent to the | ||
| 97 | // server unchanged. | ||
| 98 | func splitAdminGlobals(args []string) (remote, config string, jsonOut bool, rest []string, err error) { | ||
| 99 | for i := 0; i < len(args); i++ { | ||
| 100 | a := args[i] | ||
| 101 | if a == "--" { | ||
| 102 | rest = append(rest, args[i:]...) | ||
| 103 | return | ||
| 104 | } | ||
| 105 | switch { | ||
| 106 | case a == "--remote" || a == "-r": | ||
| 107 | i++ | ||
| 108 | if i >= len(args) { | ||
| 109 | return "", "", false, nil, usagef("%s needs a value", a) | ||
| 110 | } | ||
| 111 | remote = args[i] | ||
| 112 | case strings.HasPrefix(a, "--remote="): | ||
| 113 | remote = strings.TrimPrefix(a, "--remote=") | ||
| 114 | case a == "--config" || a == "-c": | ||
| 115 | i++ | ||
| 116 | if i >= len(args) { | ||
| 117 | return "", "", false, nil, usagef("%s needs a value", a) | ||
| 118 | } | ||
| 119 | config = args[i] | ||
| 120 | case strings.HasPrefix(a, "--config="): | ||
| 121 | config = strings.TrimPrefix(a, "--config=") | ||
| 122 | case strings.HasPrefix(a, "-c") && len(a) > 2: | ||
| 123 | config = a[2:] | ||
| 124 | case a == "--json": | ||
| 125 | jsonOut = true | ||
| 126 | default: | ||
| 127 | rest = append(rest, a) | ||
| 128 | } | ||
| 129 | } | ||
| 130 | return | ||
| 131 | } | ||
| 132 | |||
| 133 | // prepareRemoteAdmin handles the one command whose input is a file on the | ||
| 134 | // client: a public key is read here and sent on stdin, because the server has no | ||
| 135 | // access to the caller's filesystem. | ||
| 136 | func prepareRemoteAdmin(resource string, args []string) ([]string, io.Reader, error) { | ||
| 137 | if resource != "user" || len(args) == 0 || args[0] != "add-key" { | ||
| 138 | return args, nil, nil | ||
| 139 | } | ||
| 140 | out := []string{args[0]} | ||
| 141 | keyPath := "" | ||
| 142 | for i := 1; i < len(args); i++ { | ||
| 143 | a := args[i] | ||
| 144 | switch { | ||
| 145 | case a == "--key" || a == "-k": | ||
| 146 | i++ | ||
| 147 | if i >= len(args) { | ||
| 148 | return nil, nil, usagef("%s needs a value", a) | ||
| 149 | } | ||
| 150 | keyPath = args[i] | ||
| 151 | case strings.HasPrefix(a, "--key="): | ||
| 152 | keyPath = strings.TrimPrefix(a, "--key=") | ||
| 153 | case strings.HasPrefix(a, "-k") && len(a) > 2: | ||
| 154 | keyPath = a[2:] | ||
| 155 | default: | ||
| 156 | out = append(out, a) | ||
| 157 | } | ||
| 158 | } | ||
| 159 | if keyPath == "" || keyPath == "-" { | ||
| 160 | return out, os.Stdin, nil | ||
| 161 | } | ||
| 162 | data, err := os.ReadFile(keyPath) | ||
| 163 | if err != nil { | ||
| 164 | return nil, nil, err | ||
| 165 | } | ||
| 166 | return out, bytes.NewReader(data), nil | ||
| 167 | } | ||