forked from tangled.org/core
this repo has no description
at master 6.7 kB view raw
1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "path/filepath" 11 "strings" 12 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/hook" 18 "tangled.sh/tangled.sh/core/knotserver/config" 19 "tangled.sh/tangled.sh/core/knotserver/db" 20 "tangled.sh/tangled.sh/core/knotserver/git" 21 "tangled.sh/tangled.sh/core/notifier" 22 "tangled.sh/tangled.sh/core/rbac" 23 "tangled.sh/tangled.sh/core/workflow" 24) 25 26type InternalHandle struct { 27 db *db.DB 28 c *config.Config 29 e *rbac.Enforcer 30 l *slog.Logger 31 n *notifier.Notifier 32} 33 34func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 35 user := r.URL.Query().Get("user") 36 repo := r.URL.Query().Get("repo") 37 38 if user == "" || repo == "" { 39 w.WriteHeader(http.StatusBadRequest) 40 return 41 } 42 43 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 44 if err != nil || !ok { 45 w.WriteHeader(http.StatusForbidden) 46 return 47 } 48 49 w.WriteHeader(http.StatusNoContent) 50} 51 52func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 53 keys, err := h.db.GetAllPublicKeys() 54 if err != nil { 55 writeError(w, err.Error(), http.StatusInternalServerError) 56 return 57 } 58 59 data := make([]map[string]interface{}, 0) 60 for _, key := range keys { 61 j := key.JSON() 62 data = append(data, j) 63 } 64 writeJSON(w, data) 65} 66 67type PushOptions struct { 68 skipCi bool 69 verboseCi bool 70} 71 72func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 73 l := h.l.With("handler", "PostReceiveHook") 74 75 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 76 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 77 if err != nil { 78 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 79 return 80 } 81 82 parts := strings.SplitN(gitRelativeDir, "/", 2) 83 if len(parts) != 2 { 84 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 85 return 86 } 87 repoDid := parts[0] 88 repoName := parts[1] 89 90 gitUserDid := r.Header.Get("X-Git-User-Did") 91 92 lines, err := git.ParsePostReceive(r.Body) 93 if err != nil { 94 l.Error("failed to parse post-receive payload", "err", err) 95 // non-fatal 96 } 97 98 // extract any push options 99 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 100 pushOptions := PushOptions{} 101 for _, option := range pushOptionsRaw { 102 if option == "skip-ci" || option == "ci-skip" { 103 pushOptions.skipCi = true 104 } 105 if option == "verbose-ci" || option == "ci-verbose" { 106 pushOptions.verboseCi = true 107 } 108 } 109 110 resp := hook.HookResponse{ 111 Messages: make([]string, 0), 112 } 113 114 for _, line := range lines { 115 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 116 if err != nil { 117 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 118 // non-fatal 119 } 120 121 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 122 if err != nil { 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 124 // non-fatal 125 } 126 } 127 128 writeJSON(w, resp) 129} 130 131func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 132 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 133 if err != nil { 134 return err 135 } 136 137 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 138 if err != nil { 139 return err 140 } 141 142 gr, err := git.Open(repoPath, line.Ref) 143 if err != nil { 144 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 145 } 146 147 var errs error 148 meta, err := gr.RefUpdateMeta(line) 149 errors.Join(errs, err) 150 151 metaRecord := meta.AsRecord() 152 153 refUpdate := tangled.GitRefUpdate{ 154 OldSha: line.OldSha.String(), 155 NewSha: line.NewSha.String(), 156 Ref: line.Ref, 157 CommitterDid: gitUserDid, 158 RepoDid: repoDid, 159 RepoName: repoName, 160 Meta: &metaRecord, 161 } 162 eventJson, err := json.Marshal(refUpdate) 163 if err != nil { 164 return err 165 } 166 167 event := db.Event{ 168 Rkey: TID(), 169 Nsid: tangled.GitRefUpdateNSID, 170 EventJson: string(eventJson), 171 } 172 173 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 174} 175 176func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 177 if pushOptions.skipCi { 178 return nil 179 } 180 181 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 182 if err != nil { 183 return err 184 } 185 186 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 187 if err != nil { 188 return err 189 } 190 191 gr, err := git.Open(repoPath, line.Ref) 192 if err != nil { 193 return err 194 } 195 196 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 197 if err != nil { 198 return err 199 } 200 201 var pipeline workflow.RawPipeline 202 for _, e := range workflowDir { 203 if !e.IsFile { 204 continue 205 } 206 207 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 208 contents, err := gr.RawContent(fpath) 209 if err != nil { 210 continue 211 } 212 213 pipeline = append(pipeline, workflow.RawWorkflow{ 214 Name: e.Name, 215 Contents: contents, 216 }) 217 } 218 219 trigger := tangled.Pipeline_PushTriggerData{ 220 Ref: line.Ref, 221 OldSha: line.OldSha.String(), 222 NewSha: line.NewSha.String(), 223 } 224 225 compiler := workflow.Compiler{ 226 Trigger: tangled.Pipeline_TriggerMetadata{ 227 Kind: string(workflow.TriggerKindPush), 228 Push: &trigger, 229 Repo: &tangled.Pipeline_TriggerRepo{ 230 Did: repoDid, 231 Knot: h.c.Server.Hostname, 232 Repo: repoName, 233 }, 234 }, 235 } 236 237 cp := compiler.Compile(compiler.Parse(pipeline)) 238 eventJson, err := json.Marshal(cp) 239 if err != nil { 240 return err 241 } 242 243 for _, e := range compiler.Diagnostics.Errors { 244 *clientMsgs = append(*clientMsgs, e.String()) 245 } 246 247 if pushOptions.verboseCi { 248 if compiler.Diagnostics.IsEmpty() { 249 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 250 } 251 252 for _, w := range compiler.Diagnostics.Warnings { 253 *clientMsgs = append(*clientMsgs, w.String()) 254 } 255 } 256 257 // do not run empty pipelines 258 if cp.Workflows == nil { 259 return nil 260 } 261 262 event := db.Event{ 263 Rkey: TID(), 264 Nsid: tangled.PipelineNSID, 265 EventJson: string(eventJson), 266 } 267 268 return h.db.InsertEvent(event, h.n) 269} 270 271func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 272 r := chi.NewRouter() 273 274 h := InternalHandle{ 275 db, 276 c, 277 e, 278 l, 279 n, 280 } 281 282 r.Get("/push-allowed", h.PushAllowed) 283 r.Get("/keys", h.InternalKeys) 284 r.Post("/hooks/post-receive", h.PostReceiveHook) 285 r.Mount("/debug", middleware.Profiler()) 286 287 return r 288}