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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0: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.

server/admin_test.go +193

@@ -0,0 +1,193 @@
1 package server_test
2
3 import (
4 "bytes"
5 "encoding/json"
6 "net"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 )
12
13 // adminSSH runs one `vole-admin` command over the SSH listener and returns
14 // stdout, stderr, and the exit error.
15 func (f *fixture) adminSSH(key string, stdin []byte, args ...string) (string, string, error) {
16 f.t.Helper()
17 host, port, err := net.SplitHostPort(f.sshAddr)
18 if err != nil {
19 f.t.Fatal(err)
20 }
21 argv := []string{
22 "-i", key,
23 "-o", "IdentitiesOnly=yes",
24 "-o", "StrictHostKeyChecking=no",
25 "-o", "UserKnownHostsFile=/dev/null",
26 "-o", "LogLevel=ERROR",
27 "-p", port,
28 "git@" + host,
29 "vole-admin",
30 }
31 argv = append(argv, args...)
32 cmd := exec.Command("ssh", argv...)
33 if stdin != nil {
34 cmd.Stdin = bytes.NewReader(stdin)
35 }
36 var out, errOut bytes.Buffer
37 cmd.Stdout, cmd.Stderr = &out, &errOut
38 err = cmd.Run()
39 return out.String(), errOut.String(), err
40 }
41
42 func TestRemoteAdminRequiresInstanceAdmin(t *testing.T) {
43 t.Parallel()
44 f := newFixture(t)
45 out, errOut, err := f.adminSSH(f.keyPath, nil, "user", "list")
46 if err == nil {
47 t.Fatalf("non-admin used the admin API: %s%s", out, errOut)
48 }
49 if !strings.Contains(strings.ToLower(out+errOut), "instance admin required") {
50 t.Fatalf("refusal did not explain the requirement:\nstdout: %s\nstderr: %s", out, errOut)
51 }
52 }
53
54 func TestRemoteAdminManagesUsersAndRepos(t *testing.T) {
55 t.Parallel()
56 f := newFixture(t)
57 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
58 t.Fatal(err)
59 }
60
61 out, errOut, err := f.adminSSH(f.keyPath, nil, "user", "create", "alice")
62 if err != nil {
63 t.Fatalf("user create: %v\nstdout: %s\nstderr: %s", err, out, errOut)
64 }
65 if !strings.Contains(out, "alice") {
66 t.Fatalf("user create did not name alice: %s", out)
67 }
68
69 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_alice"))
70 out, errOut, err = f.adminSSH(f.keyPath, []byte(pub), "user", "add-key", "alice")
71 if err != nil {
72 t.Fatalf("user add-key: %v\nstdout: %s\nstderr: %s", err, out, errOut)
73 }
74 if !strings.Contains(out, "added key") {
75 t.Fatalf("add-key output = %q", out)
76 }
77
78 out, errOut, err = f.adminSSH(f.keyPath, nil, "--json", "user", "list")
79 if err != nil {
80 t.Fatalf("user list --json: %v\nstdout: %s\nstderr: %s", err, out, errOut)
81 }
82 var listed struct {
83 OK bool `json:"ok"`
84 Data []struct {
85 Name string `json:"name"`
86 } `json:"data"`
87 }
88 if err := json.Unmarshal([]byte(out), &listed); err != nil {
89 t.Fatalf("user list --json is not JSON: %v\n%s", err, out)
90 }
91 found := false
92 for _, u := range listed.Data {
93 if u.Name == "alice" {
94 found = true
95 }
96 }
97 if !listed.OK || !found {
98 t.Fatalf("user list did not include alice: %s", out)
99 }
100
101 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "create", "remote-demo", "--owner", "alice")
102 if err != nil {
103 t.Fatalf("repo create: %v\nstdout: %s\nstderr: %s", err, out, errOut)
104 }
105 if _, err := f.srv.Repos.Open("remote-demo"); err != nil {
106 t.Fatalf("repository was not created: %v", err)
107 }
108
109 out, errOut, err = f.adminSSH(f.keyPath, nil, "acl", "default", "remote-demo", "read")
110 if err != nil {
111 t.Fatalf("acl default: %v\nstdout: %s\nstderr: %s", err, out, errOut)
112 }
113 m, err := f.srv.Repos.Open("remote-demo")
114 if err != nil {
115 t.Fatal(err)
116 }
117 a, err := m.ACL()
118 if err != nil {
119 t.Fatal(err)
120 }
121 if a.Default != "read" {
122 t.Fatalf("default role = %q, want read", a.Default)
123 }
124
125 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "rename", "remote-demo", "renamed-demo")
126 if err != nil {
127 t.Fatalf("repo rename: %v\nstdout: %s\nstderr: %s", err, out, errOut)
128 }
129 if _, err := f.srv.Repos.Open("renamed-demo"); err != nil {
130 t.Fatalf("renamed repository is missing: %v", err)
131 }
132
133 out, errOut, err = f.adminSSH(f.keyPath, nil, "repo", "delete", "renamed-demo")
134 if err != nil {
135 t.Fatalf("repo delete: %v\nstdout: %s\nstderr: %s", err, out, errOut)
136 }
137 if _, err := f.srv.Repos.Open("renamed-demo"); err == nil {
138 t.Fatal("deleted repository still exists")
139 }
140
141 out, errOut, err = f.adminSSH(f.keyPath, nil, "user", "retire", "alice")
142 if err != nil {
143 t.Fatalf("user retire: %v\nstdout: %s\nstderr: %s", err, out, errOut)
144 }
145 if a, err := f.srv.Identity.Find("alice"); err == nil && a.Active() {
146 t.Fatal("retired account is still active")
147 }
148 }
149
150 func TestAdminCLIForwardsToRemote(t *testing.T) {
151 t.Parallel()
152 f := newFixture(t)
153 if _, err := f.srv.Identity.SetAdmin("keenan", true, "test"); err != nil {
154 t.Fatal(err)
155 }
156 cmd := exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "user", "list")
157 cmd.Env = append(cmd.Environ(),
158 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
159 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
160 )
161 var out, errOut bytes.Buffer
162 cmd.Stdout, cmd.Stderr = &out, &errOut
163 if err := cmd.Run(); err != nil {
164 t.Fatalf("vole admin --remote: %v\nstdout: %s\nstderr: %s", err, out.String(), errOut.String())
165 }
166 if !strings.Contains(out.String(), "keenan") {
167 t.Fatalf("remote admin output did not list the admin:\n%s", out.String())
168 }
169
170 // The client reads a key file and sends it on stdin; the server never sees
171 // the client's filesystem.
172 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_cli_alice"))
173 cmd = exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "user", "create", "alice")
174 cmd.Env = append(cmd.Environ(),
175 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
176 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
177 )
178 if err := cmd.Run(); err != nil {
179 t.Fatalf("remote user create: %v", err)
180 }
181 cmd = exec.Command(binPath, "admin", "--remote", "ssh://git@"+f.sshAddr, "user", "add-key", "alice")
182 cmd.Env = append(cmd.Environ(),
183 "VOLE_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
184 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
185 )
186 cmd.Stdin = strings.NewReader(pub)
187 if err := cmd.Run(); err != nil {
188 t.Fatalf("remote user add-key: %v", err)
189 }
190 if a, err := f.srv.Identity.Find("alice"); err != nil || len(a.Keys) != 1 {
191 t.Fatalf("alice's key did not land: %+v, %v", a, err)
192 }
193 }