~bigbes/sr-ht-dolt

ref: bb8ce43b699435a1b43302e70fe9c1e29a9d0f23 sr-ht-dolt/cmd/dolt-git-hook/main.go -rw-r--r-- 7.7 KiB
bb8ce43b — Eugene Blikh web: restyle dashboard and database lists after git.sr.ht 11 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
// Command dolt-git-hook is git.sr.ht's post-update-script, replacing the stock
// /usr/bin/git.sr.ht-update-hook in [git.sr.ht]post-update-script. git.sr.ht
// symlinks the configured script as all four repo hooks (pre-receive, update,
// post-update, post-receive); this binary is that script.
//
// It does two things:
//
//  1. Delegates every invocation to the stock hook, unchanged — same argv[0]
//     (so the stock binary's os.Args[0] dispatch still fires), same stdin, env,
//     working directory and exit code. Build submission, webhook delivery, ACL
//     enforcement and the stock autocreate notice all keep working.
//
//  2. On the post-update stage only, provisions a companion Dolt database at
//     ~owner/name via dolt.sr.ht's internal create endpoint, so a matching Dolt
//     DB exists before the user's first `dolt push`, and prints a one-time
//     terminal notice when it is first created. This step is strictly
//     best-effort: it never changes the delegate's exit code and never fails a
//     push (post-update runs after refs are already updated).
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// defaultDelegate is the stock git.sr.ht hook this wrapper wraps. The apk still
// installs it here; only the [git.sr.ht]post-update-script symlink target moves
// to this binary. Overridable via env for testing.
const defaultDelegate = "/usr/bin/git.sr.ht-update-hook"

// provisionTimeout bounds the internal create call so a slow or down dolt.sr.ht
// never adds more than this to a push.
const provisionTimeout = 5 * time.Second

func main() {
	code := runDelegate()

	// filepath.Base("hooks/post-update") == "post-update".
	if filepath.Base(os.Args[0]) == "post-update" {
		provisionDolt()
	}

	os.Exit(code)
}

// runDelegate execs the stock hook with this process's argv, stdio, env and
// working directory, and returns its exit code. If the delegate cannot be
// started at all (e.g. missing binary) it fails closed with a non-zero code:
// the stock hook is where ACL enforcement lives, so a push must not proceed
// without it.
func runDelegate() int {
	delegate := os.Getenv("DOLT_GIT_HOOK_DELEGATE")
	if delegate == "" {
		delegate = defaultDelegate
	}

	cmd := exec.Command(delegate)
	cmd.Args = os.Args // preserve argv[0] = "hooks/<stage>" for the stock dispatch
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = os.Environ()

	err := cmd.Run()
	if err == nil {
		return 0
	}
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		return ee.ExitCode()
	}
	fmt.Fprintf(os.Stderr, "dolt-git-hook: cannot run %s: %v\n", delegate, err)
	return 1
}

// pushContext is the subset of git.sr.ht's SRHT_PUSH_CTX we need: the repo's
// owner, name and visibility. git.sr.ht-shell sets this env var before exec'ing
// git-receive-pack, so it is inherited by every hook.
type pushContext struct {
	Repo struct {
		Name       string `json:"name"`
		OwnerName  string `json:"owner_name"`
		Visibility string `json:"visibility"`
	} `json:"repo"`
}

// internalAuth mirrors core-go's client.InternalAuth wire shape. crypto.Encrypt
// seals it with the shared [sr.ht]network-key; dolt.sr.ht's internalAuthGuard
// decrypts and trusts it.
type internalAuth struct {
	Name     string `json:"name,omitempty"`
	ClientID string `json:"client_id"`
	NodeID   string `json:"node_id"`
}

// provisionDolt asks dolt.sr.ht to create the companion database for the pushed
// repo. Every failure mode is swallowed (logged to stderr at most): this is a
// convenience, not part of the push contract.
func provisionDolt() {
	// A bug here must never escape into the push; recover defensively (config
	// loading log.Fatalf's are handled by only running post-update, where the
	// exit code is already irrelevant to push success).
	defer func() { _ = recover() }()

	raw := os.Getenv("SRHT_PUSH_CTX")
	if raw == "" {
		return // push not routed through git.sr.ht-shell; nothing to do
	}
	var pc pushContext
	if err := json.Unmarshal([]byte(raw), &pc); err != nil {
		return
	}
	if pc.Repo.Name == "" || pc.Repo.OwnerName == "" {
		return
	}

	conf := config.LoadConfig()

	// crypto.InitCrypto log.Fatalf's (os.Exit) on a missing key, which recover
	// cannot catch — check the keys ourselves first so a misconfigured instance
	// degrades to a skipped companion, never a hard-exiting hook.
	if _, ok := conf.Get("sr.ht", "network-key"); !ok {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [sr.ht]network-key not set; skipping companion provisioning")
		return
	}
	if _, ok := conf.Get("webhooks", "private-key"); !ok {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [webhooks]private-key not set; skipping companion provisioning")
		return
	}

	origin, ok := conf.Get("dolt.sr.ht", "internal-origin")
	if !ok || origin == "" {
		fmt.Fprintln(os.Stderr, "dolt-git-hook: [dolt.sr.ht]internal-origin not set; skipping companion provisioning")
		return
	}

	crypto.InitCrypto(conf)

	createCompanion(os.Stderr, origin, pc)
}

// createCompanion POSTs the internal create request for pc to origin, signing it
// with the shared network-key (crypto must already be initialized), and reports
// progress to out: the one-time notice on a fresh 201, silence on an existing
// 200, a warning on anything else. It is separated from provisionDolt so it can
// be integration-tested against an httptest server without config-file loading.
func createCompanion(out io.Writer, origin string, pc pushContext) {
	reqBody := map[string]string{
		"owner": pc.Repo.OwnerName,
		"name":  pc.Repo.Name,
	}
	if v := normalizeVisibility(pc.Repo.Visibility); v != "" {
		reqBody["visibility"] = v
	}
	body, _ := json.Marshal(reqBody)

	authBlob, _ := json.Marshal(internalAuth{
		Name:     pc.Repo.OwnerName,
		ClientID: "git.sr.ht",
		NodeID:   "dolt-git-hook",
	})

	req, err := http.NewRequest("POST",
		strings.TrimRight(origin, "/")+"/internal/repos", bytes.NewReader(body))
	if err != nil {
		return
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(authBlob)))

	client := &http.Client{Timeout: provisionTimeout}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Fprintf(out, "dolt-git-hook: dolt.sr.ht unreachable: %v\n", err)
		return
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusCreated:
		var r struct {
			URL string `json:"url"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&r)
		printNotice(out, pc.Repo.OwnerName, pc.Repo.Name, r.URL)
	case http.StatusOK:
		// Companion already existed; stay quiet so only the first push announces.
	default:
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		fmt.Fprintf(out, "dolt-git-hook: companion provisioning failed (%d): %s\n",
			resp.StatusCode, strings.TrimSpace(string(msg)))
	}
}

// normalizeVisibility maps a git.sr.ht visibility to the dolt.sr.ht enum,
// returning "" (let the endpoint default to PRIVATE) for anything unrecognized.
func normalizeVisibility(v string) string {
	switch strings.ToUpper(strings.TrimSpace(v)) {
	case "PUBLIC":
		return "PUBLIC"
	case "UNLISTED":
		return "UNLISTED"
	case "PRIVATE":
		return "PRIVATE"
	default:
		return ""
	}
}

// printNotice writes the one-time companion-created notice to out (os.Stderr in
// production), which git relays to the pushing client's terminal — the same
// stream and stage git.sr.ht uses for its own autocreate notice.
func printNotice(out io.Writer, owner, name, url string) {
	if url == "" {
		url = fmt.Sprintf("(~%s/%s)", owner, name)
	}
	fmt.Fprintf(out, "\n\t\033[93mNOTICE\033[0m\n"+
		"\tA Dolt database companion has been created for ~%s/%s:\n\n"+
		"\t    dolt clone %s\n"+
		"\t    web:   %s\n\n",
		owner, name, url, url)
}