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