Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knotserver / internal.go
17 kB 616 lines
1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net" 9 "net/http" 10 "net/url" 11 "os" 12 "path" 13 "path/filepath" 14 "strings" 15 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/go-chi/chi/v5" 18 "github.com/go-chi/chi/v5/middleware" 19 "github.com/go-git/go-git/v5/plumbing" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/eventstream" 22 "tangled.org/core/hook" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/knotserver/config" 25 "tangled.org/core/knotserver/db" 26 "tangled.org/core/knotserver/git" 27 "tangled.org/core/log" 28 "tangled.org/core/notifier" 29 "tangled.org/core/rbac" 30 "tangled.org/core/tid" 31 "tangled.org/core/workflow" 32) 33 34type InternalHandle struct { 35 db *db.DB 36 c *config.Config 37 e *rbac.Enforcer 38 l *slog.Logger 39 n *notifier.Notifier 40 res *idresolver.Resolver 41} 42 43func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 44 user := r.URL.Query().Get("user") 45 repo := r.URL.Query().Get("repo") 46 47 if user == "" || repo == "" { 48 w.WriteHeader(http.StatusBadRequest) 49 return 50 } 51 52 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 53 if err != nil || !ok { 54 w.WriteHeader(http.StatusForbidden) 55 return 56 } 57 58 w.WriteHeader(http.StatusNoContent) 59} 60 61func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 62 keys, err := h.db.GetAllPublicKeys() 63 if err != nil { 64 writeError(w, err.Error(), http.StatusInternalServerError) 65 return 66 } 67 68 data := make([]map[string]interface{}, 0) 69 for _, key := range keys { 70 j := key.JSON() 71 data = append(data, j) 72 } 73 writeJSON(w, data) 74} 75 76// response in text/plain format 77// the body will be qualified repository path on success/push-denied 78// or an error message when process failed 79func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 80 l := h.l.With("handler", "Guard") 81 82 var ( 83 incomingUser = r.URL.Query().Get("user") 84 repo = r.URL.Query().Get("repo") 85 gitCommand = r.URL.Query().Get("gitCmd") 86 ) 87 88 if incomingUser == "" || repo == "" || gitCommand == "" { 89 w.WriteHeader(http.StatusBadRequest) 90 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 91 fmt.Fprintln(w, "invalid internal request") 92 return 93 } 94 95 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 96 l.Info("command components", "components", components) 97 98 var rbacResource string 99 var diskRelative string 100 101 switch { 102 case len(components) == 1 && strings.HasPrefix(components[0], "did:"): 103 repoDid := components[0] 104 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 105 if lookupErr != nil { 106 w.WriteHeader(http.StatusNotFound) 107 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr) 108 fmt.Fprintln(w, "repo not found") 109 return 110 } 111 rbacResource = repoDid 112 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 113 if relErr != nil { 114 w.WriteHeader(http.StatusInternalServerError) 115 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 116 fmt.Fprintln(w, "internal error") 117 return 118 } 119 diskRelative = rel 120 121 case len(components) == 2: 122 repoOwner := components[0] 123 ownerIdent, resolveErr := h.res.ResolveAtIdentifier(r.Context(), repoOwner) 124 if resolveErr != nil { 125 l.Error("error resolving owner", "owner", repoOwner, "err", resolveErr) 126 w.WriteHeader(http.StatusInternalServerError) 127 fmt.Fprintf(w, "error resolving owner: invalid did or handle\n") 128 return 129 } 130 ownerDid := ownerIdent.DID 131 repoName := components[1] 132 repoDid, didErr := h.db.GetRepoDid(ownerDid.String(), repoName) 133 var repoPath string 134 if didErr == nil { 135 var lookupErr error 136 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 137 if lookupErr != nil { 138 w.WriteHeader(http.StatusNotFound) 139 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) 140 fmt.Fprintln(w, "repo not found") 141 return 142 } 143 rbacResource = repoDid 144 } else { 145 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName)) 146 if joinErr != nil { 147 w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 148 w.WriteHeader(http.StatusNotFound) 149 fmt.Fprint(w, "repo not found\n") 150 return 151 } 152 if _, statErr := os.Stat(legacyPath); statErr != nil { 153 l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName) 154 w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 155 w.WriteHeader(http.StatusNotFound) 156 fmt.Fprint(w, "repo not found\n") 157 return 158 } 159 repoPath = legacyPath 160 rbacResource = ownerDid.String() + "/" + repoName 161 } 162 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 163 if relErr != nil { 164 w.WriteHeader(http.StatusInternalServerError) 165 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 166 fmt.Fprintln(w, "internal error") 167 return 168 } 169 diskRelative = rel 170 171 default: 172 w.WriteHeader(http.StatusBadRequest) 173 l.Error("invalid repo format", "components", components) 174 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>") 175 return 176 } 177 178 if gitCommand == "git-receive-pack" { 179 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 180 if err != nil || !ok { 181 w.WriteHeader(http.StatusForbidden) 182 fmt.Fprint(w, repo) 183 return 184 } 185 } 186 187 w.WriteHeader(http.StatusOK) 188 fmt.Fprint(w, diskRelative) 189} 190 191func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 192 l := h.l.With("handler", "PostReceiveHook") 193 194 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 195 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 196 if err != nil { 197 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 198 w.WriteHeader(http.StatusInternalServerError) 199 return 200 } 201 202 var repoDid string 203 var ownerDid, repoName string 204 205 if strings.HasPrefix(gitRelativeDir, "did:") { 206 repoDid = gitRelativeDir 207 var err error 208 ownerDid, repoName, err = h.db.GetRepoKeyOwner(repoDid) 209 if err != nil { 210 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err) 211 w.WriteHeader(http.StatusBadRequest) 212 return 213 } 214 } else { 215 components := strings.SplitN(gitRelativeDir, "/", 2) 216 if len(components) != 2 { 217 l.Error("invalid git dir, expected repo DID or owner/repo", "gitRelativeDir", gitRelativeDir) 218 w.WriteHeader(http.StatusBadRequest) 219 return 220 } 221 ownerDid = components[0] 222 repoName = components[1] 223 var didErr error 224 repoDid, didErr = h.db.GetRepoDid(ownerDid, repoName) 225 if didErr != nil { 226 l.Error("failed to resolve repo DID from legacy path", "gitRelativeDir", gitRelativeDir, "err", didErr) 227 w.WriteHeader(http.StatusBadRequest) 228 return 229 } 230 } 231 232 gitUserDid := r.Header.Get("X-Git-User-Did") 233 234 lines, err := git.ParsePostReceive(r.Body) 235 if err != nil { 236 l.Error("failed to parse post-receive payload", "err", err) 237 // non-fatal 238 } 239 240 // extract max 50 push options 241 pushOptions := r.Header.Values("X-Git-Push-Option") 242 if len(pushOptions) > 50 { 243 pushOptions = pushOptions[:50] 244 } 245 246 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 247 if resolveErr != nil { 248 l.Error("failed to resolve repo on disk", "repoDid", repoDid, "err", resolveErr) 249 w.WriteHeader(http.StatusInternalServerError) 250 return 251 } 252 253 resp := hook.HookResponse{ 254 Messages: make([]string, 0), 255 } 256 257 for _, line := range lines { 258 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid, repoPath, pushOptions) 259 if err != nil { 260 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 261 } 262 263 err = h.emitPullRequestLink(&resp.Messages, line, ownerDid, repoName, repoDid) 264 if err != nil { 265 l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 266 } 267 268 if !git.HasSkipCIPushOption(pushOptions) { 269 verbose := hasVerboseCIPushOption(pushOptions) 270 compiler, compiled, err := h.compileCiPipeline(line, ownerDid, repoName, repoDid, repoPath) 271 if err != nil { 272 l.Error("failed to compile ci pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 273 } else { 274 h.emitCiDiagnostics(&resp.Messages, compiler, compiled, verbose) 275 h.emitCiSshCommand(&resp.Messages, compiled, repoDid, line) 276 } 277 } 278 } 279 280 writeJSON(w, resp) 281} 282 283func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string, repoPath string, pushOptions []string) error { 284 refUpdate := tangled.GitRefUpdate{ 285 OldSha: line.OldSha.String(), 286 NewSha: line.NewSha.String(), 287 Ref: line.Ref, 288 CommitterDid: gitUserDid, 289 OwnerDid: &ownerDid, 290 Repo: repoDid, 291 Meta: nil, 292 PushOptions: pushOptions, 293 } 294 295 if !line.NewSha.IsZero() { 296 297 gr, err := git.Open(repoPath, line.Ref) 298 if err != nil { 299 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 300 } 301 302 changedFiles, err := gr.ChangedFilesBetween(line.OldSha.String(), line.NewSha.String()) 303 if err != nil { 304 return fmt.Errorf("failed to get ref update changed files: %w", err) 305 } 306 refUpdate.ChangedFiles = changedFiles 307 308 meta, err := gr.RefUpdateMeta(line) 309 if err != nil { 310 return fmt.Errorf("failed to get ref update metadata: %w", err) 311 } 312 313 refUpdate.Meta = new(tangled.GitRefUpdate_Meta) 314 *refUpdate.Meta = meta.AsRecord() 315 } 316 317 eventJson, err := json.Marshal(refUpdate) 318 if err != nil { 319 return err 320 } 321 322 event := eventstream.Event{ 323 Rkey: tid.TID(), 324 Nsid: tangled.GitRefUpdateNSID, 325 EventJson: eventJson, 326 } 327 328 return h.db.InsertEvent(event, h.n) 329} 330 331func hasVerboseCIPushOption(pushOptions []string) bool { 332 for _, opt := range pushOptions { 333 switch opt { 334 case "verbose-ci", "ci-verbose": 335 return true 336 } 337 } 338 return false 339} 340 341func (h *InternalHandle) compileCiPipeline( 342 line git.PostReceiveLine, 343 ownerDid string, 344 repoName string, 345 repoDid string, 346 repoPath string, 347) (workflow.Compiler, tangled.Pipeline, error) { 348 l := h.l.With("func", "compileCiPipeline", "ref", line.Ref, "repo", repoDid) 349 350 if line.NewSha.IsZero() { 351 return workflow.Compiler{}, tangled.Pipeline{}, nil 352 } 353 354 gr, err := git.Open(repoPath, line.Ref) 355 if err != nil { 356 return workflow.Compiler{}, tangled.Pipeline{}, fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 357 } 358 359 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 360 if err != nil { 361 l.Info("no workflow dir found, skipping ci compilation", "err", err) 362 return workflow.Compiler{}, tangled.Pipeline{}, nil 363 } 364 365 var rawPipeline workflow.RawPipeline 366 for _, e := range workflowDir { 367 if !e.IsFile() { 368 continue 369 } 370 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 371 contents, err := gr.RawContent(fpath) 372 if err != nil { 373 l.Warn("failed to read workflow file", "file", fpath, "err", err) 374 continue 375 } 376 rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 377 Name: e.Name, 378 Contents: contents, 379 }) 380 } 381 382 l.Info("loaded workflow files", "count", len(rawPipeline)) 383 384 defaultBranch, _ := gr.FindMainBranch() 385 386 trigger := tangled.Pipeline_PushTriggerData{ 387 Ref: line.Ref, 388 OldSha: line.OldSha.String(), 389 NewSha: line.NewSha.String(), 390 } 391 392 triggerRepo := &tangled.Pipeline_TriggerRepo{ 393 Did: ownerDid, 394 Knot: h.c.Server.Hostname, 395 Repo: &repoName, 396 RepoDid: &repoDid, 397 DefaultBranch: defaultBranch, 398 } 399 400 changedFiles, err := gr.ChangedFilesBetween(line.OldSha.String(), line.NewSha.String()) 401 if err != nil { 402 return workflow.Compiler{}, tangled.Pipeline{}, fmt.Errorf("getting changed files: %w", err) 403 } 404 405 compiler := workflow.Compiler{ 406 Trigger: tangled.Pipeline_TriggerMetadata{ 407 Kind: string(workflow.TriggerKindPush), 408 Push: &trigger, 409 Repo: triggerRepo, 410 }, 411 ChangedFiles: changedFiles, 412 } 413 414 compiled := compiler.Compile(compiler.Parse(rawPipeline)) 415 416 l.Info("compiled ci pipeline", 417 "workflows", len(compiled.Workflows), 418 "errors", len(compiler.Diagnostics.Errors), 419 "warnings", len(compiler.Diagnostics.Warnings), 420 ) 421 for _, e := range compiler.Diagnostics.Errors { 422 l.Error("ci compilation error", "path", e.Path, "err", e.Error) 423 } 424 for _, w := range compiler.Diagnostics.Warnings { 425 l.Warn("ci compilation warning", "path", w.Path, "kind", w.Type, "reason", w.Reason) 426 } 427 428 return compiler, compiled, nil 429} 430 431func (h *InternalHandle) emitCiDiagnostics(clientMsgs *[]string, compiler workflow.Compiler, compiled tangled.Pipeline, verbose bool) { 432 for _, e := range compiler.Diagnostics.Errors { 433 *clientMsgs = append(*clientMsgs, e.String()) 434 } 435 if verbose { 436 if len(compiled.Workflows) == 0 { 437 *clientMsgs = append(*clientMsgs, "info: no pipelines to compile") 438 return 439 } 440 if compiler.Diagnostics.IsEmpty() { 441 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 442 return 443 } 444 for _, w := range compiler.Diagnostics.Warnings { 445 *clientMsgs = append(*clientMsgs, w.String()) 446 } 447 } 448} 449 450func (h *InternalHandle) emitCiSshCommand(clientMsgs *[]string, compiled tangled.Pipeline, repoDid string, line git.PostReceiveLine) { 451 if len(compiled.Workflows) == 0 || h.c.LogsAddr == "" { 452 return 453 } 454 host, port, err := net.SplitHostPort(h.c.LogsAddr) 455 if err != nil { 456 return 457 } 458 *clientMsgs = append(*clientMsgs, "→ Browse CI logs in your terminal:") 459 *clientMsgs = append(*clientMsgs, fmt.Sprintf(" ssh -t -p %s %s %s %s", port, host, repoDid, line.NewSha)) 460} 461 462func (h *InternalHandle) emitPullRequestLink( 463 clientMsgs *[]string, 464 line git.PostReceiveLine, 465 ownerDid string, 466 repoName string, 467 repoDid string, 468) error { 469 if line.NewSha.IsZero() { 470 return nil 471 } 472 473 // the ref was not updated to a new hash, don't reply with the link 474 // 475 // NOTE: do we need this? 476 if line.NewSha == line.OldSha { 477 return nil 478 } 479 480 pushedRef := plumbing.ReferenceName(line.Ref) 481 if !pushedRef.IsBranch() { 482 return nil 483 } 484 485 if !line.OldSha.IsZero() { 486 return nil 487 } 488 489 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 490 if resolveErr != nil { 491 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 492 } 493 494 gr, err := git.PlainOpen(repoPath) 495 if err != nil { 496 return err 497 } 498 499 remote, err := gr.Remote() 500 if err != nil { 501 return fmt.Errorf("checking for upstream remote: %w", err) 502 } 503 504 defaultBranch, err := gr.FindMainBranch() 505 if err != nil { 506 return err 507 } 508 509 pushedBranch := pushedRef.Short() 510 511 // pushing to default branch 512 if pushedBranch == defaultBranch { 513 return nil 514 } 515 516 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 517 user := ownerDid 518 if err == nil { 519 user = userIdent.Handle.String() 520 } 521 522 pullURL, err := h.createPullURL(h.c.AppViewEndpoint, remote, user, repoDid, repoName, pushedBranch, defaultBranch) 523 if err != nil { 524 return err 525 } 526 527 ZWS := "\u200B" 528 *clientMsgs = append(*clientMsgs, ZWS) 529 *clientMsgs = append(*clientMsgs, "→ Open pull request:") 530 *clientMsgs = append(*clientMsgs, " "+pullURL) 531 *clientMsgs = append(*clientMsgs, ZWS) 532 return nil 533} 534 535func (h *InternalHandle) createPullURL(appviewURL, remote, user, repoDid, repoName, pushedBranch, defaultBranch string) (string, error) { 536 if remote != "" { 537 return h.createForkPullURL(appviewURL, remote, repoDid, pushedBranch, defaultBranch) 538 } 539 540 query := url.Values{} 541 542 query.Set("source", "branch") 543 query.Set("sourceBranch", pushedBranch) 544 query.Set("targetBranch", defaultBranch) 545 546 basePath, err := url.JoinPath(appviewURL, user, repoName, "pulls", "new") 547 if err != nil { 548 return "", err 549 } 550 pullURL := basePath + "?" + query.Encode() 551 return pullURL, nil 552} 553 554func (h *InternalHandle) createForkPullURL(appviewURL, remote, repoDid, pushedBranch, defaultBranch string) (string, error) { 555 query := url.Values{} 556 557 query.Set("fork", repoDid) 558 query.Set("source", "fork") 559 query.Set("sourceBranch", pushedBranch) 560 query.Set("targetBranch", defaultBranch) 561 562 repoPath, err := h.getRemoteOwnerRepoNamePath(remote) 563 if err != nil { 564 return "", err 565 } 566 567 basePath, err := url.JoinPath(appviewURL, repoPath, "pulls", "new") 568 if err != nil { 569 return "", err 570 } 571 pullURL := basePath + "?" + query.Encode() 572 return pullURL, nil 573} 574 575func (h *InternalHandle) getRemoteOwnerRepoNamePath(remote string) (string, error) { 576 u, err := url.Parse(remote) 577 if err != nil { 578 return "", fmt.Errorf("invalid remote: %w", err) 579 } 580 581 if u.Scheme != "file" { 582 return u.Path, nil 583 } 584 585 repoDid := path.Base(u.String()) 586 587 owner, name, err := h.db.GetRepoKeyOwner(repoDid) 588 if err != nil { 589 return "", err 590 } 591 592 return fmt.Sprintf("%s/%s", owner, name), nil 593} 594 595func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier, res *idresolver.Resolver) http.Handler { 596 r := chi.NewRouter() 597 l := log.FromContext(ctx) 598 l = log.SubLogger(l, "internal") 599 600 h := InternalHandle{ 601 db: db, 602 c: c, 603 e: e, 604 l: l, 605 n: n, 606 res: res, 607 } 608 609 r.Get("/push-allowed", h.PushAllowed) 610 r.Get("/keys", h.InternalKeys) 611 r.Get("/guard", h.Guard) 612 r.Post("/hooks/post-receive", h.PostReceiveHook) 613 r.Mount("/debug", middleware.Profiler()) 614 615 return r 616}