Monorepo for Tangled
at push-pkuzytwlwptp 463 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 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, ownerDid, repoName, 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, _ = securejoin.SecureJoin(ownerDid, repoName) 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 qualified, _ := securejoin.SecureJoin(repoOwnerIdent.DID.String(), components[1]) 126 rbacResource = qualified 127 diskRelative = qualified 128 129 default: 130 w.WriteHeader(http.StatusBadRequest) 131 l.Error("invalid repo format", "components", components) 132 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>") 133 return 134 } 135 136 if gitCommand == "git-receive-pack" { 137 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 138 if err != nil || !ok { 139 w.WriteHeader(http.StatusForbidden) 140 fmt.Fprint(w, repo) 141 return 142 } 143 } 144 145 w.WriteHeader(http.StatusOK) 146 fmt.Fprint(w, diskRelative) 147} 148 149type PushOptions struct { 150 skipCi bool 151 verboseCi bool 152} 153 154func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 155 l := h.l.With("handler", "PostReceiveHook") 156 157 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 158 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 159 if err != nil { 160 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 161 w.WriteHeader(http.StatusInternalServerError) 162 return 163 } 164 165 parts := strings.SplitN(gitRelativeDir, "/", 2) 166 167 var ownerDid, repoName string 168 switch { 169 case len(parts) == 2: 170 ownerDid = parts[0] 171 repoName = parts[1] 172 case len(parts) == 1 && strings.HasPrefix(parts[0], "did:"): 173 var err error 174 ownerDid, repoName, err = h.db.GetRepoKeyOwner(parts[0]) 175 if err != nil { 176 l.Error("failed to resolve repo DID from git dir", "repoDid", parts[0], "err", err) 177 w.WriteHeader(http.StatusBadRequest) 178 return 179 } 180 default: 181 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 182 w.WriteHeader(http.StatusBadRequest) 183 return 184 } 185 186 gitUserDid := r.Header.Get("X-Git-User-Did") 187 188 lines, err := git.ParsePostReceive(r.Body) 189 if err != nil { 190 l.Error("failed to parse post-receive payload", "err", err) 191 // non-fatal 192 } 193 194 // extract any push options 195 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 196 pushOptions := PushOptions{} 197 for _, option := range pushOptionsRaw { 198 if option == "skip-ci" || option == "ci-skip" { 199 pushOptions.skipCi = true 200 } 201 if option == "verbose-ci" || option == "ci-verbose" { 202 pushOptions.verboseCi = true 203 } 204 } 205 206 resp := hook.HookResponse{ 207 Messages: make([]string, 0), 208 } 209 210 for _, line := range lines { 211 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName) 212 if err != nil { 213 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 214 // non-fatal 215 } 216 217 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName) 218 if err != nil { 219 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 220 // non-fatal 221 } 222 223 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, pushOptions) 224 if err != nil { 225 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 226 // non-fatal 227 } 228 } 229 230 writeJSON(w, resp) 231} 232 233func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName string) error { 234 repoPath, _, resolveErr := h.db.ResolveRepoOnDisk(h.c.Repo.ScanPath, ownerDid, repoName) 235 if resolveErr != nil { 236 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 237 } 238 239 gr, err := git.Open(repoPath, line.Ref) 240 if err != nil { 241 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 242 } 243 244 meta, err := gr.RefUpdateMeta(line) 245 if err != nil { 246 return fmt.Errorf("failed to get ref update metadata: %w", err) 247 } 248 249 metaRecord := meta.AsRecord() 250 251 refUpdate := tangled.GitRefUpdate{ 252 OldSha: line.OldSha.String(), 253 NewSha: line.NewSha.String(), 254 Ref: line.Ref, 255 CommitterDid: gitUserDid, 256 OwnerDid: ownerDid, 257 RepoName: repoName, 258 Meta: &metaRecord, 259 } 260 261 if repoDidVal, lookupErr := h.db.GetRepoDid(ownerDid, repoName); lookupErr == nil { 262 refUpdate.RepoDid = &repoDidVal 263 } 264 265 eventJson, err := json.Marshal(refUpdate) 266 if err != nil { 267 return err 268 } 269 270 event := db.Event{ 271 Rkey: TID(), 272 Nsid: tangled.GitRefUpdateNSID, 273 EventJson: string(eventJson), 274 } 275 276 return h.db.InsertEvent(event, h.n) 277} 278 279func (h *InternalHandle) triggerPipeline( 280 clientMsgs *[]string, 281 line git.PostReceiveLine, 282 gitUserDid string, 283 ownerDid string, 284 repoName string, 285 pushOptions PushOptions, 286) error { 287 if pushOptions.skipCi { 288 return nil 289 } 290 291 repoPath, _, resolveErr := h.db.ResolveRepoOnDisk(h.c.Repo.ScanPath, ownerDid, repoName) 292 if resolveErr != nil { 293 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 294 } 295 296 gr, err := git.Open(repoPath, line.Ref) 297 if err != nil { 298 return err 299 } 300 301 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 302 if err != nil { 303 return err 304 } 305 306 var pipeline workflow.RawPipeline 307 for _, e := range workflowDir { 308 if !e.IsFile() { 309 continue 310 } 311 312 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 313 contents, err := gr.RawContent(fpath) 314 if err != nil { 315 continue 316 } 317 318 pipeline = append(pipeline, workflow.RawWorkflow{ 319 Name: e.Name, 320 Contents: contents, 321 }) 322 } 323 324 trigger := tangled.Pipeline_PushTriggerData{ 325 Ref: line.Ref, 326 OldSha: line.OldSha.String(), 327 NewSha: line.NewSha.String(), 328 } 329 330 triggerRepo := &tangled.Pipeline_TriggerRepo{ 331 Did: ownerDid, 332 Knot: h.c.Server.Hostname, 333 Repo: repoName, 334 } 335 336 if repoDidVal, lookupErr := h.db.GetRepoDid(ownerDid, repoName); lookupErr == nil { 337 triggerRepo.RepoDid = &repoDidVal 338 } 339 340 compiler := workflow.Compiler{ 341 Trigger: tangled.Pipeline_TriggerMetadata{ 342 Kind: string(workflow.TriggerKindPush), 343 Push: &trigger, 344 Repo: triggerRepo, 345 }, 346 } 347 348 cp := compiler.Compile(compiler.Parse(pipeline)) 349 eventJson, err := json.Marshal(cp) 350 if err != nil { 351 return err 352 } 353 354 for _, e := range compiler.Diagnostics.Errors { 355 *clientMsgs = append(*clientMsgs, e.String()) 356 } 357 358 if pushOptions.verboseCi { 359 if compiler.Diagnostics.IsEmpty() { 360 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 361 } 362 363 for _, w := range compiler.Diagnostics.Warnings { 364 *clientMsgs = append(*clientMsgs, w.String()) 365 } 366 } 367 368 // do not run empty pipelines 369 if cp.Workflows == nil { 370 return nil 371 } 372 373 event := db.Event{ 374 Rkey: TID(), 375 Nsid: tangled.PipelineNSID, 376 EventJson: string(eventJson), 377 } 378 379 return h.db.InsertEvent(event, h.n) 380} 381 382func (h *InternalHandle) emitCompareLink( 383 clientMsgs *[]string, 384 line git.PostReceiveLine, 385 ownerDid string, 386 repoName string, 387) error { 388 // this is a second push to a branch, don't reply with the link again 389 if !line.OldSha.IsZero() { 390 return nil 391 } 392 393 // the ref was not updated to a new hash, don't reply with the link 394 // 395 // NOTE: do we need this? 396 if line.NewSha.String() == line.OldSha.String() { 397 return nil 398 } 399 400 pushedRef := plumbing.ReferenceName(line.Ref) 401 402 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 403 user := ownerDid 404 if err == nil { 405 user = userIdent.Handle.String() 406 } 407 408 repoPath, _, resolveErr := h.db.ResolveRepoOnDisk(h.c.Repo.ScanPath, ownerDid, repoName) 409 if resolveErr != nil { 410 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 411 } 412 413 gr, err := git.PlainOpen(repoPath) 414 if err != nil { 415 return err 416 } 417 418 defaultBranch, err := gr.FindMainBranch() 419 if err != nil { 420 return err 421 } 422 423 // pushing to default branch 424 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 425 return nil 426 } 427 428 // pushing a tag, don't prompt the user the open a PR 429 if pushedRef.IsTag() { 430 return nil 431 } 432 433 ZWS := "\u200B" 434 *clientMsgs = append(*clientMsgs, ZWS) 435 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 436 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 437 *clientMsgs = append(*clientMsgs, ZWS) 438 return nil 439} 440 441func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 442 r := chi.NewRouter() 443 l := log.FromContext(ctx) 444 l = log.SubLogger(l, "internal") 445 res := idresolver.DefaultResolver(c.Server.PlcUrl) 446 447 h := InternalHandle{ 448 db, 449 c, 450 e, 451 l, 452 n, 453 res, 454 } 455 456 r.Get("/push-allowed", h.PushAllowed) 457 r.Get("/keys", h.InternalKeys) 458 r.Get("/guard", h.Guard) 459 r.Post("/hooks/post-receive", h.PostReceiveHook) 460 r.Mount("/debug", middleware.Profiler()) 461 462 return r 463}