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