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