Monorepo for Tangled
at master 470 lines 12 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 "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}