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}