Monorepo for Tangled
1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "path/filepath"
10 "strings"
11
12 "github.com/go-chi/chi/v5"
13 "github.com/go-chi/chi/v5/middleware"
14 "github.com/go-git/go-git/v5/plumbing"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/hook"
17 "tangled.org/core/idresolver"
18 "tangled.org/core/knotserver/config"
19 "tangled.org/core/knotserver/db"
20 "tangled.org/core/knotserver/git"
21 "tangled.org/core/log"
22 "tangled.org/core/notifier"
23 "tangled.org/core/rbac"
24 "tangled.org/core/workflow"
25)
26
27type InternalHandle struct {
28 db *db.DB
29 c *config.Config
30 e *rbac.Enforcer
31 l *slog.Logger
32 n *notifier.Notifier
33 res *idresolver.Resolver
34}
35
36func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
37 user := r.URL.Query().Get("user")
38 repo := r.URL.Query().Get("repo")
39
40 if user == "" || repo == "" {
41 w.WriteHeader(http.StatusBadRequest)
42 return
43 }
44
45 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
46 if err != nil || !ok {
47 w.WriteHeader(http.StatusForbidden)
48 return
49 }
50
51 w.WriteHeader(http.StatusNoContent)
52}
53
54func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
55 keys, err := h.db.GetAllPublicKeys()
56 if err != nil {
57 writeError(w, err.Error(), http.StatusInternalServerError)
58 return
59 }
60
61 data := make([]map[string]interface{}, 0)
62 for _, key := range keys {
63 j := key.JSON()
64 data = append(data, j)
65 }
66 writeJSON(w, data)
67}
68
69// response in text/plain format
70// the body will be qualified repository path on success/push-denied
71// or an error message when process failed
72func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
73 l := h.l.With("handler", "Guard")
74
75 var (
76 incomingUser = r.URL.Query().Get("user")
77 repo = r.URL.Query().Get("repo")
78 gitCommand = r.URL.Query().Get("gitCmd")
79 )
80
81 if incomingUser == "" || repo == "" || gitCommand == "" {
82 w.WriteHeader(http.StatusBadRequest)
83 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
84 fmt.Fprintln(w, "invalid internal request")
85 return
86 }
87
88 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
89 l.Info("command components", "components", components)
90
91 var rbacResource string
92 var diskRelative string
93
94 switch {
95 case len(components) == 1 && strings.HasPrefix(components[0], "did:"):
96 repoDid := components[0]
97 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
98 if lookupErr != nil {
99 w.WriteHeader(http.StatusNotFound)
100 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr)
101 fmt.Fprintln(w, "repo not found")
102 return
103 }
104 rbacResource = repoDid
105 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
106 if relErr != nil {
107 w.WriteHeader(http.StatusInternalServerError)
108 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
109 fmt.Fprintln(w, "internal error")
110 return
111 }
112 diskRelative = rel
113
114 case len(components) == 2:
115 repoOwner := components[0]
116 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
117 repoOwnerIdent, resolveErr := resolver.ResolveIdent(r.Context(), repoOwner)
118 if resolveErr != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
119 l.Error("Error resolving handle", "handle", repoOwner, "err", resolveErr)
120 w.WriteHeader(http.StatusInternalServerError)
121 fmt.Fprintf(w, "error resolving handle: invalid handle\n")
122 return
123 }
124 ownerDid := repoOwnerIdent.DID.String()
125 repoName := components[1]
126 repoDid, didErr := h.db.GetRepoDid(ownerDid, repoName)
127 if didErr != nil {
128 w.WriteHeader(http.StatusNotFound)
129 l.Error("repo DID not found", "owner", ownerDid, "name", repoName, "err", didErr)
130 fmt.Fprintln(w, "repo not found")
131 return
132 }
133 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
134 if lookupErr != nil {
135 w.WriteHeader(http.StatusNotFound)
136 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr)
137 fmt.Fprintln(w, "repo not found")
138 return
139 }
140 rbacResource = repoDid
141 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
142 if relErr != nil {
143 w.WriteHeader(http.StatusInternalServerError)
144 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
145 fmt.Fprintln(w, "internal error")
146 return
147 }
148 diskRelative = rel
149
150 default:
151 w.WriteHeader(http.StatusBadRequest)
152 l.Error("invalid repo format", "components", components)
153 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>")
154 return
155 }
156
157 if gitCommand == "git-receive-pack" {
158 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource)
159 if err != nil || !ok {
160 w.WriteHeader(http.StatusForbidden)
161 fmt.Fprint(w, repo)
162 return
163 }
164 }
165
166 w.WriteHeader(http.StatusOK)
167 fmt.Fprint(w, diskRelative)
168}
169
170type PushOptions struct {
171 skipCi bool
172 verboseCi bool
173}
174
175func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
176 l := h.l.With("handler", "PostReceiveHook")
177
178 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
179 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
180 if err != nil {
181 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
182 w.WriteHeader(http.StatusInternalServerError)
183 return
184 }
185
186 repoDid := gitRelativeDir
187 if !strings.HasPrefix(repoDid, "did:") {
188 l.Error("invalid git dir, expected repo DID", "gitRelativeDir", gitRelativeDir)
189 w.WriteHeader(http.StatusBadRequest)
190 return
191 }
192
193 ownerDid, repoName, err := h.db.GetRepoKeyOwner(repoDid)
194 if err != nil {
195 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err)
196 w.WriteHeader(http.StatusBadRequest)
197 return
198 }
199
200 gitUserDid := r.Header.Get("X-Git-User-Did")
201
202 lines, err := git.ParsePostReceive(r.Body)
203 if err != nil {
204 l.Error("failed to parse post-receive payload", "err", err)
205 // non-fatal
206 }
207
208 // extract any push options
209 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
210 pushOptions := PushOptions{}
211 for _, option := range pushOptionsRaw {
212 if option == "skip-ci" || option == "ci-skip" {
213 pushOptions.skipCi = true
214 }
215 if option == "verbose-ci" || option == "ci-verbose" {
216 pushOptions.verboseCi = true
217 }
218 }
219
220 resp := hook.HookResponse{
221 Messages: make([]string, 0),
222 }
223
224 for _, line := range lines {
225 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid)
226 if err != nil {
227 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
228 }
229
230 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid)
231 if err != nil {
232 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
233 }
234
235 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions)
236 if err != nil {
237 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
238 }
239 }
240
241 writeJSON(w, resp)
242}
243
244func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error {
245 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
246 if resolveErr != nil {
247 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
248 }
249
250 gr, err := git.Open(repoPath, line.Ref)
251 if err != nil {
252 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
253 }
254
255 meta, err := gr.RefUpdateMeta(line)
256 if err != nil {
257 return fmt.Errorf("failed to get ref update metadata: %w", err)
258 }
259
260 metaRecord := meta.AsRecord()
261
262 refUpdate := tangled.GitRefUpdate{
263 OldSha: line.OldSha.String(),
264 NewSha: line.NewSha.String(),
265 Ref: line.Ref,
266 CommitterDid: gitUserDid,
267 OwnerDid: ownerDid,
268 RepoName: repoName,
269 RepoDid: repoDid,
270 Meta: &metaRecord,
271 }
272
273 eventJson, err := json.Marshal(refUpdate)
274 if err != nil {
275 return err
276 }
277
278 event := db.Event{
279 Rkey: TID(),
280 Nsid: tangled.GitRefUpdateNSID,
281 EventJson: string(eventJson),
282 }
283
284 return h.db.InsertEvent(event, h.n)
285}
286
287func (h *InternalHandle) triggerPipeline(
288 clientMsgs *[]string,
289 line git.PostReceiveLine,
290 gitUserDid string,
291 ownerDid string,
292 repoName string,
293 repoDid string,
294 pushOptions PushOptions,
295) error {
296 if pushOptions.skipCi {
297 return nil
298 }
299
300 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
301 if resolveErr != nil {
302 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
303 }
304
305 gr, err := git.Open(repoPath, line.Ref)
306 if err != nil {
307 return err
308 }
309
310 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir)
311 if err != nil {
312 return err
313 }
314
315 var pipeline workflow.RawPipeline
316 for _, e := range workflowDir {
317 if !e.IsFile() {
318 continue
319 }
320
321 fpath := filepath.Join(workflow.WorkflowDir, e.Name)
322 contents, err := gr.RawContent(fpath)
323 if err != nil {
324 continue
325 }
326
327 pipeline = append(pipeline, workflow.RawWorkflow{
328 Name: e.Name,
329 Contents: contents,
330 })
331 }
332
333 trigger := tangled.Pipeline_PushTriggerData{
334 Ref: line.Ref,
335 OldSha: line.OldSha.String(),
336 NewSha: line.NewSha.String(),
337 }
338
339 triggerRepo := &tangled.Pipeline_TriggerRepo{
340 Did: ownerDid,
341 Knot: h.c.Server.Hostname,
342 Repo: repoName,
343 RepoDid: repoDid,
344 }
345
346 compiler := workflow.Compiler{
347 Trigger: tangled.Pipeline_TriggerMetadata{
348 Kind: string(workflow.TriggerKindPush),
349 Push: &trigger,
350 Repo: triggerRepo,
351 },
352 }
353
354 cp := compiler.Compile(compiler.Parse(pipeline))
355 eventJson, err := json.Marshal(cp)
356 if err != nil {
357 return err
358 }
359
360 for _, e := range compiler.Diagnostics.Errors {
361 *clientMsgs = append(*clientMsgs, e.String())
362 }
363
364 if pushOptions.verboseCi {
365 if compiler.Diagnostics.IsEmpty() {
366 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics")
367 }
368
369 for _, w := range compiler.Diagnostics.Warnings {
370 *clientMsgs = append(*clientMsgs, w.String())
371 }
372 }
373
374 // do not run empty pipelines
375 if cp.Workflows == nil {
376 return nil
377 }
378
379 event := db.Event{
380 Rkey: TID(),
381 Nsid: tangled.PipelineNSID,
382 EventJson: string(eventJson),
383 }
384
385 return h.db.InsertEvent(event, h.n)
386}
387
388func (h *InternalHandle) emitCompareLink(
389 clientMsgs *[]string,
390 line git.PostReceiveLine,
391 ownerDid string,
392 repoName string,
393 repoDid string,
394) error {
395 // this is a second push to a branch, don't reply with the link again
396 if !line.OldSha.IsZero() {
397 return nil
398 }
399
400 // the ref was not updated to a new hash, don't reply with the link
401 //
402 // NOTE: do we need this?
403 if line.NewSha.String() == line.OldSha.String() {
404 return nil
405 }
406
407 pushedRef := plumbing.ReferenceName(line.Ref)
408
409 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid)
410 user := ownerDid
411 if err == nil {
412 user = userIdent.Handle.String()
413 }
414
415 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
416 if resolveErr != nil {
417 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
418 }
419
420 gr, err := git.PlainOpen(repoPath)
421 if err != nil {
422 return err
423 }
424
425 defaultBranch, err := gr.FindMainBranch()
426 if err != nil {
427 return err
428 }
429
430 // pushing to default branch
431 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
432 return nil
433 }
434
435 // pushing a tag, don't prompt the user the open a PR
436 if pushedRef.IsTag() {
437 return nil
438 }
439
440 ZWS := "\u200B"
441 *clientMsgs = append(*clientMsgs, ZWS)
442 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
443 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
444 *clientMsgs = append(*clientMsgs, ZWS)
445 return nil
446}
447
448func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
449 r := chi.NewRouter()
450 l := log.FromContext(ctx)
451 l = log.SubLogger(l, "internal")
452 res := idresolver.DefaultResolver(c.Server.PlcUrl)
453
454 h := InternalHandle{
455 db,
456 c,
457 e,
458 l,
459 n,
460 res,
461 }
462
463 r.Get("/push-allowed", h.PushAllowed)
464 r.Get("/keys", h.InternalKeys)
465 r.Get("/guard", h.Guard)
466 r.Post("/hooks/post-receive", h.PostReceiveHook)
467 r.Mount("/debug", middleware.Profiler())
468
469 return r
470}