Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }