Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
11 kB · 434 lines
Go
at ci
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435package knotserver
import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "path/filepath" "strings"
securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-git/go-git/v5/plumbing" "tangled.org/core/api/tangled" "tangled.org/core/hook" "tangled.org/core/idresolver" "tangled.org/core/knotserver/config" "tangled.org/core/knotserver/db" "tangled.org/core/knotserver/git" "tangled.org/core/log" "tangled.org/core/notifier" "tangled.org/core/rbac" "tangled.org/core/workflow")
type InternalHandle struct { db *db.DB c *config.Config e *rbac.Enforcer l *slog.Logger n *notifier.Notifier res *idresolver.Resolver}
func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { user := r.URL.Query().Get("user") repo := r.URL.Query().Get("repo")
if user == "" || repo == "" { w.WriteHeader(http.StatusBadRequest) return }
ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) if err != nil || !ok { w.WriteHeader(http.StatusForbidden) return }
w.WriteHeader(http.StatusNoContent)}
func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { keys, err := h.db.GetAllPublicKeys() if err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return }
data := make([]map[string]interface{}, 0) for _, key := range keys { j := key.JSON() data = append(data, j) } writeJSON(w, data)}
// response in text/plain format// the body will be qualified repository path on success/push-denied// or an error message when process failedfunc (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { l := h.l.With("handler", "PostReceiveHook")
var ( incomingUser = r.URL.Query().Get("user") repo = r.URL.Query().Get("repo") gitCommand = r.URL.Query().Get("gitCmd") )
if incomingUser == "" || repo == "" || gitCommand == "" { w.WriteHeader(http.StatusBadRequest) l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) fmt.Fprintln(w, "invalid internal request") return }
// did:foo/repo-name or // handle/repo-name or // any of the above with a leading slash (/) components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") l.Info("command components", "components", components)
if len(components) != 2 { w.WriteHeader(http.StatusBadRequest) l.Error("invalid repo format", "components", components) fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") return } repoOwner := components[0] repoName := components[1]
resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { l.Error("Error resolving handle", "handle", repoOwner, "err", err) w.WriteHeader(http.StatusInternalServerError) fmt.Fprintf(w, "error resolving handle: invalid handle\n") return } repoOwnerDid := repoOwnerIdent.DID.String()
qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
if gitCommand == "git-receive-pack" { ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) if err != nil || !ok { w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, repo) return } }
w.WriteHeader(http.StatusOK) fmt.Fprint(w, qualifiedRepo)}
type PushOptions struct { skipCi bool verboseCi bool}
func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { l := h.l.With("handler", "PostReceiveHook")
gitAbsoluteDir := r.Header.Get("X-Git-Dir") gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) if err != nil { l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) return }
parts := strings.SplitN(gitRelativeDir, "/", 2) if len(parts) != 2 { l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) return } repoDid := parts[0] repoName := parts[1]
gitUserDid := r.Header.Get("X-Git-User-Did")
lines, err := git.ParsePostReceive(r.Body) if err != nil { l.Error("failed to parse post-receive payload", "err", err) // non-fatal }
// extract any push options pushOptionsRaw := r.Header.Values("X-Git-Push-Option") pushOptions := PushOptions{} for _, option := range pushOptionsRaw { if option == "skip-ci" || option == "ci-skip" { pushOptions.skipCi = true } if option == "verbose-ci" || option == "ci-verbose" { pushOptions.verboseCi = true } }
resp := hook.HookResponse{ Messages: make([]string, 0), }
for _, line := range lines { err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) if err != nil { l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) // non-fatal }
err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) if err != nil { l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) // non-fatal }
err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) if err != nil { l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) // non-fatal } }
writeJSON(w, resp)}
func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) if err != nil { return err }
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) if err != nil { return err }
gr, err := git.Open(repoPath, line.Ref) if err != nil { return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) }
var errs error meta, err := gr.RefUpdateMeta(line) errors.Join(errs, err)
metaRecord := meta.AsRecord()
refUpdate := tangled.GitRefUpdate{ OldSha: line.OldSha.String(), NewSha: line.NewSha.String(), Ref: line.Ref, CommitterDid: gitUserDid, RepoDid: repoDid, RepoName: repoName, Meta: &metaRecord, } eventJson, err := json.Marshal(refUpdate) if err != nil { return err }
event := db.Event{ Rkey: TID(), Nsid: tangled.GitRefUpdateNSID, EventJson: string(eventJson), }
return errors.Join(errs, h.db.InsertEvent(event, h.n))}
func (h *InternalHandle) triggerPipeline( clientMsgs *[]string, line git.PostReceiveLine, gitUserDid string, repoDid string, repoName string, pushOptions PushOptions,) error { if pushOptions.skipCi { return nil }
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) if err != nil { return err }
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) if err != nil { return err }
gr, err := git.Open(repoPath, line.Ref) if err != nil { return err }
workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) if err != nil { return err }
var pipeline workflow.RawPipeline for _, e := range workflowDir { if !e.IsFile() { continue }
fpath := filepath.Join(workflow.WorkflowDir, e.Name) contents, err := gr.RawContent(fpath) if err != nil { continue }
pipeline = append(pipeline, workflow.RawWorkflow{ Name: e.Name, Contents: contents, }) }
trigger := tangled.Pipeline_PushTriggerData{ Ref: line.Ref, OldSha: line.OldSha.String(), NewSha: line.NewSha.String(), }
compiler := workflow.Compiler{ Trigger: tangled.Pipeline_TriggerMetadata{ Kind: string(workflow.TriggerKindPush), Push: &trigger, Repo: &tangled.Pipeline_TriggerRepo{ Did: repoDid, Knot: h.c.Server.Hostname, Repo: repoName, }, }, }
cp := compiler.Compile(compiler.Parse(pipeline)) eventJson, err := json.Marshal(cp) if err != nil { return err }
for _, e := range compiler.Diagnostics.Errors { *clientMsgs = append(*clientMsgs, e.String()) }
if pushOptions.verboseCi { if compiler.Diagnostics.IsEmpty() { *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") }
for _, w := range compiler.Diagnostics.Warnings { *clientMsgs = append(*clientMsgs, w.String()) } }
// do not run empty pipelines if cp.Workflows == nil { return nil }
event := db.Event{ Rkey: TID(), Nsid: tangled.PipelineNSID, EventJson: string(eventJson), }
return h.db.InsertEvent(event, h.n)}
func (h *InternalHandle) emitCompareLink( clientMsgs *[]string, line git.PostReceiveLine, repoDid string, repoName string,) error { // this is a second push to a branch, don't reply with the link again if !line.OldSha.IsZero() { return nil }
// the ref was not updated to a new hash, don't reply with the link // // NOTE: do we need this? if line.NewSha.String() == line.OldSha.String() { return nil }
pushedRef := plumbing.ReferenceName(line.Ref)
userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) user := repoDid if err == nil { user = userIdent.Handle.String() }
didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) if err != nil { return err }
repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) if err != nil { return err }
gr, err := git.PlainOpen(repoPath) if err != nil { return err }
defaultBranch, err := gr.FindMainBranch() if err != nil { return err }
// pushing to default branch if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { return nil }
// pushing a tag, don't prompt the user the open a PR if pushedRef.IsTag() { return nil }
ZWS := "\u200B" *clientMsgs = append(*clientMsgs, ZWS) *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) *clientMsgs = append(*clientMsgs, ZWS) return nil}
func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { r := chi.NewRouter() l := log.FromContext(ctx) l = log.SubLogger(l, "internal") res := idresolver.DefaultResolver(c.Server.PlcUrl)
h := InternalHandle{ db, c, e, l, n, res, }
r.Get("/push-allowed", h.PushAllowed) r.Get("/keys", h.InternalKeys) r.Get("/guard", h.Guard) r.Post("/hooks/post-receive", h.PostReceiveHook) r.Mount("/debug", middleware.Profiler())
return r}