coverage~bigbes/sr-ht-doltede3b0bbcmd/dolt-git-hook/main.go

Coverage
38.0% 27/71 statements
Δ
Blob
fa2994a
1 // Command dolt-git-hook is git.sr.ht's post-update-script, replacing the stock
2 // /usr/bin/git.sr.ht-update-hook in [git.sr.ht]post-update-script. git.sr.ht
3 // symlinks the configured script as all four repo hooks (pre-receive, update,
4 // post-update, post-receive); this binary is that script.
5 //
6 // It does two things:
7 //
8 // 1. Delegates every invocation to the stock hook, unchanged — same argv[0]
9 // (so the stock binary's os.Args[0] dispatch still fires), same stdin, env,
10 // working directory and exit code. Build submission, webhook delivery, ACL
11 // enforcement and the stock autocreate notice all keep working.
12 //
13 // 2. On the post-update stage only, provisions a companion Dolt database at
14 // ~owner/name via dolt.sr.ht's internal create endpoint, so a matching Dolt
15 // DB exists before the user's first `dolt push`, and prints a one-time
16 // terminal notice when it is first created. This step is strictly
17 // best-effort: it never changes the delegate's exit code and never fails a
18 // push (post-update runs after refs are already updated).
19 package main
20
21 import (
22 "bytes"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "io"
27 "net/http"
28 "os"
29 "os/exec"
30 "path/filepath"
31 "strings"
32 "time"
33
34 "sourcecraft.dev/bigbes/sr-ht-core/config"
35 "sourcecraft.dev/bigbes/sr-ht-core/crypto"
36
37 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
38 "sourcecraft.dev/bigbes/sr-ht-ecore/internalauth"
39
40 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
41 )
42
43 // defaultDelegate is the stock git.sr.ht hook this wrapper wraps. The apk still
44 // installs it here; only the [git.sr.ht]post-update-script symlink target moves
45 // to this binary. Overridable via env for testing.
46 const defaultDelegate = "/usr/bin/git.sr.ht-update-hook"
47
48 // doltService is the config section of the service this hook provisions into.
49 const doltService = "dolt.sr.ht"
50
51 // provisionTimeout bounds the internal create call so a slow or down dolt.sr.ht
52 // never adds more than this to a push.
53 const provisionTimeout = 5 * time.Second
54
55 0 func main() {
56 0 code := runDelegate()
57 0
58 0 // filepath.Base("hooks/post-update") == "post-update".
59 0 if filepath.Base(os.Args[0]) == "post-update" {
60 0 provisionDolt()
61 0 }
62
63 0 os.Exit(code)
64 }
65
66 // runDelegate execs the stock hook with this process's argv, stdio, env and
67 // working directory, and returns its exit code. If the delegate cannot be
68 // started at all (e.g. missing binary) it fails closed with a non-zero code:
69 // the stock hook is where ACL enforcement lives, so a push must not proceed
70 // without it.
71 0 func runDelegate() int {
72 0 delegate := os.Getenv("DOLT_GIT_HOOK_DELEGATE")
73 0 if delegate == "" {
74 0 delegate = defaultDelegate
75 0 }
76
77 0 cmd := exec.Command(delegate)
78 0 cmd.Args = os.Args // preserve argv[0] = "hooks/<stage>" for the stock dispatch
79 0 cmd.Stdin = os.Stdin
80 0 cmd.Stdout = os.Stdout
81 0 cmd.Stderr = os.Stderr
82 0 cmd.Env = os.Environ()
83 0
84 0 err := cmd.Run()
85 0 if err == nil {
86 0 return 0
87 0 }
88 0 var ee *exec.ExitError
89 0 if errors.As(err, &ee) {
90 0 return ee.ExitCode()
91 0 }
92 0 fmt.Fprintf(os.Stderr, "dolt-git-hook: cannot run %s: %v\n", delegate, err)
93 0 return 1
94 }
95
96 // pushContext is the subset of git.sr.ht's SRHT_PUSH_CTX we need: the repo's
97 // owner, name and visibility. git.sr.ht-shell sets this env var before exec'ing
98 // git-receive-pack, so it is inherited by every hook.
99 type pushContext struct {
100 Repo struct {
101 Name string `json:"name"`
102 OwnerName string `json:"owner_name"`
103 Visibility string `json:"visibility"`
104 } `json:"repo"`
105 }
106
107 // provisionDolt asks dolt.sr.ht to create the companion database for the pushed
108 // repo. Every failure mode is swallowed (logged to stderr at most): this is a
109 // convenience, not part of the push contract.
110 0 func provisionDolt() {
111 0 // A bug here must never escape into the push; recover defensively (config
112 0 // loading log.Fatalf's are handled by only running post-update, where the
113 0 // exit code is already irrelevant to push success).
114 0 defer func() { _ = recover() }()
115
116 0 raw := os.Getenv("SRHT_PUSH_CTX")
117 0 if raw == "" {
118 0 return // push not routed through git.sr.ht-shell; nothing to do
119 0 }
120 0 var pc pushContext
121 0 if err := json.Unmarshal([]byte(raw), &pc); err != nil {
122 0 return
123 0 }
124 0 if pc.Repo.Name == "" || pc.Repo.OwnerName == "" {
125 0 return
126 0 }
127
128 0 conf := config.LoadConfig()
129 0
130 0 // crypto.InitCrypto log.Fatalf's (os.Exit) on a missing key, which recover
131 0 // cannot catch — check the keys ourselves first so a misconfigured instance
132 0 // degrades to a skipped companion, never a hard-exiting hook. Everything
133 0 // this call needs is asked for at once, so an operator whose config is
134 0 // incomplete reads the whole list off one push instead of one key per push.
135 0 if err := instconf.Require(conf,
136 0 instconf.Need("sr.ht", "network-key"),
137 0 instconf.Need("webhooks", "private-key"),
138 0 instconf.NeedAny(doltService, "internal-origin", "origin"),
139 0 ); err != nil {
140 0 fmt.Fprintf(os.Stderr, "dolt-git-hook: %v; skipping companion provisioning\n", err)
141 0 return
142 0 }
143
144 // The internal origin when the instance has one, the external origin when it
145 // does not. An instance that routes service-to-service traffic over a private
146 // network sets the first; one that has only the public address is not
147 // misconfigured, and used to be refused here for it.
148 0 origin := instconf.InternalOrigin(conf, doltService)
149 0
150 0 crypto.InitCrypto(conf)
151 0
152 0 createCompanion(os.Stderr, origin, pc)
153 }
154
155 // createCompanion POSTs the internal create request for pc to origin, signing it
156 // with the shared network-key (crypto must already be initialized), and reports
157 // progress to out: the one-time notice on a fresh 201, silence on an existing
158 // 200, a warning on anything else. It is separated from provisionDolt so it can
159 // be integration-tested against an httptest server without config-file loading.
160 3 func createCompanion(out io.Writer, origin string, pc pushContext) {
161 3 reqBody := map[string]string{
162 3 "owner": pc.Repo.OwnerName,
163 3 "name": pc.Repo.Name,
164 3 }
165 3 if v := normalizeVisibility(pc.Repo.Visibility); v != "" {
166 3 reqBody["visibility"] = v
167 3 }
168 3 body, _ := json.Marshal(reqBody)
169 3
170 3 // The other end of this is web's internalauth.Guard, and the two are now one
171 3 // package rather than a struct literal here and a decode there. The call is
172 3 // made on the pushing repo's owner's behalf; the ids are the pinned pair the
173 3 // guard accepts. A missing network key surfaces as an error instead of a nil
174 3 // dereference inside fernet, which on this side matters — it must cost a
175 3 // companion database, never the push.
176 3 authorization, err := internalauth.AuthorizationAs(
177 3 pc.Repo.OwnerName, core.InternalClientID, core.InternalNodeID)
178 3 if err != nil {
179 0 fmt.Fprintf(out, "dolt-git-hook: cannot sign the provisioning call: %v\n", err)
180 0 return
181 0 }
182
183 3 req, err := http.NewRequest("POST",
184 3 instconf.CanonicalOrigin(origin)+"/internal/repos", bytes.NewReader(body))
185 3 if err != nil {
186 0 return
187 0 }
188 3 req.Header.Set("Content-Type", "application/json")
189 3 req.Header.Set("Authorization", authorization)
190 3
191 3 client := &http.Client{Timeout: provisionTimeout}
192 3 resp, err := client.Do(req)
193 3 if err != nil {
194 0 fmt.Fprintf(out, "dolt-git-hook: dolt.sr.ht unreachable: %v\n", err)
195 0 return
196 0 }
197 3 defer resp.Body.Close()
198 3
199 3 switch resp.StatusCode {
200 1 case http.StatusCreated:
201 1 var r struct {
202 1 URL string `json:"url"`
203 1 }
204 1 _ = json.NewDecoder(resp.Body).Decode(&r)
205 1 printNotice(out, pc.Repo.OwnerName, pc.Repo.Name, r.URL)
206 case http.StatusOK:
207 // Companion already existed; stay quiet so only the first push announces.
208 1 default:
209 1 msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
210 1 fmt.Fprintf(out, "dolt-git-hook: companion provisioning failed (%d): %s\n",
211 1 resp.StatusCode, strings.TrimSpace(string(msg)))
212 }
213 }
214
215 // normalizeVisibility maps a git.sr.ht visibility to the dolt.sr.ht enum,
216 // returning "" (let the endpoint default to PRIVATE) for anything unrecognized.
217 8 func normalizeVisibility(v string) string {
218 8 switch strings.ToUpper(strings.TrimSpace(v)) {
219 1 case "PUBLIC":
220 1 return "PUBLIC"
221 1 case "UNLISTED":
222 1 return "UNLISTED"
223 4 case "PRIVATE":
224 4 return "PRIVATE"
225 2 default:
226 2 return ""
227 }
228 }
229
230 // printNotice writes the one-time companion-created notice to out (os.Stderr in
231 // production), which git relays to the pushing client's terminal — the same
232 // stream and stage git.sr.ht uses for its own autocreate notice.
233 1 func printNotice(out io.Writer, owner, name, url string) {
234 1 if url == "" {
235 0 url = fmt.Sprintf("(~%s/%s)", owner, name)
236 0 }
237 1 fmt.Fprintf(out, "\n\t\033[93mNOTICE\033[0m\n"+
238 1 "\tA Dolt database companion has been created for ~%s/%s:\n\n"+
239 1 "\t dolt clone %s\n"+
240 1 "\t web: %s\n\n",
241 1 owner, name, url, url)
242 }