forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

Changed files
+5675 -2262
api
appview
docs
hook
knotserver
lexicons
nix
orm
rbac2
sets
spindle
tap
types
+34
api/tangled/pipelinecancelPipeline.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.pipeline.cancelPipeline 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + PipelineCancelPipelineNSID = "sh.tangled.pipeline.cancelPipeline" 15 + ) 16 + 17 + // PipelineCancelPipeline_Input is the input argument to a sh.tangled.pipeline.cancelPipeline call. 18 + type PipelineCancelPipeline_Input struct { 19 + // pipeline: pipeline at-uri 20 + Pipeline string `json:"pipeline" cborgen:"pipeline"` 21 + // repo: repo at-uri, spindle can't resolve repo from pipeline at-uri yet 22 + Repo string `json:"repo" cborgen:"repo"` 23 + // workflow: workflow name 24 + Workflow string `json:"workflow" cborgen:"workflow"` 25 + } 26 + 27 + // PipelineCancelPipeline calls the XRPC method "sh.tangled.pipeline.cancelPipeline". 28 + func PipelineCancelPipeline(ctx context.Context, c util.LexClient, input *PipelineCancelPipeline_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.pipeline.cancelPipeline", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+2
appview/db/follow.go
··· 167 167 if err != nil { 168 168 return nil, err 169 169 } 170 + defer rows.Close() 171 + 170 172 for rows.Next() { 171 173 var follow models.Follow 172 174 var followedAt string
+1
appview/db/issues.go
··· 452 452 if err != nil { 453 453 return nil, err 454 454 } 455 + defer rows.Close() 455 456 456 457 for rows.Next() { 457 458 var comment models.IssueComment
+1 -1
appview/db/language.go
··· 28 28 whereClause, 29 29 ) 30 30 rows, err := e.Query(query, args...) 31 - 32 31 if err != nil { 33 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 34 33 } 34 + defer rows.Close() 35 35 36 36 var langs []models.RepoLanguage 37 37 for rows.Next() {
+6 -6
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/orm" 11 12 ) ··· 216 217 } 217 218 defer rows.Close() 218 219 219 - pipelines := make(map[string]models.Pipeline) 220 + pipelines := make(map[syntax.ATURI]models.Pipeline) 220 221 for rows.Next() { 221 222 var p models.Pipeline 222 223 var t models.Trigger ··· 253 254 p.Trigger = &t 254 255 p.Statuses = make(map[string]models.WorkflowStatus) 255 256 256 - k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 257 - pipelines[k] = p 257 + pipelines[p.AtUri()] = p 258 258 } 259 259 260 260 // get all statuses ··· 314 314 return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err) 315 315 } 316 316 317 - key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey) 317 + pipelineAt := ps.PipelineAt() 318 318 319 319 // extract 320 - pipeline, ok := pipelines[key] 320 + pipeline, ok := pipelines[pipelineAt] 321 321 if !ok { 322 322 continue 323 323 } ··· 331 331 332 332 // reassign 333 333 pipeline.Statuses[ps.Workflow] = statuses 334 - pipelines[key] = pipeline 334 + pipelines[pipelineAt] = pipeline 335 335 } 336 336 337 337 var all []models.Pipeline
+5
appview/db/profile.go
··· 230 230 if err != nil { 231 231 return nil, err 232 232 } 233 + defer rows.Close() 233 234 234 235 profileMap := make(map[string]*models.Profile) 235 236 for rows.Next() { ··· 270 271 if err != nil { 271 272 return nil, err 272 273 } 274 + defer rows.Close() 275 + 273 276 idxs := make(map[string]int) 274 277 for did := range profileMap { 275 278 idxs[did] = 0 ··· 290 293 if err != nil { 291 294 return nil, err 292 295 } 296 + defer rows.Close() 297 + 293 298 idxs = make(map[string]int) 294 299 for did := range profileMap { 295 300 idxs[did] = 0
+1
appview/db/registration.go
··· 38 38 if err != nil { 39 39 return nil, err 40 40 } 41 + defer rows.Close() 41 42 42 43 for rows.Next() { 43 44 var createdAt string
+12 -1
appview/db/repos.go
··· 56 56 limitClause, 57 57 ) 58 58 rows, err := e.Query(repoQuery, args...) 59 - 60 59 if err != nil { 61 60 return nil, fmt.Errorf("failed to execute repo query: %w ", err) 62 61 } 62 + defer rows.Close() 63 63 64 64 for rows.Next() { 65 65 var repo models.Repo ··· 128 128 if err != nil { 129 129 return nil, fmt.Errorf("failed to execute labels query: %w ", err) 130 130 } 131 + defer rows.Close() 132 + 131 133 for rows.Next() { 132 134 var repoat, labelat string 133 135 if err := rows.Scan(&repoat, &labelat); err != nil { ··· 156 158 from repo_languages 157 159 where repo_at in (%s) 158 160 and is_default_ref = 1 161 + and language <> '' 159 162 ) 160 163 where rn = 1 161 164 `, ··· 165 168 if err != nil { 166 169 return nil, fmt.Errorf("failed to execute lang query: %w ", err) 167 170 } 171 + defer rows.Close() 172 + 168 173 for rows.Next() { 169 174 var repoat, lang string 170 175 if err := rows.Scan(&repoat, &lang); err != nil { ··· 191 196 if err != nil { 192 197 return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 193 198 } 199 + defer rows.Close() 200 + 194 201 for rows.Next() { 195 202 var repoat string 196 203 var count int ··· 220 227 if err != nil { 221 228 return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 222 229 } 230 + defer rows.Close() 231 + 223 232 for rows.Next() { 224 233 var repoat string 225 234 var open, closed int ··· 261 270 if err != nil { 262 271 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 263 272 } 273 + defer rows.Close() 274 + 264 275 for rows.Next() { 265 276 var repoat string 266 277 var open, merged, closed, deleted int
+1
appview/db/star.go
··· 165 165 if err != nil { 166 166 return nil, err 167 167 } 168 + defer rows.Close() 168 169 169 170 starMap := make(map[string][]models.Star) 170 171 for rows.Next() {
+10
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 + "fmt" 4 5 "slices" 5 6 "time" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/go-git/go-git/v5/plumbing" 10 + "tangled.org/core/api/tangled" 9 11 spindle "tangled.org/core/spindle/models" 10 12 "tangled.org/core/workflow" 11 13 ) ··· 23 25 // populate when querying for reverse mappings 24 26 Trigger *Trigger 25 27 Statuses map[string]WorkflowStatus 28 + } 29 + 30 + func (p *Pipeline) AtUri() syntax.ATURI { 31 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey)) 26 32 } 27 33 28 34 type WorkflowStatus struct { ··· 128 134 Error *string 129 135 ExitCode int 130 136 } 137 + 138 + func (ps *PipelineStatus) PipelineAt() syntax.ATURI { 139 + return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey)) 140 + }
+67 -57
appview/notify/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "log" 6 - "maps" 7 6 "slices" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 13 12 "tangled.org/core/appview/notify" 14 13 "tangled.org/core/idresolver" 15 14 "tangled.org/core/orm" 15 + "tangled.org/core/sets" 16 16 ) 17 17 18 18 const ( 19 - maxMentions = 5 19 + maxMentions = 8 20 20 ) 21 21 22 22 type databaseNotifier struct { ··· 50 50 } 51 51 52 52 actorDid := syntax.DID(star.Did) 53 - recipients := []syntax.DID{syntax.DID(repo.Did)} 53 + recipients := sets.Singleton(syntax.DID(repo.Did)) 54 54 eventType := models.NotificationTypeRepoStarred 55 55 entityType := "repo" 56 56 entityId := star.RepoAt.String() ··· 75 75 } 76 76 77 77 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 - 79 - // build the recipients list 80 - // - owner of the repo 81 - // - collaborators in the repo 82 - var recipients []syntax.DID 83 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 84 78 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 85 79 if err != nil { 86 80 log.Printf("failed to fetch collaborators: %v", err) 87 81 return 88 82 } 83 + 84 + // build the recipients list 85 + // - owner of the repo 86 + // - collaborators in the repo 87 + // - remove users already mentioned 88 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 89 for _, c := range collaborators { 90 - recipients = append(recipients, c.SubjectDid) 90 + recipients.Insert(c.SubjectDid) 91 + } 92 + for _, m := range mentions { 93 + recipients.Remove(m) 91 94 } 92 95 93 96 actorDid := syntax.DID(issue.Did) ··· 109 112 ) 110 113 n.notifyEvent( 111 114 actorDid, 112 - mentions, 115 + sets.Collect(slices.Values(mentions)), 113 116 models.NotificationTypeUserMentioned, 114 117 entityType, 115 118 entityId, ··· 131 134 } 132 135 issue := issues[0] 133 136 134 - var recipients []syntax.DID 135 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 137 + // built the recipients list: 138 + // - the owner of the repo 139 + // - | if the comment is a reply -> everybody on that thread 140 + // | if the comment is a top level -> just the issue owner 141 + // - remove mentioned users from the recipients list 142 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 136 143 137 144 if comment.IsReply() { 138 145 // if this comment is a reply, then notify everybody in that thread 139 146 parentAtUri := *comment.ReplyTo 140 - allThreads := issue.CommentList() 141 147 142 148 // find the parent thread, and add all DIDs from here to the recipient list 143 - for _, t := range allThreads { 149 + for _, t := range issue.CommentList() { 144 150 if t.Self.AtUri().String() == parentAtUri { 145 - recipients = append(recipients, t.Participants()...) 151 + for _, p := range t.Participants() { 152 + recipients.Insert(p) 153 + } 146 154 } 147 155 } 148 156 } else { 149 157 // not a reply, notify just the issue author 150 - recipients = append(recipients, syntax.DID(issue.Did)) 158 + recipients.Insert(syntax.DID(issue.Did)) 159 + } 160 + 161 + for _, m := range mentions { 162 + recipients.Remove(m) 151 163 } 152 164 153 165 actorDid := syntax.DID(comment.Did) ··· 169 181 ) 170 182 n.notifyEvent( 171 183 actorDid, 172 - mentions, 184 + sets.Collect(slices.Values(mentions)), 173 185 models.NotificationTypeUserMentioned, 174 186 entityType, 175 187 entityId, ··· 185 197 186 198 func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 187 199 actorDid := syntax.DID(follow.UserDid) 188 - recipients := []syntax.DID{syntax.DID(follow.SubjectDid)} 200 + recipients := sets.Singleton(syntax.DID(follow.SubjectDid)) 189 201 eventType := models.NotificationTypeFollowed 190 202 entityType := "follow" 191 203 entityId := follow.UserDid ··· 213 225 log.Printf("NewPull: failed to get repos: %v", err) 214 226 return 215 227 } 216 - 217 - // build the recipients list 218 - // - owner of the repo 219 - // - collaborators in the repo 220 - var recipients []syntax.DID 221 - recipients = append(recipients, syntax.DID(repo.Did)) 222 228 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 223 229 if err != nil { 224 230 log.Printf("failed to fetch collaborators: %v", err) 225 231 return 226 232 } 233 + 234 + // build the recipients list 235 + // - owner of the repo 236 + // - collaborators in the repo 237 + recipients := sets.Singleton(syntax.DID(repo.Did)) 227 238 for _, c := range collaborators { 228 - recipients = append(recipients, c.SubjectDid) 239 + recipients.Insert(c.SubjectDid) 229 240 } 230 241 231 242 actorDid := syntax.DID(pull.OwnerDid) ··· 268 279 // build up the recipients list: 269 280 // - repo owner 270 281 // - all pull participants 271 - var recipients []syntax.DID 272 - recipients = append(recipients, syntax.DID(repo.Did)) 282 + // - remove those already mentioned 283 + recipients := sets.Singleton(syntax.DID(repo.Did)) 273 284 for _, p := range pull.Participants() { 274 - recipients = append(recipients, syntax.DID(p)) 285 + recipients.Insert(syntax.DID(p)) 286 + } 287 + for _, m := range mentions { 288 + recipients.Remove(m) 275 289 } 276 290 277 291 actorDid := syntax.DID(comment.OwnerDid) ··· 295 309 ) 296 310 n.notifyEvent( 297 311 actorDid, 298 - mentions, 312 + sets.Collect(slices.Values(mentions)), 299 313 models.NotificationTypeUserMentioned, 300 314 entityType, 301 315 entityId, ··· 322 336 } 323 337 324 338 func (n *databaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { 325 - // build up the recipients list: 326 - // - repo owner 327 - // - repo collaborators 328 - // - all issue participants 329 - var recipients []syntax.DID 330 - recipients = append(recipients, syntax.DID(issue.Repo.Did)) 331 339 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 332 340 if err != nil { 333 341 log.Printf("failed to fetch collaborators: %v", err) 334 342 return 335 343 } 344 + 345 + // build up the recipients list: 346 + // - repo owner 347 + // - repo collaborators 348 + // - all issue participants 349 + recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 336 350 for _, c := range collaborators { 337 - recipients = append(recipients, c.SubjectDid) 351 + recipients.Insert(c.SubjectDid) 338 352 } 339 353 for _, p := range issue.Participants() { 340 - recipients = append(recipients, syntax.DID(p)) 354 + recipients.Insert(syntax.DID(p)) 341 355 } 342 356 343 357 entityType := "pull" ··· 373 387 return 374 388 } 375 389 376 - // build up the recipients list: 377 - // - repo owner 378 - // - all pull participants 379 - var recipients []syntax.DID 380 - recipients = append(recipients, syntax.DID(repo.Did)) 381 390 collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", repo.RepoAt())) 382 391 if err != nil { 383 392 log.Printf("failed to fetch collaborators: %v", err) 384 393 return 385 394 } 395 + 396 + // build up the recipients list: 397 + // - repo owner 398 + // - all pull participants 399 + recipients := sets.Singleton(syntax.DID(repo.Did)) 386 400 for _, c := range collaborators { 387 - recipients = append(recipients, c.SubjectDid) 401 + recipients.Insert(c.SubjectDid) 388 402 } 389 403 for _, p := range pull.Participants() { 390 - recipients = append(recipients, syntax.DID(p)) 404 + recipients.Insert(syntax.DID(p)) 391 405 } 392 406 393 407 entityType := "pull" ··· 423 437 424 438 func (n *databaseNotifier) notifyEvent( 425 439 actorDid syntax.DID, 426 - recipients []syntax.DID, 440 + recipients sets.Set[syntax.DID], 427 441 eventType models.NotificationType, 428 442 entityType string, 429 443 entityId string, ··· 431 445 issueId *int64, 432 446 pullId *int64, 433 447 ) { 434 - if eventType == models.NotificationTypeUserMentioned && len(recipients) > maxMentions { 435 - recipients = recipients[:maxMentions] 448 + // if the user is attempting to mention >maxMentions users, this is probably spam, do not mention anybody 449 + if eventType == models.NotificationTypeUserMentioned && recipients.Len() > maxMentions { 450 + return 436 451 } 437 - recipientSet := make(map[syntax.DID]struct{}) 438 - for _, did := range recipients { 439 - // everybody except actor themselves 440 - if did != actorDid { 441 - recipientSet[did] = struct{}{} 442 - } 443 - } 452 + 453 + recipients.Remove(actorDid) 444 454 445 455 prefMap, err := db.GetNotificationPreferences( 446 456 n.db, 447 - orm.FilterIn("user_did", slices.Collect(maps.Keys(recipientSet))), 457 + orm.FilterIn("user_did", slices.Collect(recipients.All())), 448 458 ) 449 459 if err != nil { 450 460 // failed to get prefs for users ··· 460 470 defer tx.Rollback() 461 471 462 472 // filter based on preferences 463 - for recipientDid := range recipientSet { 473 + for recipientDid := range recipients.All() { 464 474 prefs, ok := prefMap[recipientDid] 465 475 if !ok { 466 476 prefs = models.DefaultNotificationPreferences(recipientDid)
-1
appview/notify/merged_notifier.go
··· 39 39 v.Call(in) 40 40 }(n) 41 41 } 42 - wg.Wait() 43 42 } 44 43 45 44 func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
+6 -1
appview/pages/funcmap.go
··· 25 25 "github.com/dustin/go-humanize" 26 26 "github.com/go-enry/go-enry/v2" 27 27 "github.com/yuin/goldmark" 28 + emoji "github.com/yuin/goldmark-emoji" 28 29 "tangled.org/core/appview/filetree" 29 30 "tangled.org/core/appview/models" 30 31 "tangled.org/core/appview/pages/markup" ··· 261 262 }, 262 263 "description": func(text string) template.HTML { 263 264 p.rctx.RendererType = markup.RendererTypeDefault 264 - htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New()) 265 + htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New( 266 + goldmark.WithExtensions( 267 + emoji.Emoji, 268 + ), 269 + )) 265 270 sanitized := p.rctx.SanitizeDescription(htmlString) 266 271 return template.HTML(sanitized) 267 272 },
+13 -3
appview/pages/markup/extension/atlink.go
··· 35 35 return KindAt 36 36 } 37 37 38 - var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\b)`) 39 + var markdownLinkRegexp = regexp.MustCompile(`(?ms)\[.*\]\(.*\)`) 39 40 40 41 type atParser struct{} 41 42 ··· 55 56 if m == nil { 56 57 return nil 57 58 } 59 + 60 + // Check for all links in the markdown to see if the handle found is inside one 61 + linksIndexes := markdownLinkRegexp.FindAllIndex(block.Source(), -1) 62 + for _, linkMatch := range linksIndexes { 63 + if linkMatch[0] < segment.Start && segment.Start < linkMatch[1] { 64 + return nil 65 + } 66 + } 67 + 58 68 atSegment := text.NewSegment(segment.Start, segment.Start+m[1]) 59 69 block.Advance(m[1]) 60 70 node := &AtNode{} ··· 87 97 88 98 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 99 if entering { 90 - w.WriteString(`<a href="/@`) 100 + w.WriteString(`<a href="/`) 91 101 w.WriteString(n.(*AtNode).Handle) 92 - w.WriteString(`" class="mention font-bold">`) 102 + w.WriteString(`" class="mention">`) 93 103 } else { 94 104 w.WriteString("</a>") 95 105 }
+2
appview/pages/markup/markdown.go
··· 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 15 "github.com/yuin/goldmark" 16 + "github.com/yuin/goldmark-emoji" 16 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 17 18 "github.com/yuin/goldmark/ast" 18 19 "github.com/yuin/goldmark/extension" ··· 66 67 ), 67 68 callout.CalloutExtention, 68 69 textension.AtExt, 70 + emoji.Emoji, 69 71 ), 70 72 goldmark.WithParserOptions( 71 73 parser.WithAutoHeadingID(),
+121
appview/pages/markup/markdown_test.go
··· 1 + package markup 2 + 3 + import ( 4 + "bytes" 5 + "testing" 6 + ) 7 + 8 + func TestAtExtension_Rendering(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + markdown string 12 + expected string 13 + }{ 14 + { 15 + name: "renders simple at mention", 16 + markdown: "Hello @user.tngl.sh!", 17 + expected: `<p>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>!</p>`, 18 + }, 19 + { 20 + name: "renders multiple at mentions", 21 + markdown: "Hi @alice.tngl.sh and @bob.example.com", 22 + expected: `<p>Hi <a href="/alice.tngl.sh" class="mention">@alice.tngl.sh</a> and <a href="/bob.example.com" class="mention">@bob.example.com</a></p>`, 23 + }, 24 + { 25 + name: "renders at mention in parentheses", 26 + markdown: "Check this out (@user.tngl.sh)", 27 + expected: `<p>Check this out (<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>)</p>`, 28 + }, 29 + { 30 + name: "does not render email", 31 + markdown: "Contact me at test@example.com", 32 + expected: `<p>Contact me at <a href="mailto:test@example.com">test@example.com</a></p>`, 33 + }, 34 + { 35 + name: "renders at mention with hyphen", 36 + markdown: "Follow @user-name.tngl.sh", 37 + expected: `<p>Follow <a href="/user-name.tngl.sh" class="mention">@user-name.tngl.sh</a></p>`, 38 + }, 39 + { 40 + name: "renders at mention with numbers", 41 + markdown: "@user123.test456.social", 42 + expected: `<p><a href="/user123.test456.social" class="mention">@user123.test456.social</a></p>`, 43 + }, 44 + { 45 + name: "at mention at start of line", 46 + markdown: "@user.tngl.sh is cool", 47 + expected: `<p><a href="/user.tngl.sh" class="mention">@user.tngl.sh</a> is cool</p>`, 48 + }, 49 + } 50 + 51 + for _, tt := range tests { 52 + t.Run(tt.name, func(t *testing.T) { 53 + md := NewMarkdown() 54 + 55 + var buf bytes.Buffer 56 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 57 + t.Fatalf("failed to convert markdown: %v", err) 58 + } 59 + 60 + result := buf.String() 61 + if result != tt.expected+"\n" { 62 + t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, result) 63 + } 64 + }) 65 + } 66 + } 67 + 68 + func TestAtExtension_WithOtherMarkdown(t *testing.T) { 69 + tests := []struct { 70 + name string 71 + markdown string 72 + contains string 73 + }{ 74 + { 75 + name: "at mention with bold", 76 + markdown: "**Hello @user.tngl.sh**", 77 + contains: `<strong>Hello <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></strong>`, 78 + }, 79 + { 80 + name: "at mention with italic", 81 + markdown: "*Check @user.tngl.sh*", 82 + contains: `<em>Check <a href="/user.tngl.sh" class="mention">@user.tngl.sh</a></em>`, 83 + }, 84 + { 85 + name: "at mention in list", 86 + markdown: "- Item 1\n- @user.tngl.sh\n- Item 3", 87 + contains: `<a href="/user.tngl.sh" class="mention">@user.tngl.sh</a>`, 88 + }, 89 + { 90 + name: "at mention in link", 91 + markdown: "[@regnault.dev](https://regnault.dev)", 92 + contains: `<a href="https://regnault.dev">@regnault.dev</a>`, 93 + }, 94 + { 95 + name: "at mention in link again", 96 + markdown: "[check out @regnault.dev](https://regnault.dev)", 97 + contains: `<a href="https://regnault.dev">check out @regnault.dev</a>`, 98 + }, 99 + { 100 + name: "at mention in link again, multiline", 101 + markdown: "[\ncheck out @regnault.dev](https://regnault.dev)", 102 + contains: "<a href=\"https://regnault.dev\">\ncheck out @regnault.dev</a>", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + md := NewMarkdown() 109 + 110 + var buf bytes.Buffer 111 + if err := md.Convert([]byte(tt.markdown), &buf); err != nil { 112 + t.Fatalf("failed to convert markdown: %v", err) 113 + } 114 + 115 + result := buf.String() 116 + if !bytes.Contains([]byte(result), []byte(tt.contains)) { 117 + t.Errorf("expected output to contain:\n%s\ngot:\n%s", tt.contains, result) 118 + } 119 + }) 120 + } 121 + }
+1 -1
appview/pages/pages.go
··· 640 640 } 641 641 642 642 func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error { 643 - return p.executePlain("fragments/starBtn", w, params) 643 + return p.executePlain("fragments/starBtn-oob", w, params) 644 644 } 645 645 646 646 type RepoIndexParams struct {
+1 -1
appview/pages/templates/banner.html
··· 30 30 <div class="mx-6"> 31 31 These services may not be fully accessible until upgraded. 32 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://tangled.org/@tangled.org/core/tree/master/docs/migrations.md"> 33 + href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 34 Click to read the upgrade guide</a>. 35 35 </div> 36 36 </details>
+5
appview/pages/templates/fragments/starBtn-oob.html
··· 1 + {{ define "fragments/starBtn-oob" }} 2 + <div hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'> 3 + {{ template "fragments/starBtn" . }} 4 + </div> 5 + {{ end }}
+1 -3
appview/pages/templates/fragments/starBtn.html
··· 1 1 {{ define "fragments/starBtn" }} 2 + {{/* NOTE: this fragment is always replaced with hx-swap-oob */}} 2 3 <button 3 4 id="starBtn" 4 5 class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" ··· 10 11 {{ end }} 11 12 12 13 hx-trigger="click" 13 - hx-target="this" 14 - hx-swap="outerHTML" 15 - hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]' 16 14 hx-disabled-elt="#starBtn" 17 15 > 18 16 {{ if .IsStarred }}
+1 -1
appview/pages/templates/knots/index.html
··· 105 105 {{ define "docsButton" }} 106 106 <a 107 107 class="btn flex items-center gap-2" 108 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 108 + href="https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide"> 109 109 {{ i "book" "size-4" }} 110 110 docs 111 111 </a>
+2 -2
appview/pages/templates/layouts/fragments/footer.html
··· 26 26 <div class="flex flex-col gap-1"> 27 27 <div class="{{ $headerStyle }}">resources</div> 28 28 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 29 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 30 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 31 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 32 </div> ··· 73 73 <div class="flex flex-col gap-1"> 74 74 <div class="{{ $headerStyle }}">resources</div> 75 75 <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 76 + <a href="https://docs.tangled.org" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 77 <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 78 <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 79 </div>
+1 -1
appview/pages/templates/repo/empty.html
··· 26 26 {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 27 {{ $knot := .RepoInfo.Knot }} 28 28 {{ if eq $knot "knot1.tangled.sh" }} 29 - {{ $knot = "tangled.sh" }} 29 + {{ $knot = "tangled.org" }} 30 30 {{ end }} 31 31 <div class="w-full flex place-content-center"> 32 32 <div class="py-6 w-fit flex flex-col gap-4">
+6 -6
appview/pages/templates/repo/fragments/backlinks.html
··· 14 14 <div class="flex gap-2 items-center"> 15 15 {{ if .State.IsClosed }} 16 16 <span class="text-gray-500 dark:text-gray-400"> 17 - {{ i "ban" "w-4 h-4" }} 17 + {{ i "ban" "size-3" }} 18 18 </span> 19 19 {{ else if eq .Kind.String "issues" }} 20 20 <span class="text-green-600 dark:text-green-500"> 21 - {{ i "circle-dot" "w-4 h-4" }} 21 + {{ i "circle-dot" "size-3" }} 22 22 </span> 23 23 {{ else if .State.IsOpen }} 24 24 <span class="text-green-600 dark:text-green-500"> 25 - {{ i "git-pull-request" "w-4 h-4" }} 25 + {{ i "git-pull-request" "size-3" }} 26 26 </span> 27 27 {{ else if .State.IsMerged }} 28 28 <span class="text-purple-600 dark:text-purple-500"> 29 - {{ i "git-merge" "w-4 h-4" }} 29 + {{ i "git-merge" "size-3" }} 30 30 </span> 31 31 {{ else }} 32 32 <span class="text-gray-600 dark:text-gray-300"> 33 - {{ i "git-pull-request-closed" "w-4 h-4" }} 33 + {{ i "git-pull-request-closed" "size-3" }} 34 34 </span> 35 35 {{ end }} 36 - <a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 36 + <a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 37 </div> 38 38 {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 39 <div>
+1 -1
appview/pages/templates/repo/pipelines/pipelines.html
··· 23 23 </p> 24 24 <p> 25 25 <span class="{{ $bullet }}">2</span>Configure your CI/CD 26 - <a href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/pipeline.md" class="underline">pipeline</a>. 26 + <a href="https://docs.tangled.org/spindles.html#pipelines" class="underline">pipeline</a>. 27 27 </p> 28 28 <p><span class="{{ $bullet }}">3</span>Trigger a workflow with a push or a pull-request!</p> 29 29 </div>
+14
appview/pages/templates/repo/pipelines/workflow.html
··· 12 12 {{ block "sidebar" . }} {{ end }} 13 13 </div> 14 14 <div class="col-span-1 md:col-span-3"> 15 + <!-- TODO(boltless): explictly check for pipeline cancel permission --> 16 + {{ if $.RepoInfo.Roles.IsOwner }} 17 + <div class="flex justify-between mb-2"> 18 + <div id="workflow-error" class="text-red-500 dark:text-red-400"></div> 19 + <button 20 + class="btn" 21 + hx-post="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/cancel" 22 + hx-swap="none" 23 + {{ if (index .Pipeline.Statuses .Workflow).Latest.Status.IsFinish -}} 24 + disabled 25 + {{- end }} 26 + >Cancel</button> 27 + </div> 28 + {{ end }} 15 29 {{ block "logs" . }} {{ end }} 16 30 </div> 17 31 </section>
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 22 22 <p class="text-gray-500 dark:text-gray-400"> 23 23 Choose a spindle to execute your workflows on. Only repository owners 24 24 can configure spindles. Spindles can be selfhosted, 25 - <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 26 26 click to learn more. 27 27 </a> 28 28 </p>
+1 -1
appview/pages/templates/spindles/index.html
··· 102 102 {{ define "docsButton" }} 103 103 <a 104 104 class="btn flex items-center gap-2" 105 - href="https://tangled.org/@tangled.org/core/blob/master/docs/spindle/hosting.md"> 105 + href="https://docs.tangled.org/spindles.html#self-hosting-guide"> 106 106 {{ i "book" "size-4" }} 107 107 docs 108 108 </a>
+1 -1
appview/pages/templates/strings/string.html
··· 17 17 <span class="select-none">/</span> 18 18 <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 19 </div> 20 - <div class="flex gap-2 text-base"> 20 + <div class="flex gap-2 items-stretch text-base"> 21 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 22 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 23 hx-boost="true"
+2 -2
appview/pages/templates/user/fragments/followCard.html
··· 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 12 <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 13 </a> 14 14 {{ with .Profile }} 15 - <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 15 + <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 16 {{ end }} 17 17 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 18 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+9 -6
appview/pages/templates/user/signup.html
··· 43 43 page to complete your registration. 44 44 </span> 45 45 <div class="w-full mt-4 text-center"> 46 - <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div> 47 47 </div> 48 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 49 <span>join now</span> 50 50 </button> 51 + <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 + </p> 54 + 55 + <p id="signup-msg" class="error w-full"></p> 56 + <p class="text-sm text-gray-500 pt-4"> 57 + By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>. 58 + </p> 51 59 </form> 52 - <p class="text-sm text-gray-500"> 53 - Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 - </p> 55 - 56 - <p id="signup-msg" class="error w-full"></p> 57 60 </main> 58 61 </body> 59 62 </html>
+86 -1
appview/pipelines/pipelines.go
··· 4 4 "bytes" 5 5 "context" 6 6 "encoding/json" 7 + "fmt" 7 8 "log/slog" 8 9 "net/http" 9 10 "strings" 10 11 "time" 11 12 13 + "tangled.org/core/api/tangled" 12 14 "tangled.org/core/appview/config" 13 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/middleware" 17 + "tangled.org/core/appview/models" 14 18 "tangled.org/core/appview/oauth" 15 19 "tangled.org/core/appview/pages" 16 20 "tangled.org/core/appview/reporesolver" ··· 36 40 logger *slog.Logger 37 41 } 38 42 39 - func (p *Pipelines) Router() http.Handler { 43 + func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler { 40 44 r := chi.NewRouter() 41 45 r.Get("/", p.Index) 42 46 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 43 47 r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 48 + r. 49 + With(mw.RepoPermissionMiddleware("repo:owner")). 50 + Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel) 44 51 45 52 return r 46 53 } ··· 314 321 } 315 322 } 316 323 } 324 + } 325 + 326 + func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) { 327 + l := p.logger.With("handler", "Cancel") 328 + 329 + var ( 330 + pipelineId = chi.URLParam(r, "pipeline") 331 + workflow = chi.URLParam(r, "workflow") 332 + ) 333 + if pipelineId == "" || workflow == "" { 334 + http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 335 + return 336 + } 337 + 338 + f, err := p.repoResolver.Resolve(r) 339 + if err != nil { 340 + l.Error("failed to get repo and knot", "err", err) 341 + http.Error(w, "bad repo/knot", http.StatusBadRequest) 342 + return 343 + } 344 + 345 + pipeline, err := func() (models.Pipeline, error) { 346 + ps, err := db.GetPipelineStatuses( 347 + p.db, 348 + 1, 349 + orm.FilterEq("repo_owner", f.Did), 350 + orm.FilterEq("repo_name", f.Name), 351 + orm.FilterEq("knot", f.Knot), 352 + orm.FilterEq("id", pipelineId), 353 + ) 354 + if err != nil { 355 + return models.Pipeline{}, err 356 + } 357 + if len(ps) != 1 { 358 + return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps)) 359 + } 360 + return ps[0], nil 361 + }() 362 + if err != nil { 363 + l.Error("pipeline query failed", "err", err) 364 + http.Error(w, "pipeline not found", http.StatusNotFound) 365 + } 366 + var ( 367 + spindle = f.Spindle 368 + knot = f.Knot 369 + rkey = pipeline.Rkey 370 + ) 371 + 372 + if spindle == "" || knot == "" || rkey == "" { 373 + http.Error(w, "invalid repo info", http.StatusBadRequest) 374 + return 375 + } 376 + 377 + spindleClient, err := p.oauth.ServiceClient( 378 + r, 379 + oauth.WithService(f.Spindle), 380 + oauth.WithLxm(tangled.PipelineCancelPipelineNSID), 381 + oauth.WithDev(p.config.Core.Dev), 382 + oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time 383 + ) 384 + 385 + err = tangled.PipelineCancelPipeline( 386 + r.Context(), 387 + spindleClient, 388 + &tangled.PipelineCancelPipeline_Input{ 389 + Repo: string(f.RepoAt()), 390 + Pipeline: pipeline.AtUri().String(), 391 + Workflow: workflow, 392 + }, 393 + ) 394 + err = fmt.Errorf("boo! new error") 395 + errorId := "workflow-error" 396 + if err != nil { 397 + l.Error("failed to cancel workflow", "err", err) 398 + p.pages.Notice(w, errorId, "Failed to cancel workflow") 399 + return 400 + } 401 + l.Debug("canceled pipeline", "uri", pipeline.AtUri()) 317 402 } 318 403 319 404 // either a message or an error
+8
appview/pulls/pulls.go
··· 1366 1366 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1367 1367 return 1368 1368 } 1369 + 1369 1370 } 1370 1371 1371 1372 if err = tx.Commit(); err != nil { 1372 1373 log.Println("failed to create pull request", err) 1373 1374 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1374 1375 return 1376 + } 1377 + 1378 + // notify about each pull 1379 + // 1380 + // this is performed after tx.Commit, because it could result in a locked DB otherwise 1381 + for _, p := range stack { 1382 + s.notifier.NewPull(r.Context(), p) 1375 1383 } 1376 1384 1377 1385 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+17
appview/state/git_http.go
··· 25 25 26 26 } 27 27 28 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 + if !ok { 31 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 + return 33 + } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 + 36 + scheme := "https" 37 + if s.config.Core.Dev { 38 + scheme = "http" 39 + } 40 + 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 + s.proxyRequest(w, r, targetURL) 43 + } 44 + 28 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 29 46 user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 47 if !ok {
-86
appview/state/knotstream.go
··· 18 18 "tangled.org/core/log" 19 19 "tangled.org/core/orm" 20 20 "tangled.org/core/rbac" 21 - "tangled.org/core/workflow" 22 21 23 - "github.com/bluesky-social/indigo/atproto/syntax" 24 22 "github.com/go-git/go-git/v5/plumbing" 25 23 "github.com/posthog/posthog-go" 26 24 ) ··· 67 65 switch msg.Nsid { 68 66 case tangled.GitRefUpdateNSID: 69 67 return ingestRefUpdate(d, enforcer, posthog, dev, source, msg) 70 - case tangled.PipelineNSID: 71 - return ingestPipeline(d, source, msg) 72 68 } 73 69 74 70 return nil ··· 190 186 191 187 return tx.Commit() 192 188 } 193 - 194 - func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { 195 - var record tangled.Pipeline 196 - err := json.Unmarshal(msg.EventJson, &record) 197 - if err != nil { 198 - return err 199 - } 200 - 201 - if record.TriggerMetadata == nil { 202 - return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 203 - } 204 - 205 - if record.TriggerMetadata.Repo == nil { 206 - return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 207 - } 208 - 209 - // does this repo have a spindle configured? 210 - repos, err := db.GetRepos( 211 - d, 212 - 0, 213 - orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 214 - orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 215 - ) 216 - if err != nil { 217 - return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 218 - } 219 - if len(repos) != 1 { 220 - return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 221 - } 222 - if repos[0].Spindle == "" { 223 - return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 224 - } 225 - 226 - // trigger info 227 - var trigger models.Trigger 228 - var sha string 229 - trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 230 - switch trigger.Kind { 231 - case workflow.TriggerKindPush: 232 - trigger.PushRef = &record.TriggerMetadata.Push.Ref 233 - trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 234 - trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 235 - sha = *trigger.PushNewSha 236 - case workflow.TriggerKindPullRequest: 237 - trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 238 - trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 239 - trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 240 - trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 241 - sha = *trigger.PRSourceSha 242 - } 243 - 244 - tx, err := d.Begin() 245 - if err != nil { 246 - return fmt.Errorf("failed to start txn: %w", err) 247 - } 248 - 249 - triggerId, err := db.AddTrigger(tx, trigger) 250 - if err != nil { 251 - return fmt.Errorf("failed to add trigger entry: %w", err) 252 - } 253 - 254 - pipeline := models.Pipeline{ 255 - Rkey: msg.Rkey, 256 - Knot: source.Key(), 257 - RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 258 - RepoName: record.TriggerMetadata.Repo.Repo, 259 - TriggerId: int(triggerId), 260 - Sha: sha, 261 - } 262 - 263 - err = db.AddPipeline(tx, pipeline) 264 - if err != nil { 265 - return fmt.Errorf("failed to add pipeline: %w", err) 266 - } 267 - 268 - err = tx.Commit() 269 - if err != nil { 270 - return fmt.Errorf("failed to commit txn: %w", err) 271 - } 272 - 273 - return nil 274 - }
+4 -3
appview/state/router.go
··· 96 96 r.Mount("/", s.RepoRouter(mw)) 97 97 r.Mount("/issues", s.IssuesRouter(mw)) 98 98 r.Mount("/pulls", s.PullsRouter(mw)) 99 - r.Mount("/pipelines", s.PipelinesRouter()) 99 + r.Mount("/pipelines", s.PipelinesRouter(mw)) 100 100 r.Mount("/labels", s.LabelsRouter()) 101 101 102 102 // These routes get proxied to the knot 103 103 r.Get("/info/refs", s.InfoRefs) 104 + r.Post("/git-upload-archive", s.UploadArchive) 104 105 r.Post("/git-upload-pack", s.UploadPack) 105 106 r.Post("/git-receive-pack", s.ReceivePack) 106 107 ··· 312 313 return repo.Router(mw) 313 314 } 314 315 315 - func (s *State) PipelinesRouter() http.Handler { 316 + func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 316 317 pipes := pipelines.New( 317 318 s.oauth, 318 319 s.repoResolver, ··· 324 325 s.enforcer, 325 326 log.SubLogger(s.logger, "pipelines"), 326 327 ) 327 - return pipes.Router() 328 + return pipes.Router(mw) 328 329 } 329 330 330 331 func (s *State) LabelsRouter() http.Handler {
+89
appview/state/spindlestream.go
··· 20 20 "tangled.org/core/orm" 21 21 "tangled.org/core/rbac" 22 22 spindle "tangled.org/core/spindle/models" 23 + "tangled.org/core/workflow" 23 24 ) 24 25 25 26 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 62 63 func spindleIngester(ctx context.Context, logger *slog.Logger, d *db.DB) ec.ProcessFunc { 63 64 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 64 65 switch msg.Nsid { 66 + case tangled.PipelineNSID: 67 + return ingestPipeline(logger, d, source, msg) 65 68 case tangled.PipelineStatusNSID: 66 69 return ingestPipelineStatus(ctx, logger, d, source, msg) 67 70 } 68 71 69 72 return nil 70 73 } 74 + } 75 + 76 + func ingestPipeline(l *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error { 77 + var record tangled.Pipeline 78 + err := json.Unmarshal(msg.EventJson, &record) 79 + if err != nil { 80 + return err 81 + } 82 + 83 + if record.TriggerMetadata == nil { 84 + return fmt.Errorf("empty trigger metadata: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 85 + } 86 + 87 + if record.TriggerMetadata.Repo == nil { 88 + return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 89 + } 90 + 91 + // does this repo have a spindle configured? 92 + repos, err := db.GetRepos( 93 + d, 94 + 0, 95 + orm.FilterEq("did", record.TriggerMetadata.Repo.Did), 96 + orm.FilterEq("name", record.TriggerMetadata.Repo.Repo), 97 + ) 98 + if err != nil { 99 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 100 + } 101 + if len(repos) != 1 { 102 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 103 + } 104 + if repos[0].Spindle == "" { 105 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 106 + } 107 + 108 + // trigger info 109 + var trigger models.Trigger 110 + var sha string 111 + trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 112 + switch trigger.Kind { 113 + case workflow.TriggerKindPush: 114 + trigger.PushRef = &record.TriggerMetadata.Push.Ref 115 + trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 116 + trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 117 + sha = *trigger.PushNewSha 118 + case workflow.TriggerKindPullRequest: 119 + trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 120 + trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 121 + trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha 122 + trigger.PRAction = &record.TriggerMetadata.PullRequest.Action 123 + sha = *trigger.PRSourceSha 124 + } 125 + 126 + tx, err := d.Begin() 127 + if err != nil { 128 + return fmt.Errorf("failed to start txn: %w", err) 129 + } 130 + 131 + triggerId, err := db.AddTrigger(tx, trigger) 132 + if err != nil { 133 + return fmt.Errorf("failed to add trigger entry: %w", err) 134 + } 135 + 136 + // TODO: we shouldn't even use knot to identify pipelines 137 + knot := record.TriggerMetadata.Repo.Knot 138 + pipeline := models.Pipeline{ 139 + Rkey: msg.Rkey, 140 + Knot: knot, 141 + RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did), 142 + RepoName: record.TriggerMetadata.Repo.Repo, 143 + TriggerId: int(triggerId), 144 + Sha: sha, 145 + } 146 + 147 + err = db.AddPipeline(tx, pipeline) 148 + if err != nil { 149 + return fmt.Errorf("failed to add pipeline: %w", err) 150 + } 151 + 152 + err = tx.Commit() 153 + if err != nil { 154 + return fmt.Errorf("failed to commit txn: %w", err) 155 + } 156 + 157 + l.Info("added pipeline", "pipeline", pipeline) 158 + 159 + return nil 71 160 } 72 161 73 162 func ingestPipelineStatus(ctx context.Context, logger *slog.Logger, d *db.DB, source ec.Source, msg ec.Message) error {
+1529
docs/DOCS.md
··· 1 + --- 2 + title: Tangled docs 3 + author: The Tangled Contributors 4 + date: 21 Sun, Dec 2025 5 + --- 6 + 7 + # Introduction 8 + 9 + Tangled is a decentralized code hosting and collaboration 10 + platform. Every component of Tangled is open-source and 11 + self-hostable. [tangled.org](https://tangled.org) also 12 + provides hosting and CI services that are free to use. 13 + 14 + There are several models for decentralized code 15 + collaboration platforms, ranging from ActivityPubโ€™s 16 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 17 + Our approach attempts to be the best of both worlds by 18 + adopting the AT Protocolโ€”a protocol for building decentralized 19 + social applications with a central identity 20 + 21 + Our approach to this is the idea of โ€œknotsโ€. Knots are 22 + lightweight, headless servers that enable users to host Git 23 + repositories with ease. Knots are designed for either single 24 + or multi-tenant use which is perfect for self-hosting on a 25 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 26 + default, Tangled provides managed knots where you can host 27 + your repositories for free. 28 + 29 + The appview at tangled.org acts as a consolidated "view" 30 + into the whole network, allowing users to access, clone and 31 + contribute to repositories hosted across different knots 32 + seamlessly. 33 + 34 + # Quick start guide 35 + 36 + ## Login or sign up 37 + 38 + You can [login](https://tangled.org) by using your AT Protocol 39 + account. If you are unclear on what that means, simply head 40 + to the [signup](https://tangled.org/signup) page and create 41 + an account. By doing so, you will be choosing Tangled as 42 + your account provider (you will be granted a handle of the 43 + form `user.tngl.sh`). 44 + 45 + In the AT Protocol network, users are free to choose their account 46 + provider (known as a "Personal Data Service", or PDS), and 47 + login to applications that support AT accounts. 48 + 49 + You can think of it as "one account for all of the atmosphere"! 50 + 51 + If you already have an AT account (you may have one if you 52 + signed up to Bluesky, for example), you can login with the 53 + same handle on Tangled (so just use `user.bsky.social` on 54 + the login page). 55 + 56 + ## Add an SSH key 57 + 58 + Once you are logged in, you can start creating repositories 59 + and pushing code. Tangled supports pushing git repositories 60 + over SSH. 61 + 62 + First, you'll need to generate an SSH key if you don't 63 + already have one: 64 + 65 + ```bash 66 + ssh-keygen -t ed25519 -C "foo@bar.com" 67 + ``` 68 + 69 + When prompted, save the key to the default location 70 + (`~/.ssh/id_ed25519`) and optionally set a passphrase. 71 + 72 + Copy your public key to your clipboard: 73 + 74 + ```bash 75 + # on X11 76 + cat ~/.ssh/id_ed25519.pub | xclip -sel c 77 + 78 + # on wayland 79 + cat ~/.ssh/id_ed25519.pub | wl-copy 80 + 81 + # on macos 82 + cat ~/.ssh/id_ed25519.pub | pbcopy 83 + ``` 84 + 85 + Now, navigate to 'Settings' -> 'Keys' and hit 'Add Key', 86 + paste your public key, give it a descriptive name, and hit 87 + save. 88 + 89 + ## Create a repository 90 + 91 + Once your SSH key is added, create your first repository: 92 + 93 + 1. Hit the green `+` icon on the topbar, and select 94 + repository 95 + 2. Enter a repository name 96 + 3. Add a description 97 + 4. Choose a knotserver to host this repository on 98 + 5. Hit create 99 + 100 + Knots are self-hostable, lightweight Git servers that can 101 + host your repository. Unlike traditional code forges, your 102 + code can live on any server. Read the [Knots](TODO) section 103 + for more. 104 + 105 + ## Configure SSH 106 + 107 + To ensure Git uses the correct SSH key and connects smoothly 108 + to Tangled, add this configuration to your `~/.ssh/config` 109 + file: 110 + 111 + ``` 112 + Host tangled.org 113 + Hostname tangled.org 114 + User git 115 + IdentityFile ~/.ssh/id_ed25519 116 + AddressFamily inet 117 + ``` 118 + 119 + This tells SSH to use your specific key when connecting to 120 + Tangled and prevents authentication issues if you have 121 + multiple SSH keys. 122 + 123 + Note that this configuration only works for knotservers that 124 + are hosted by tangled.org. If you use a custom knot, refer 125 + to the [Knots](TODO) section. 126 + 127 + ## Push your first repository 128 + 129 + Initialize a new Git repository: 130 + 131 + ```bash 132 + mkdir my-project 133 + cd my-project 134 + 135 + git init 136 + echo "# My Project" > README.md 137 + ``` 138 + 139 + Add some content and push! 140 + 141 + ```bash 142 + git add README.md 143 + git commit -m "Initial commit" 144 + git remote add origin git@tangled.org:user.tngl.sh/my-project 145 + git push -u origin main 146 + ``` 147 + 148 + That's it! Your code is now hosted on Tangled. 149 + 150 + ## Migrating an existing repository 151 + 152 + Moving your repositories from GitHub, GitLab, Bitbucket, or 153 + any other Git forge to Tangled is straightforward. You'll 154 + simply change your repository's remote URL. At the moment, 155 + Tangled does not have any tooling to migrate data such as 156 + GitHub issues or pull requests. 157 + 158 + First, create a new repository on tangled.org as described 159 + in the [Quick Start Guide](#create-a-repository). 160 + 161 + Navigate to your existing local repository: 162 + 163 + ```bash 164 + cd /path/to/your/existing/repo 165 + ``` 166 + 167 + You can inspect your existing Git remote like so: 168 + 169 + ```bash 170 + git remote -v 171 + ``` 172 + 173 + You'll see something like: 174 + 175 + ``` 176 + origin git@github.com:username/my-project (fetch) 177 + origin git@github.com:username/my-project (push) 178 + ``` 179 + 180 + Update the remote URL to point to tangled: 181 + 182 + ```bash 183 + git remote set-url origin git@tangled.org:user.tngl.sh/my-project 184 + ``` 185 + 186 + Verify the change: 187 + 188 + ```bash 189 + git remote -v 190 + ``` 191 + 192 + You should now see: 193 + 194 + ``` 195 + origin git@tangled.org:user.tngl.sh/my-project (fetch) 196 + origin git@tangled.org:user.tngl.sh/my-project (push) 197 + ``` 198 + 199 + Push all your branches and tags to Tangled: 200 + 201 + ```bash 202 + git push -u origin --all 203 + git push -u origin --tags 204 + ``` 205 + 206 + Your repository is now migrated to Tangled! All commit 207 + history, branches, and tags have been preserved. 208 + 209 + ## Mirroring a repository to Tangled 210 + 211 + If you want to maintain your repository on multiple forges 212 + simultaneously, for example, keeping your primary repository 213 + on GitHub while mirroring to Tangled for backup or 214 + redundancy, you can do so by adding multiple remotes. 215 + 216 + You can configure your local repository to push to both 217 + Tangled and, say, GitHub. You may already have the following 218 + setup: 219 + 220 + ``` 221 + $ git remote -v 222 + origin git@github.com:username/my-project (fetch) 223 + origin git@github.com:username/my-project (push) 224 + ``` 225 + 226 + Now add Tangled as an additional push URL to the same 227 + remote: 228 + 229 + ```bash 230 + git remote set-url --add --push origin git@tangled.org:user.tngl.sh/my-project 231 + ``` 232 + 233 + You also need to re-add the original URL as a push 234 + destination (Git replaces the push URL when you use `--add` 235 + the first time): 236 + 237 + ```bash 238 + git remote set-url --add --push origin git@github.com:username/my-project 239 + ``` 240 + 241 + Verify your configuration: 242 + 243 + ``` 244 + $ git remote -v 245 + origin git@github.com:username/repo (fetch) 246 + origin git@tangled.org:username/my-project (push) 247 + origin git@github.com:username/repo (push) 248 + ``` 249 + 250 + Notice that there's one fetch URL (the primary remote) and 251 + two push URLs. Now, whenever you push, Git will 252 + automatically push to both remotes: 253 + 254 + ```bash 255 + git push origin main 256 + ``` 257 + 258 + This single command pushes your `main` branch to both GitHub 259 + and Tangled simultaneously. 260 + 261 + To push all branches and tags: 262 + 263 + ```bash 264 + git push origin --all 265 + git push origin --tags 266 + ``` 267 + 268 + If you prefer more control over which remote you push to, 269 + you can maintain separate remotes: 270 + 271 + ```bash 272 + git remote add github git@github.com:username/my-project 273 + git remote add tangled git@tangled.org:username/my-project 274 + ``` 275 + 276 + Then push to each explicitly: 277 + 278 + ```bash 279 + git push github main 280 + git push tangled main 281 + ``` 282 + 283 + # Knot self-hosting guide 284 + 285 + So you want to run your own knot server? Great! Here are a few prerequisites: 286 + 287 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 288 + 2. A (sub)domain name. People generally use `knot.example.com`. 289 + 3. A valid SSL certificate for your domain. 290 + 291 + ## NixOS 292 + 293 + Refer to the [knot 294 + module](https://tangled.org/tangled.org/core/blob/master/nix/modules/knot.nix) 295 + for a full list of options. Sample configurations: 296 + 297 + - [The test VM](https://tangled.org/tangled.org/core/blob/master/nix/vm.nix#L85) 298 + - [@pyrox.dev/nix](https://tangled.org/pyrox.dev/nix/blob/d19571cc1b5fe01035e1e6951ec8cf8a476b4dee/hosts/marvin/services/tangled.nix#L15-25) 299 + 300 + ## Docker 301 + 302 + Refer to 303 + [@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker). 304 + Note that this is community maintained. 305 + 306 + ## Manual setup 307 + 308 + First, clone this repository: 309 + 310 + ``` 311 + git clone https://tangled.org/@tangled.org/core 312 + ``` 313 + 314 + Then, build the `knot` CLI. This is the knot administration 315 + and operation tool. For the purpose of this guide, we're 316 + only concerned with these subcommands: 317 + 318 + * `knot server`: the main knot server process, typically 319 + run as a supervised service 320 + * `knot guard`: handles role-based access control for git 321 + over SSH (you'll never have to run this yourself) 322 + * `knot keys`: fetches SSH keys associated with your knot; 323 + we'll use this to generate the SSH 324 + `AuthorizedKeysCommand` 325 + 326 + ``` 327 + cd core 328 + export CGO_ENABLED=1 329 + go build -o knot ./cmd/knot 330 + ``` 331 + 332 + Next, move the `knot` binary to a location owned by `root` -- 333 + `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 334 + 335 + ``` 336 + sudo mv knot /usr/local/bin/knot 337 + sudo chown root:root /usr/local/bin/knot 338 + ``` 339 + 340 + This is necessary because SSH `AuthorizedKeysCommand` requires [really 341 + specific permissions](https://stackoverflow.com/a/27638306). The 342 + `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 343 + retrieve a user's public SSH keys dynamically for authentication. Let's 344 + set that up. 345 + 346 + ``` 347 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 348 + Match User git 349 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 350 + AuthorizedKeysCommandUser nobody 351 + EOF 352 + ``` 353 + 354 + Then, reload `sshd`: 355 + 356 + ``` 357 + sudo systemctl reload ssh 358 + ``` 359 + 360 + Next, create the `git` user. We'll use the `git` user's home directory 361 + to store repositories: 362 + 363 + ``` 364 + sudo adduser git 365 + ``` 366 + 367 + Create `/home/git/.knot.env` with the following, updating the values as 368 + necessary. The `KNOT_SERVER_OWNER` should be set to your 369 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 370 + 371 + ``` 372 + KNOT_REPO_SCAN_PATH=/home/git 373 + KNOT_SERVER_HOSTNAME=knot.example.com 374 + APPVIEW_ENDPOINT=https://tangled.org 375 + KNOT_SERVER_OWNER=did:plc:foobar 376 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 377 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 378 + ``` 379 + 380 + If you run a Linux distribution that uses systemd, you can use the provided 381 + service file to run the server. Copy 382 + [`knotserver.service`](/systemd/knotserver.service) 383 + to `/etc/systemd/system/`. Then, run: 384 + 385 + ``` 386 + systemctl enable knotserver 387 + systemctl start knotserver 388 + ``` 389 + 390 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 391 + knot. Here's an example configuration for Nginx: 392 + 393 + ``` 394 + server { 395 + listen 80; 396 + listen [::]:80; 397 + server_name knot.example.com; 398 + 399 + location / { 400 + proxy_pass http://localhost:5555; 401 + proxy_set_header Host $host; 402 + proxy_set_header X-Real-IP $remote_addr; 403 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 404 + proxy_set_header X-Forwarded-Proto $scheme; 405 + } 406 + 407 + # wss endpoint for git events 408 + location /events { 409 + proxy_set_header X-Forwarded-For $remote_addr; 410 + proxy_set_header Host $http_host; 411 + proxy_set_header Upgrade websocket; 412 + proxy_set_header Connection Upgrade; 413 + proxy_pass http://localhost:5555; 414 + } 415 + # additional config for SSL/TLS go here. 416 + } 417 + 418 + ``` 419 + 420 + Remember to use Let's Encrypt or similar to procure a certificate for your 421 + knot domain. 422 + 423 + You should now have a running knot server! You can finalize 424 + your registration by hitting the `verify` button on the 425 + [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 426 + a record on your PDS to announce the existence of the knot. 427 + 428 + ### Custom paths 429 + 430 + (This section applies to manual setup only. Docker users should edit the mounts 431 + in `docker-compose.yml` instead.) 432 + 433 + Right now, the database and repositories of your knot lives in `/home/git`. You 434 + can move these paths if you'd like to store them in another folder. Be careful 435 + when adjusting these paths: 436 + 437 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 438 + any possible side effects. Remember to restart it once you're done. 439 + * Make backups before moving in case something goes wrong. 440 + * Make sure the `git` user can read and write from the new paths. 441 + 442 + #### Database 443 + 444 + As an example, let's say the current database is at `/home/git/knotserver.db`, 445 + and we want to move it to `/home/git/database/knotserver.db`. 446 + 447 + Copy the current database to the new location. Make sure to copy the `.db-shm` 448 + and `.db-wal` files if they exist. 449 + 450 + ``` 451 + mkdir /home/git/database 452 + cp /home/git/knotserver.db* /home/git/database 453 + ``` 454 + 455 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 456 + the new file path (_not_ the directory): 457 + 458 + ``` 459 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 460 + ``` 461 + 462 + #### Repositories 463 + 464 + As an example, let's say the repositories are currently in `/home/git`, and we 465 + want to move them into `/home/git/repositories`. 466 + 467 + Create the new folder, then move the existing repositories (if there are any): 468 + 469 + ``` 470 + mkdir /home/git/repositories 471 + # move all DIDs into the new folder; these will vary for you! 472 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 473 + ``` 474 + 475 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 476 + to the new directory: 477 + 478 + ``` 479 + KNOT_REPO_SCAN_PATH=/home/git/repositories 480 + ``` 481 + 482 + Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 483 + repository path: 484 + 485 + ``` 486 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 487 + Match User git 488 + AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 489 + AuthorizedKeysCommandUser nobody 490 + EOF 491 + ``` 492 + 493 + Make sure to restart your SSH server! 494 + 495 + #### MOTD (message of the day) 496 + 497 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 498 + `/home/git/motd` file: 499 + 500 + ``` 501 + printf "Hi from this knot!\n" > /home/git/motd 502 + ``` 503 + 504 + Note that you should add a newline at the end if setting a non-empty message 505 + since the knot won't do this for you. 506 + 507 + # Spindles 508 + 509 + ## Pipelines 510 + 511 + Spindle workflows allow you to write CI/CD pipelines in a 512 + simple format. They're located in the `.tangled/workflows` 513 + directory at the root of your repository, and are defined 514 + using YAML. 515 + 516 + The fields are: 517 + 518 + - [Trigger](#trigger): A **required** field that defines 519 + when a workflow should be triggered. 520 + - [Engine](#engine): A **required** field that defines which 521 + engine a workflow should run on. 522 + - [Clone options](#clone-options): An **optional** field 523 + that defines how the repository should be cloned. 524 + - [Dependencies](#dependencies): An **optional** field that 525 + allows you to list dependencies you may need. 526 + - [Environment](#environment): An **optional** field that 527 + allows you to define environment variables. 528 + - [Steps](#steps): An **optional** field that allows you to 529 + define what steps should run in the workflow. 530 + 531 + ### Trigger 532 + 533 + The first thing to add to a workflow is the trigger, which 534 + defines when a workflow runs. This is defined using a `when` 535 + field, which takes in a list of conditions. Each condition 536 + has the following fields: 537 + 538 + - `event`: This is a **required** field that defines when 539 + your workflow should run. It's a list that can take one or 540 + more of the following values: 541 + - `push`: The workflow should run every time a commit is 542 + pushed to the repository. 543 + - `pull_request`: The workflow should run every time a 544 + pull request is made or updated. 545 + - `manual`: The workflow can be triggered manually. 546 + - `branch`: Defines which branches the workflow should run 547 + for. If used with the `push` event, commits to the 548 + branch(es) listed here will trigger the workflow. If used 549 + with the `pull_request` event, updates to pull requests 550 + targeting the branch(es) listed here will trigger the 551 + workflow. This field has no effect with the `manual` 552 + event. Supports glob patterns using `*` and `**` (e.g., 553 + `main`, `develop`, `release-*`). Either `branch` or `tag` 554 + (or both) must be specified for `push` events. 555 + - `tag`: Defines which tags the workflow should run for. 556 + Only used with the `push` event - when tags matching the 557 + pattern(s) listed here are pushed, the workflow will 558 + trigger. This field has no effect with `pull_request` or 559 + `manual` events. Supports glob patterns using `*` and `**` 560 + (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or 561 + `tag` (or both) must be specified for `push` events. 562 + 563 + For example, if you'd like to define a workflow that runs 564 + when commits are pushed to the `main` and `develop` 565 + branches, or when pull requests that target the `main` 566 + branch are updated, or manually, you can do so with: 567 + 568 + ```yaml 569 + when: 570 + - event: ["push", "manual"] 571 + branch: ["main", "develop"] 572 + - event: ["pull_request"] 573 + branch: ["main"] 574 + ``` 575 + 576 + You can also trigger workflows on tag pushes. For instance, 577 + to run a deployment workflow when tags matching `v*` are 578 + pushed: 579 + 580 + ```yaml 581 + when: 582 + - event: ["push"] 583 + tag: ["v*"] 584 + ``` 585 + 586 + You can even combine branch and tag patterns in a single 587 + constraint (the workflow triggers if either matches): 588 + 589 + ```yaml 590 + when: 591 + - event: ["push"] 592 + branch: ["main", "release-*"] 593 + tag: ["v*", "stable"] 594 + ``` 595 + 596 + ### Engine 597 + 598 + Next is the engine on which the workflow should run, defined 599 + using the **required** `engine` field. The currently 600 + supported engines are: 601 + 602 + - `nixery`: This uses an instance of 603 + [Nixery](https://nixery.dev) to run steps, which allows 604 + you to add [dependencies](#dependencies) from 605 + Nixpkgs (https://github.com/NixOS/nixpkgs). You can 606 + search for packages on https://search.nixos.org, and 607 + there's a pretty good chance the package(s) you're looking 608 + for will be there. 609 + 610 + Example: 611 + 612 + ```yaml 613 + engine: "nixery" 614 + ``` 615 + 616 + ### Clone options 617 + 618 + When a workflow starts, the first step is to clone the 619 + repository. You can customize this behavior using the 620 + **optional** `clone` field. It has the following fields: 621 + 622 + - `skip`: Setting this to `true` will skip cloning the 623 + repository. This can be useful if your workflow is doing 624 + something that doesn't require anything from the 625 + repository itself. This is `false` by default. 626 + - `depth`: This sets the number of commits, or the "clone 627 + depth", to fetch from the repository. For example, if you 628 + set this to 2, the last 2 commits will be fetched. By 629 + default, the depth is set to 1, meaning only the most 630 + recent commit will be fetched, which is the commit that 631 + triggered the workflow. 632 + - `submodules`: If you use Git submodules 633 + (https://git-scm.com/book/en/v2/Git-Tools-Submodules) 634 + in your repository, setting this field to `true` will 635 + recursively fetch all submodules. This is `false` by 636 + default. 637 + 638 + The default settings are: 639 + 640 + ```yaml 641 + clone: 642 + skip: false 643 + depth: 1 644 + submodules: false 645 + ``` 646 + 647 + ### Dependencies 648 + 649 + Usually when you're running a workflow, you'll need 650 + additional dependencies. The `dependencies` field lets you 651 + define which dependencies to get, and from where. It's a 652 + key-value map, with the key being the registry to fetch 653 + dependencies from, and the value being the list of 654 + dependencies to fetch. 655 + 656 + Say you want to fetch Node.js and Go from `nixpkgs`, and a 657 + package called `my_pkg` you've made from your own registry 658 + at your repository at 659 + `https://tangled.org/@example.com/my_pkg`. You can define 660 + those dependencies like so: 661 + 662 + ```yaml 663 + dependencies: 664 + # nixpkgs 665 + nixpkgs: 666 + - nodejs 667 + - go 668 + # custom registry 669 + git+https://tangled.org/@example.com/my_pkg: 670 + - my_pkg 671 + ``` 672 + 673 + Now these dependencies are available to use in your 674 + workflow! 675 + 676 + ### Environment 677 + 678 + The `environment` field allows you define environment 679 + variables that will be available throughout the entire 680 + workflow. **Do not put secrets here, these environment 681 + variables are visible to anyone viewing the repository. You 682 + can add secrets for pipelines in your repository's 683 + settings.** 684 + 685 + Example: 686 + 687 + ```yaml 688 + environment: 689 + GOOS: "linux" 690 + GOARCH: "arm64" 691 + NODE_ENV: "production" 692 + MY_ENV_VAR: "MY_ENV_VALUE" 693 + ``` 694 + 695 + ### Steps 696 + 697 + The `steps` field allows you to define what steps should run 698 + in the workflow. It's a list of step objects, each with the 699 + following fields: 700 + 701 + - `name`: This field allows you to give your step a name. 702 + This name is visible in your workflow runs, and is used to 703 + describe what the step is doing. 704 + - `command`: This field allows you to define a command to 705 + run in that step. The step is run in a Bash shell, and the 706 + logs from the command will be visible in the pipelines 707 + page on the Tangled website. The 708 + [dependencies](#dependencies) you added will be available 709 + to use here. 710 + - `environment`: Similar to the global 711 + [environment](#environment) config, this **optional** 712 + field is a key-value map that allows you to set 713 + environment variables for the step. **Do not put secrets 714 + here, these environment variables are visible to anyone 715 + viewing the repository. You can add secrets for pipelines 716 + in your repository's settings.** 717 + 718 + Example: 719 + 720 + ```yaml 721 + steps: 722 + - name: "Build backend" 723 + command: "go build" 724 + environment: 725 + GOOS: "darwin" 726 + GOARCH: "arm64" 727 + - name: "Build frontend" 728 + command: "npm run build" 729 + environment: 730 + NODE_ENV: "production" 731 + ``` 732 + 733 + ### Complete workflow 734 + 735 + ```yaml 736 + # .tangled/workflows/build.yml 737 + 738 + when: 739 + - event: ["push", "manual"] 740 + branch: ["main", "develop"] 741 + - event: ["pull_request"] 742 + branch: ["main"] 743 + 744 + engine: "nixery" 745 + 746 + # using the default values 747 + clone: 748 + skip: false 749 + depth: 1 750 + submodules: false 751 + 752 + dependencies: 753 + # nixpkgs 754 + nixpkgs: 755 + - nodejs 756 + - go 757 + # custom registry 758 + git+https://tangled.org/@example.com/my_pkg: 759 + - my_pkg 760 + 761 + environment: 762 + GOOS: "linux" 763 + GOARCH: "arm64" 764 + NODE_ENV: "production" 765 + MY_ENV_VAR: "MY_ENV_VALUE" 766 + 767 + steps: 768 + - name: "Build backend" 769 + command: "go build" 770 + environment: 771 + GOOS: "darwin" 772 + GOARCH: "arm64" 773 + - name: "Build frontend" 774 + command: "npm run build" 775 + environment: 776 + NODE_ENV: "production" 777 + ``` 778 + 779 + If you want another example of a workflow, you can look at 780 + the one [Tangled uses to build the 781 + project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml). 782 + 783 + ## Self-hosting guide 784 + 785 + ### Prerequisites 786 + 787 + * Go 788 + * Docker (the only supported backend currently) 789 + 790 + ### Configuration 791 + 792 + Spindle is configured using environment variables. The following environment variables are available: 793 + 794 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 795 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 796 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 797 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 798 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 799 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 800 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 801 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 802 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 803 + 804 + ### Running spindle 805 + 806 + 1. **Set the environment variables.** For example: 807 + 808 + ```shell 809 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 810 + export SPINDLE_SERVER_OWNER="your-did" 811 + ``` 812 + 813 + 2. **Build the Spindle binary.** 814 + 815 + ```shell 816 + cd core 817 + go mod download 818 + go build -o cmd/spindle/spindle cmd/spindle/main.go 819 + ``` 820 + 821 + 3. **Create the log directory.** 822 + 823 + ```shell 824 + sudo mkdir -p /var/log/spindle 825 + sudo chown $USER:$USER -R /var/log/spindle 826 + ``` 827 + 828 + 4. **Run the Spindle binary.** 829 + 830 + ```shell 831 + ./cmd/spindle/spindle 832 + ``` 833 + 834 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines. 835 + 836 + ## Architecture 837 + 838 + Spindle is a small CI runner service. Here's a high-level overview of how it operates: 839 + 840 + * Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 841 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 842 + * When a new repo record comes through (typically when you add a spindle to a 843 + repo from the settings), spindle then resolves the underlying knot and 844 + subscribes to repo events (see: 845 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 846 + * The spindle engine then handles execution of the pipeline, with results and 847 + logs beamed on the spindle event stream over WebSocket 848 + 849 + ### The engine 850 + 851 + At present, the only supported backend is Docker (and Podman, if Docker 852 + compatibility is enabled, so that `/run/docker.sock` is created). spindle 853 + executes each step in the pipeline in a fresh container, with state persisted 854 + across steps within the `/tangled/workspace` directory. 855 + 856 + The base image for the container is constructed on the fly using 857 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 858 + used packages. 859 + 860 + The pipeline manifest is [specified here](https://docs.tangled.org/spindles.html#pipelines). 861 + 862 + ## Secrets with openbao 863 + 864 + This document covers setting up spindle to use OpenBao for secrets 865 + management via OpenBao Proxy instead of the default SQLite backend. 866 + 867 + ### Overview 868 + 869 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 870 + authentication automatically using AppRole credentials, while spindle 871 + connects to the local proxy instead of directly to the OpenBao server. 872 + 873 + This approach provides better security, automatic token renewal, and 874 + simplified application code. 875 + 876 + ### Installation 877 + 878 + Install OpenBao from Nixpkgs: 879 + 880 + ```bash 881 + nix shell nixpkgs#openbao # for a local server 882 + ``` 883 + 884 + ### Setup 885 + 886 + The setup process can is documented for both local development and production. 887 + 888 + #### Local development 889 + 890 + Start OpenBao in dev mode: 891 + 892 + ```bash 893 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 894 + ``` 895 + 896 + This starts OpenBao on `http://localhost:8201` with a root token. 897 + 898 + Set up environment for bao CLI: 899 + 900 + ```bash 901 + export BAO_ADDR=http://localhost:8200 902 + export BAO_TOKEN=root 903 + ``` 904 + 905 + #### Production 906 + 907 + You would typically use a systemd service with a 908 + configuration file. Refer to 909 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) 910 + for how this can be achieved using Nix. 911 + 912 + Then, initialize the bao server: 913 + 914 + ```bash 915 + bao operator init -key-shares=1 -key-threshold=1 916 + ``` 917 + 918 + This will print out an unseal key and a root key. Save them 919 + somewhere (like a password manager). Then unseal the vault 920 + to begin setting it up: 921 + 922 + ```bash 923 + bao operator unseal <unseal_key> 924 + ``` 925 + 926 + All steps below remain the same across both dev and 927 + production setups. 928 + 929 + #### Configure openbao server 930 + 931 + Create the spindle KV mount: 932 + 933 + ```bash 934 + bao secrets enable -path=spindle -version=2 kv 935 + ``` 936 + 937 + Set up AppRole authentication and policy: 938 + 939 + Create a policy file `spindle-policy.hcl`: 940 + 941 + ```hcl 942 + # Full access to spindle KV v2 data 943 + path "spindle/data/*" { 944 + capabilities = ["create", "read", "update", "delete"] 945 + } 946 + 947 + # Access to metadata for listing and management 948 + path "spindle/metadata/*" { 949 + capabilities = ["list", "read", "delete", "update"] 950 + } 951 + 952 + # Allow listing at root level 953 + path "spindle/" { 954 + capabilities = ["list"] 955 + } 956 + 957 + # Required for connection testing and health checks 958 + path "auth/token/lookup-self" { 959 + capabilities = ["read"] 960 + } 961 + ``` 962 + 963 + Apply the policy and create an AppRole: 964 + 965 + ```bash 966 + bao policy write spindle-policy spindle-policy.hcl 967 + bao auth enable approle 968 + bao write auth/approle/role/spindle \ 969 + token_policies="spindle-policy" \ 970 + token_ttl=1h \ 971 + token_max_ttl=4h \ 972 + bind_secret_id=true \ 973 + secret_id_ttl=0 \ 974 + secret_id_num_uses=0 975 + ``` 976 + 977 + Get the credentials: 978 + 979 + ```bash 980 + # Get role ID (static) 981 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 982 + 983 + # Generate secret ID 984 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 985 + 986 + echo "Role ID: $ROLE_ID" 987 + echo "Secret ID: $SECRET_ID" 988 + ``` 989 + 990 + #### Create proxy configuration 991 + 992 + Create the credential files: 993 + 994 + ```bash 995 + # Create directory for OpenBao files 996 + mkdir -p /tmp/openbao 997 + 998 + # Save credentials 999 + echo "$ROLE_ID" > /tmp/openbao/role-id 1000 + echo "$SECRET_ID" > /tmp/openbao/secret-id 1001 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 1002 + ``` 1003 + 1004 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 1005 + 1006 + ```hcl 1007 + # OpenBao server connection 1008 + vault { 1009 + address = "http://localhost:8200" 1010 + } 1011 + 1012 + # Auto-Auth using AppRole 1013 + auto_auth { 1014 + method "approle" { 1015 + mount_path = "auth/approle" 1016 + config = { 1017 + role_id_file_path = "/tmp/openbao/role-id" 1018 + secret_id_file_path = "/tmp/openbao/secret-id" 1019 + } 1020 + } 1021 + 1022 + # Optional: write token to file for debugging 1023 + sink "file" { 1024 + config = { 1025 + path = "/tmp/openbao/token" 1026 + mode = 0640 1027 + } 1028 + } 1029 + } 1030 + 1031 + # Proxy listener for spindle 1032 + listener "tcp" { 1033 + address = "127.0.0.1:8201" 1034 + tls_disable = true 1035 + } 1036 + 1037 + # Enable API proxy with auto-auth token 1038 + api_proxy { 1039 + use_auto_auth_token = true 1040 + } 1041 + 1042 + # Enable response caching 1043 + cache { 1044 + use_auto_auth_token = true 1045 + } 1046 + 1047 + # Logging 1048 + log_level = "info" 1049 + ``` 1050 + 1051 + #### Start the proxy 1052 + 1053 + Start OpenBao Proxy: 1054 + 1055 + ```bash 1056 + bao proxy -config=/tmp/openbao/proxy.hcl 1057 + ``` 1058 + 1059 + The proxy will authenticate with OpenBao and start listening on 1060 + `127.0.0.1:8201`. 1061 + 1062 + #### Configure spindle 1063 + 1064 + Set these environment variables for spindle: 1065 + 1066 + ```bash 1067 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 1068 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 1069 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 1070 + ``` 1071 + 1072 + On startup, spindle will now connect to the local proxy, 1073 + which handles all authentication automatically. 1074 + 1075 + ### Production setup for proxy 1076 + 1077 + For production, you'll want to run the proxy as a service: 1078 + 1079 + Place your production configuration in 1080 + `/etc/openbao/proxy.hcl` with proper TLS settings for the 1081 + vault connection. 1082 + 1083 + ### Verifying setup 1084 + 1085 + Test the proxy directly: 1086 + 1087 + ```bash 1088 + # Check proxy health 1089 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 1090 + 1091 + # Test token lookup through proxy 1092 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 1093 + ``` 1094 + 1095 + Test OpenBao operations through the server: 1096 + 1097 + ```bash 1098 + # List all secrets 1099 + bao kv list spindle/ 1100 + 1101 + # Add a test secret via the spindle API, then check it exists 1102 + bao kv list spindle/repos/ 1103 + 1104 + # Get a specific secret 1105 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 1106 + ``` 1107 + 1108 + ### How it works 1109 + 1110 + - Spindle connects to OpenBao Proxy on localhost (typically 1111 + port 8200 or 8201) 1112 + - The proxy authenticates with OpenBao using AppRole 1113 + credentials 1114 + - All spindle requests go through the proxy, which injects 1115 + authentication tokens 1116 + - Secrets are stored at 1117 + `spindle/repos/{sanitized_repo_path}/{secret_key}` 1118 + - Repository paths like `did:plc:alice/myrepo` become 1119 + `did_plc_alice_myrepo` 1120 + - The proxy handles all token renewal automatically 1121 + - Spindle no longer manages tokens or authentication 1122 + directly 1123 + 1124 + ### Troubleshooting 1125 + 1126 + **Connection refused**: Check that the OpenBao Proxy is 1127 + running and listening on the configured address. 1128 + 1129 + **403 errors**: Verify the AppRole credentials are correct 1130 + and the policy has the necessary permissions. 1131 + 1132 + **404 route errors**: The spindle KV mount probably doesn't 1133 + existโ€”run the mount creation step again. 1134 + 1135 + **Proxy authentication failures**: Check the proxy logs and 1136 + verify the role-id and secret-id files are readable and 1137 + contain valid credentials. 1138 + 1139 + **Secret not found after writing**: This can indicate policy 1140 + permission issues. Verify the policy includes both 1141 + `spindle/data/*` and `spindle/metadata/*` paths with 1142 + appropriate capabilities. 1143 + 1144 + Check proxy logs: 1145 + 1146 + ```bash 1147 + # If running as systemd service 1148 + journalctl -u openbao-proxy -f 1149 + 1150 + # If running directly, check the console output 1151 + ``` 1152 + 1153 + Test AppRole authentication manually: 1154 + 1155 + ```bash 1156 + bao write auth/approle/login \ 1157 + role_id="$(cat /tmp/openbao/role-id)" \ 1158 + secret_id="$(cat /tmp/openbao/secret-id)" 1159 + ``` 1160 + 1161 + # Migrating knots and spindles 1162 + 1163 + Sometimes, non-backwards compatible changes are made to the 1164 + knot/spindle XRPC APIs. If you host a knot or a spindle, you 1165 + will need to follow this guide to upgrade. Typically, this 1166 + only requires you to deploy the newest version. 1167 + 1168 + This document is laid out in reverse-chronological order. 1169 + Newer migration guides are listed first, and older guides 1170 + are further down the page. 1171 + 1172 + ## Upgrading from v1.8.x 1173 + 1174 + After v1.8.2, the HTTP API for knots and spindles has been 1175 + deprecated and replaced with XRPC. Repositories on outdated 1176 + knots will not be viewable from the appview. Upgrading is 1177 + straightforward however. 1178 + 1179 + For knots: 1180 + 1181 + - Upgrade to the latest tag (v1.9.0 or above) 1182 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1183 + hit the "retry" button to verify your knot 1184 + 1185 + For spindles: 1186 + 1187 + - Upgrade to the latest tag (v1.9.0 or above) 1188 + - Head to the [spindle 1189 + dashboard](https://tangled.org/settings/spindles) and hit the 1190 + "retry" button to verify your spindle 1191 + 1192 + ## Upgrading from v1.7.x 1193 + 1194 + After v1.7.0, knot secrets have been deprecated. You no 1195 + longer need a secret from the appview to run a knot. All 1196 + authorized commands to knots are managed via [Inter-Service 1197 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 1198 + Knots will be read-only until upgraded. 1199 + 1200 + Upgrading is quite easy, in essence: 1201 + 1202 + - `KNOT_SERVER_SECRET` is no more, you can remove this 1203 + environment variable entirely 1204 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 1205 + your DID. You can find your DID in the 1206 + [settings](https://tangled.org/settings) page. 1207 + - Restart your knot once you have replaced the environment 1208 + variable 1209 + - Head to the [knot dashboard](https://tangled.org/settings/knots) and 1210 + hit the "retry" button to verify your knot. This simply 1211 + writes a `sh.tangled.knot` record to your PDS. 1212 + 1213 + If you use the nix module, simply bump the flake to the 1214 + latest revision, and change your config block like so: 1215 + 1216 + ```diff 1217 + services.tangled.knot = { 1218 + enable = true; 1219 + server = { 1220 + - secretFile = /path/to/secret; 1221 + + owner = "did:plc:foo"; 1222 + }; 1223 + }; 1224 + ``` 1225 + 1226 + # Hacking on Tangled 1227 + 1228 + We highly recommend [installing 1229 + Nix](https://nixos.org/download/) (the package manager) 1230 + before working on the codebase. The Nix flake provides a lot 1231 + of helpers to get started and most importantly, builds and 1232 + dev shells are entirely deterministic. 1233 + 1234 + To set up your dev environment: 1235 + 1236 + ```bash 1237 + nix develop 1238 + ``` 1239 + 1240 + Non-Nix users can look at the `devShell` attribute in the 1241 + `flake.nix` file to determine necessary dependencies. 1242 + 1243 + ## Running the appview 1244 + 1245 + The Nix flake also exposes a few `app` attributes (run `nix 1246 + flake show` to see a full list of what the flake provides), 1247 + one of the apps runs the appview with the `air` 1248 + live-reloader: 1249 + 1250 + ```bash 1251 + TANGLED_DEV=true nix run .#watch-appview 1252 + 1253 + # TANGLED_DB_PATH might be of interest to point to 1254 + # different sqlite DBs 1255 + 1256 + # in a separate shell, you can live-reload tailwind 1257 + nix run .#watch-tailwind 1258 + ``` 1259 + 1260 + To authenticate with the appview, you will need Redis and 1261 + OAuth JWKs to be set up: 1262 + 1263 + ``` 1264 + # OAuth JWKs should already be set up by the Nix devshell: 1265 + echo $TANGLED_OAUTH_CLIENT_SECRET 1266 + z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 1267 + 1268 + echo $TANGLED_OAUTH_CLIENT_KID 1269 + 1761667908 1270 + 1271 + # if not, you can set it up yourself: 1272 + goat key generate -t P-256 1273 + Key Type: P-256 / secp256r1 / ES256 private key 1274 + Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 1275 + z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 1276 + Public Key (DID Key Syntax): share or publish this (eg, in DID document) 1277 + did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 1278 + 1279 + # the secret key from above 1280 + export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 1281 + 1282 + # Run Redis in a new shell to store OAuth sessions 1283 + redis-server 1284 + ``` 1285 + 1286 + ## Running knots and spindles 1287 + 1288 + An end-to-end knot setup requires setting up a machine with 1289 + `sshd`, `AuthorizedKeysCommand`, and a Git user, which is 1290 + quite cumbersome. So the Nix flake provides a 1291 + `nixosConfiguration` to do so. 1292 + 1293 + <details> 1294 + <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary> 1295 + 1296 + In order to build Tangled's dev VM on macOS, you will 1297 + first need to set up a Linux Nix builder. The recommended 1298 + way to do so is to run a [`darwin.linux-builder` 1299 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 1300 + and to register it in `nix.conf` as a builder for Linux 1301 + with the same architecture as your Mac (`linux-aarch64` if 1302 + you are using Apple Silicon). 1303 + 1304 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 1305 + > the Tangled repo so that it doesn't conflict with the other VM. For example, 1306 + > you can do 1307 + > 1308 + > ```shell 1309 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 1310 + > ``` 1311 + > 1312 + > to store the builder VM in a temporary dir. 1313 + > 1314 + > You should read and follow [all the other intructions][darwin builder vm] to 1315 + > avoid subtle problems. 1316 + 1317 + Alternatively, you can use any other method to set up a 1318 + Linux machine with Nix installed that you can `sudo ssh` 1319 + into (in other words, root user on your Mac has to be able 1320 + to ssh into the Linux machine without entering a password) 1321 + and that has the same architecture as your Mac. See 1322 + [remote builder 1323 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 1324 + for how to register such a builder in `nix.conf`. 1325 + 1326 + > WARNING: If you'd like to use 1327 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 1328 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 1329 + > ssh` works can be tricky. It seems to be [possible with 1330 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 1331 + 1332 + </details> 1333 + 1334 + To begin, grab your DID from http://localhost:3000/settings. 1335 + Then, set `TANGLED_VM_KNOT_OWNER` and 1336 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 1337 + lightweight NixOS VM like so: 1338 + 1339 + ```bash 1340 + nix run --impure .#vm 1341 + 1342 + # type `poweroff` at the shell to exit the VM 1343 + ``` 1344 + 1345 + This starts a knot on port 6444, a spindle on port 6555 1346 + with `ssh` exposed on port 2222. 1347 + 1348 + Once the services are running, head to 1349 + http://localhost:3000/settings/knots and hit "Verify". It should 1350 + verify the ownership of the services instantly if everything 1351 + went smoothly. 1352 + 1353 + You can push repositories to this VM with this ssh config 1354 + block on your main machine: 1355 + 1356 + ```bash 1357 + Host nixos-shell 1358 + Hostname localhost 1359 + Port 2222 1360 + User git 1361 + IdentityFile ~/.ssh/my_tangled_key 1362 + ``` 1363 + 1364 + Set up a remote called `local-dev` on a git repo: 1365 + 1366 + ```bash 1367 + git remote add local-dev git@nixos-shell:user/repo 1368 + git push local-dev main 1369 + ``` 1370 + 1371 + The above VM should already be running a spindle on 1372 + `localhost:6555`. Head to http://localhost:3000/settings/spindles and 1373 + hit "Verify". You can then configure each repository to use 1374 + this spindle and run CI jobs. 1375 + 1376 + Of interest when debugging spindles: 1377 + 1378 + ``` 1379 + # Service logs from journald: 1380 + journalctl -xeu spindle 1381 + 1382 + # CI job logs from disk: 1383 + ls /var/log/spindle 1384 + 1385 + # Debugging spindle database: 1386 + sqlite3 /var/lib/spindle/spindle.db 1387 + 1388 + # litecli has a nicer REPL interface: 1389 + litecli /var/lib/spindle/spindle.db 1390 + ``` 1391 + 1392 + If for any reason you wish to disable either one of the 1393 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 1394 + `services.tangled.spindle.enable` (or 1395 + `services.tangled.knot.enable`) to `false`. 1396 + 1397 + # Contribution guide 1398 + 1399 + ## Commit guidelines 1400 + 1401 + We follow a commit style similar to the Go project. Please keep commits: 1402 + 1403 + * **atomic**: each commit should represent one logical change 1404 + * **descriptive**: the commit message should clearly describe what the 1405 + change does and why it's needed 1406 + 1407 + ### Message format 1408 + 1409 + ``` 1410 + <service/top-level directory>/<affected package/directory>: <short summary of change> 1411 + 1412 + Optional longer description can go here, if necessary. Explain what the 1413 + change does and why, especially if not obvious. Reference relevant 1414 + issues or PRs when applicable. These can be links for now since we don't 1415 + auto-link issues/PRs yet. 1416 + ``` 1417 + 1418 + Here are some examples: 1419 + 1420 + ``` 1421 + appview/state: fix token expiry check in middleware 1422 + 1423 + The previous check did not account for clock drift, leading to premature 1424 + token invalidation. 1425 + ``` 1426 + 1427 + ``` 1428 + knotserver/git/service: improve error checking in upload-pack 1429 + ``` 1430 + 1431 + 1432 + ### General notes 1433 + 1434 + - PRs get merged "as-is" (fast-forward)โ€”like applying a patch-series 1435 + using `git am`. At present, there is no squashingโ€”so please author 1436 + your commits as they would appear on `master`, following the above 1437 + guidelines. 1438 + - If there is a lot of nesting, for example "appview: 1439 + pages/templates/repo/fragments: ...", these can be truncated down to 1440 + just "appview: repo/fragments: ...". If the change affects a lot of 1441 + subdirectories, you may abbreviate to just the top-level names, e.g. 1442 + "appview: ..." or "knotserver: ...". 1443 + - Keep commits lowercased with no trailing period. 1444 + - Use the imperative mood in the summary line (e.g., "fix bug" not 1445 + "fixed bug" or "fixes bug"). 1446 + - Try to keep the summary line under 72 characters, but we aren't too 1447 + fussed about this. 1448 + - Follow the same formatting for PR titles if filled manually. 1449 + - Don't include unrelated changes in the same commit. 1450 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 1451 + before submitting if necessary. 1452 + 1453 + ## Code formatting 1454 + 1455 + We use a variety of tools to format our code, and multiplex them with 1456 + [`treefmt`](https://treefmt.com). All you need to do to format your changes 1457 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 1458 + 1459 + ## Proposals for bigger changes 1460 + 1461 + Small fixes like typos, minor bugs, or trivial refactors can be 1462 + submitted directly as PRs. 1463 + 1464 + For larger changesโ€”especially those introducing new features, significant 1465 + refactoring, or altering system behaviorโ€”please open a proposal first. This 1466 + helps us evaluate the scope, design, and potential impact before implementation. 1467 + 1468 + Create a new issue titled: 1469 + 1470 + ``` 1471 + proposal: <affected scope>: <summary of change> 1472 + ``` 1473 + 1474 + In the description, explain: 1475 + 1476 + - What the change is 1477 + - Why it's needed 1478 + - How you plan to implement it (roughly) 1479 + - Any open questions or tradeoffs 1480 + 1481 + We'll use the issue thread to discuss and refine the idea before moving 1482 + forward. 1483 + 1484 + ## Developer Certificate of Origin (DCO) 1485 + 1486 + We require all contributors to certify that they have the right to 1487 + submit the code they're contributing. To do this, we follow the 1488 + [Developer Certificate of Origin 1489 + (DCO)](https://developercertificate.org/). 1490 + 1491 + By signing your commits, you're stating that the contribution is your 1492 + own work, or that you have the right to submit it under the project's 1493 + license. This helps us keep things clean and legally sound. 1494 + 1495 + To sign your commit, just add the `-s` flag when committing: 1496 + 1497 + ```sh 1498 + git commit -s -m "your commit message" 1499 + ``` 1500 + 1501 + This appends a line like: 1502 + 1503 + ``` 1504 + Signed-off-by: Your Name <your.email@example.com> 1505 + ``` 1506 + 1507 + We won't merge commits if they aren't signed off. If you forget, you can 1508 + amend the last commit like this: 1509 + 1510 + ```sh 1511 + git commit --amend -s 1512 + ``` 1513 + 1514 + If you're submitting a PR with multiple commits, make sure each one is 1515 + signed. 1516 + 1517 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 1518 + to make it sign off commits in the tangled repo: 1519 + 1520 + ```shell 1521 + # Safety check, should say "No matching config key..." 1522 + jj config list templates.commit_trailers 1523 + # The command below may need to be adjusted if the command above returned something. 1524 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 1525 + ``` 1526 + 1527 + Refer to the [jujutsu 1528 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 1529 + for more information.
-136
docs/contributing.md
··· 1 - # tangled contributing guide 2 - 3 - ## commit guidelines 4 - 5 - We follow a commit style similar to the Go project. Please keep commits: 6 - 7 - * **atomic**: each commit should represent one logical change 8 - * **descriptive**: the commit message should clearly describe what the 9 - change does and why it's needed 10 - 11 - ### message format 12 - 13 - ``` 14 - <service/top-level directory>/<affected package/directory>: <short summary of change> 15 - 16 - 17 - Optional longer description can go here, if necessary. Explain what the 18 - change does and why, especially if not obvious. Reference relevant 19 - issues or PRs when applicable. These can be links for now since we don't 20 - auto-link issues/PRs yet. 21 - ``` 22 - 23 - Here are some examples: 24 - 25 - ``` 26 - appview/state: fix token expiry check in middleware 27 - 28 - The previous check did not account for clock drift, leading to premature 29 - token invalidation. 30 - ``` 31 - 32 - ``` 33 - knotserver/git/service: improve error checking in upload-pack 34 - ``` 35 - 36 - 37 - ### general notes 38 - 39 - - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 40 - using `git am`. At present, there is no squashing -- so please author 41 - your commits as they would appear on `master`, following the above 42 - guidelines. 43 - - If there is a lot of nesting, for example "appview: 44 - pages/templates/repo/fragments: ...", these can be truncated down to 45 - just "appview: repo/fragments: ...". If the change affects a lot of 46 - subdirectories, you may abbreviate to just the top-level names, e.g. 47 - "appview: ..." or "knotserver: ...". 48 - - Keep commits lowercased with no trailing period. 49 - - Use the imperative mood in the summary line (e.g., "fix bug" not 50 - "fixed bug" or "fixes bug"). 51 - - Try to keep the summary line under 72 characters, but we aren't too 52 - fussed about this. 53 - - Follow the same formatting for PR titles if filled manually. 54 - - Don't include unrelated changes in the same commit. 55 - - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 - before submitting if necessary. 57 - 58 - ## code formatting 59 - 60 - We use a variety of tools to format our code, and multiplex them with 61 - [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 - is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 63 - 64 - ## proposals for bigger changes 65 - 66 - Small fixes like typos, minor bugs, or trivial refactors can be 67 - submitted directly as PRs. 68 - 69 - For larger changesโ€”especially those introducing new features, significant 70 - refactoring, or altering system behaviorโ€”please open a proposal first. This 71 - helps us evaluate the scope, design, and potential impact before implementation. 72 - 73 - ### proposal format 74 - 75 - Create a new issue titled: 76 - 77 - ``` 78 - proposal: <affected scope>: <summary of change> 79 - ``` 80 - 81 - In the description, explain: 82 - 83 - - What the change is 84 - - Why it's needed 85 - - How you plan to implement it (roughly) 86 - - Any open questions or tradeoffs 87 - 88 - We'll use the issue thread to discuss and refine the idea before moving 89 - forward. 90 - 91 - ## developer certificate of origin (DCO) 92 - 93 - We require all contributors to certify that they have the right to 94 - submit the code they're contributing. To do this, we follow the 95 - [Developer Certificate of Origin 96 - (DCO)](https://developercertificate.org/). 97 - 98 - By signing your commits, you're stating that the contribution is your 99 - own work, or that you have the right to submit it under the project's 100 - license. This helps us keep things clean and legally sound. 101 - 102 - To sign your commit, just add the `-s` flag when committing: 103 - 104 - ```sh 105 - git commit -s -m "your commit message" 106 - ``` 107 - 108 - This appends a line like: 109 - 110 - ``` 111 - Signed-off-by: Your Name <your.email@example.com> 112 - ``` 113 - 114 - We won't merge commits if they aren't signed off. If you forget, you can 115 - amend the last commit like this: 116 - 117 - ```sh 118 - git commit --amend -s 119 - ``` 120 - 121 - If you're submitting a PR with multiple commits, make sure each one is 122 - signed. 123 - 124 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 - to make it sign off commits in the tangled repo: 126 - 127 - ```shell 128 - # Safety check, should say "No matching config key..." 129 - jj config list templates.commit_trailers 130 - # The command below may need to be adjusted if the command above returned something. 131 - jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 132 - ``` 133 - 134 - Refer to the [jj 135 - documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 136 - for more information.
-172
docs/hacking.md
··· 1 - # hacking on tangled 2 - 3 - We highly recommend [installing 4 - nix](https://nixos.org/download/) (the package manager) 5 - before working on the codebase. The nix flake provides a lot 6 - of helpers to get started and most importantly, builds and 7 - dev shells are entirely deterministic. 8 - 9 - To set up your dev environment: 10 - 11 - ```bash 12 - nix develop 13 - ``` 14 - 15 - Non-nix users can look at the `devShell` attribute in the 16 - `flake.nix` file to determine necessary dependencies. 17 - 18 - ## running the appview 19 - 20 - The nix flake also exposes a few `app` attributes (run `nix 21 - flake show` to see a full list of what the flake provides), 22 - one of the apps runs the appview with the `air` 23 - live-reloader: 24 - 25 - ```bash 26 - TANGLED_DEV=true nix run .#watch-appview 27 - 28 - # TANGLED_DB_PATH might be of interest to point to 29 - # different sqlite DBs 30 - 31 - # in a separate shell, you can live-reload tailwind 32 - nix run .#watch-tailwind 33 - ``` 34 - 35 - To authenticate with the appview, you will need redis and 36 - OAUTH JWKs to be setup: 37 - 38 - ``` 39 - # oauth jwks should already be setup by the nix devshell: 40 - echo $TANGLED_OAUTH_CLIENT_SECRET 41 - z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc 42 - 43 - echo $TANGLED_OAUTH_CLIENT_KID 44 - 1761667908 45 - 46 - # if not, you can set it up yourself: 47 - goat key generate -t P-256 48 - Key Type: P-256 / secp256r1 / ES256 private key 49 - Secret Key (Multibase Syntax): save this securely (eg, add to password manager) 50 - z42tuPDKRfM2mz2Kv953ARen2jmrPA8S9LX9tRq4RVcUMwwL 51 - Public Key (DID Key Syntax): share or publish this (eg, in DID document) 52 - did:key:zDnaeUBxtG6Xuv3ATJE4GaWeyXM3jyamJsZw3bSPpxx4bNXDR 53 - 54 - # the secret key from above 55 - export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..." 56 - 57 - # run redis in at a new shell to store oauth sessions 58 - redis-server 59 - ``` 60 - 61 - ## running knots and spindles 62 - 63 - An end-to-end knot setup requires setting up a machine with 64 - `sshd`, `AuthorizedKeysCommand`, and git user, which is 65 - quite cumbersome. So the nix flake provides a 66 - `nixosConfiguration` to do so. 67 - 68 - <details> 69 - <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 70 - 71 - In order to build Tangled's dev VM on macOS, you will 72 - first need to set up a Linux Nix builder. The recommended 73 - way to do so is to run a [`darwin.linux-builder` 74 - VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 75 - and to register it in `nix.conf` as a builder for Linux 76 - with the same architecture as your Mac (`linux-aarch64` if 77 - you are using Apple Silicon). 78 - 79 - > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 80 - > the tangled repo so that it doesn't conflict with the other VM. For example, 81 - > you can do 82 - > 83 - > ```shell 84 - > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 85 - > ``` 86 - > 87 - > to store the builder VM in a temporary dir. 88 - > 89 - > You should read and follow [all the other intructions][darwin builder vm] to 90 - > avoid subtle problems. 91 - 92 - Alternatively, you can use any other method to set up a 93 - Linux machine with `nix` installed that you can `sudo ssh` 94 - into (in other words, root user on your Mac has to be able 95 - to ssh into the Linux machine without entering a password) 96 - and that has the same architecture as your Mac. See 97 - [remote builder 98 - instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 99 - for how to register such a builder in `nix.conf`. 100 - 101 - > WARNING: If you'd like to use 102 - > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 103 - > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 104 - > ssh` works can be tricky. It seems to be [possible with 105 - > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 106 - 107 - </details> 108 - 109 - To begin, grab your DID from http://localhost:3000/settings. 110 - Then, set `TANGLED_VM_KNOT_OWNER` and 111 - `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 112 - lightweight NixOS VM like so: 113 - 114 - ```bash 115 - nix run --impure .#vm 116 - 117 - # type `poweroff` at the shell to exit the VM 118 - ``` 119 - 120 - This starts a knot on port 6444, a spindle on port 6555 121 - with `ssh` exposed on port 2222. 122 - 123 - Once the services are running, head to 124 - http://localhost:3000/settings/knots and hit verify. It should 125 - verify the ownership of the services instantly if everything 126 - went smoothly. 127 - 128 - You can push repositories to this VM with this ssh config 129 - block on your main machine: 130 - 131 - ```bash 132 - Host nixos-shell 133 - Hostname localhost 134 - Port 2222 135 - User git 136 - IdentityFile ~/.ssh/my_tangled_key 137 - ``` 138 - 139 - Set up a remote called `local-dev` on a git repo: 140 - 141 - ```bash 142 - git remote add local-dev git@nixos-shell:user/repo 143 - git push local-dev main 144 - ``` 145 - 146 - ### running a spindle 147 - 148 - The above VM should already be running a spindle on 149 - `localhost:6555`. Head to http://localhost:3000/settings/spindles and 150 - hit verify. You can then configure each repository to use 151 - this spindle and run CI jobs. 152 - 153 - Of interest when debugging spindles: 154 - 155 - ``` 156 - # service logs from journald: 157 - journalctl -xeu spindle 158 - 159 - # CI job logs from disk: 160 - ls /var/log/spindle 161 - 162 - # debugging spindle db: 163 - sqlite3 /var/lib/spindle/spindle.db 164 - 165 - # litecli has a nicer REPL interface: 166 - litecli /var/lib/spindle/spindle.db 167 - ``` 168 - 169 - If for any reason you wish to disable either one of the 170 - services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 171 - `services.tangled.spindle.enable` (or 172 - `services.tangled.knot.enable`) to `false`.
+93
docs/highlight.theme
··· 1 + { 2 + "text-color": null, 3 + "background-color": null, 4 + "line-number-color": null, 5 + "line-number-background-color": null, 6 + "text-styles": { 7 + "Annotation": { 8 + "text-color": null, 9 + "background-color": null, 10 + "bold": false, 11 + "italic": true, 12 + "underline": false 13 + }, 14 + "ControlFlow": { 15 + "text-color": null, 16 + "background-color": null, 17 + "bold": true, 18 + "italic": false, 19 + "underline": false 20 + }, 21 + "Error": { 22 + "text-color": null, 23 + "background-color": null, 24 + "bold": true, 25 + "italic": false, 26 + "underline": false 27 + }, 28 + "Alert": { 29 + "text-color": null, 30 + "background-color": null, 31 + "bold": true, 32 + "italic": false, 33 + "underline": false 34 + }, 35 + "Preprocessor": { 36 + "text-color": null, 37 + "background-color": null, 38 + "bold": true, 39 + "italic": false, 40 + "underline": false 41 + }, 42 + "Information": { 43 + "text-color": null, 44 + "background-color": null, 45 + "bold": false, 46 + "italic": true, 47 + "underline": false 48 + }, 49 + "Warning": { 50 + "text-color": null, 51 + "background-color": null, 52 + "bold": false, 53 + "italic": true, 54 + "underline": false 55 + }, 56 + "Documentation": { 57 + "text-color": null, 58 + "background-color": null, 59 + "bold": false, 60 + "italic": true, 61 + "underline": false 62 + }, 63 + "DataType": { 64 + "text-color": "#8f4e8b", 65 + "background-color": null, 66 + "bold": false, 67 + "italic": false, 68 + "underline": false 69 + }, 70 + "Comment": { 71 + "text-color": null, 72 + "background-color": null, 73 + "bold": false, 74 + "italic": true, 75 + "underline": false 76 + }, 77 + "CommentVar": { 78 + "text-color": null, 79 + "background-color": null, 80 + "bold": false, 81 + "italic": true, 82 + "underline": false 83 + }, 84 + "Keyword": { 85 + "text-color": null, 86 + "background-color": null, 87 + "bold": true, 88 + "italic": false, 89 + "underline": false 90 + } 91 + } 92 + } 93 +
-214
docs/knot-hosting.md
··· 1 - # knot self-hosting guide 2 - 3 - So you want to run your own knot server? Great! Here are a few prerequisites: 4 - 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 - 2. A (sub)domain name. People generally use `knot.example.com`. 7 - 3. A valid SSL certificate for your domain. 8 - 9 - There's a couple of ways to get started: 10 - * NixOS: refer to 11 - [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 12 - * Docker: Documented at 13 - [@tangled.sh/knot-docker](https://tangled.sh/@tangled.sh/knot-docker) 14 - (community maintained: support is not guaranteed!) 15 - * Manual: Documented below. 16 - 17 - ## manual setup 18 - 19 - First, clone this repository: 20 - 21 - ``` 22 - git clone https://tangled.org/@tangled.org/core 23 - ``` 24 - 25 - Then, build the `knot` CLI. This is the knot administration and operation tool. 26 - For the purpose of this guide, we're only concerned with these subcommands: 27 - 28 - * `knot server`: the main knot server process, typically run as a 29 - supervised service 30 - * `knot guard`: handles role-based access control for git over SSH 31 - (you'll never have to run this yourself) 32 - * `knot keys`: fetches SSH keys associated with your knot; we'll use 33 - this to generate the SSH `AuthorizedKeysCommand` 34 - 35 - ``` 36 - cd core 37 - export CGO_ENABLED=1 38 - go build -o knot ./cmd/knot 39 - ``` 40 - 41 - Next, move the `knot` binary to a location owned by `root` -- 42 - `/usr/local/bin/` is a good choice. Make sure the binary itself is also owned by `root`: 43 - 44 - ``` 45 - sudo mv knot /usr/local/bin/knot 46 - sudo chown root:root /usr/local/bin/knot 47 - ``` 48 - 49 - This is necessary because SSH `AuthorizedKeysCommand` requires [really 50 - specific permissions](https://stackoverflow.com/a/27638306). The 51 - `AuthorizedKeysCommand` specifies a command that is run by `sshd` to 52 - retrieve a user's public SSH keys dynamically for authentication. Let's 53 - set that up. 54 - 55 - ``` 56 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 57 - Match User git 58 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys 59 - AuthorizedKeysCommandUser nobody 60 - EOF 61 - ``` 62 - 63 - Then, reload `sshd`: 64 - 65 - ``` 66 - sudo systemctl reload ssh 67 - ``` 68 - 69 - Next, create the `git` user. We'll use the `git` user's home directory 70 - to store repositories: 71 - 72 - ``` 73 - sudo adduser git 74 - ``` 75 - 76 - Create `/home/git/.knot.env` with the following, updating the values as 77 - necessary. The `KNOT_SERVER_OWNER` should be set to your 78 - DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_OWNER=did:plc:foobar 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - The last step is to configure a reverse proxy like Nginx or Caddy to front your 100 - knot. Here's an example configuration for Nginx: 101 - 102 - ``` 103 - server { 104 - listen 80; 105 - listen [::]:80; 106 - server_name knot.example.com; 107 - 108 - location / { 109 - proxy_pass http://localhost:5555; 110 - proxy_set_header Host $host; 111 - proxy_set_header X-Real-IP $remote_addr; 112 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 113 - proxy_set_header X-Forwarded-Proto $scheme; 114 - } 115 - 116 - # wss endpoint for git events 117 - location /events { 118 - proxy_set_header X-Forwarded-For $remote_addr; 119 - proxy_set_header Host $http_host; 120 - proxy_set_header Upgrade websocket; 121 - proxy_set_header Connection Upgrade; 122 - proxy_pass http://localhost:5555; 123 - } 124 - # additional config for SSL/TLS go here. 125 - } 126 - 127 - ``` 128 - 129 - Remember to use Let's Encrypt or similar to procure a certificate for your 130 - knot domain. 131 - 132 - You should now have a running knot server! You can finalize 133 - your registration by hitting the `verify` button on the 134 - [/settings/knots](https://tangled.org/settings/knots) page. This simply creates 135 - a record on your PDS to announce the existence of the knot. 136 - 137 - ### custom paths 138 - 139 - (This section applies to manual setup only. Docker users should edit the mounts 140 - in `docker-compose.yml` instead.) 141 - 142 - Right now, the database and repositories of your knot lives in `/home/git`. You 143 - can move these paths if you'd like to store them in another folder. Be careful 144 - when adjusting these paths: 145 - 146 - * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 147 - any possible side effects. Remember to restart it once you're done. 148 - * Make backups before moving in case something goes wrong. 149 - * Make sure the `git` user can read and write from the new paths. 150 - 151 - #### database 152 - 153 - As an example, let's say the current database is at `/home/git/knotserver.db`, 154 - and we want to move it to `/home/git/database/knotserver.db`. 155 - 156 - Copy the current database to the new location. Make sure to copy the `.db-shm` 157 - and `.db-wal` files if they exist. 158 - 159 - ``` 160 - mkdir /home/git/database 161 - cp /home/git/knotserver.db* /home/git/database 162 - ``` 163 - 164 - In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 165 - the new file path (_not_ the directory): 166 - 167 - ``` 168 - KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 169 - ``` 170 - 171 - #### repositories 172 - 173 - As an example, let's say the repositories are currently in `/home/git`, and we 174 - want to move them into `/home/git/repositories`. 175 - 176 - Create the new folder, then move the existing repositories (if there are any): 177 - 178 - ``` 179 - mkdir /home/git/repositories 180 - # move all DIDs into the new folder; these will vary for you! 181 - mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 182 - ``` 183 - 184 - In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 185 - to the new directory: 186 - 187 - ``` 188 - KNOT_REPO_SCAN_PATH=/home/git/repositories 189 - ``` 190 - 191 - Similarly, update your `sshd` `AuthorizedKeysCommand` to use the updated 192 - repository path: 193 - 194 - ``` 195 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 196 - Match User git 197 - AuthorizedKeysCommand /usr/local/bin/knot keys -o authorized-keys -git-dir /home/git/repositories 198 - AuthorizedKeysCommandUser nobody 199 - EOF 200 - ``` 201 - 202 - Make sure to restart your SSH server! 203 - 204 - #### MOTD (message of the day) 205 - 206 - To configure the MOTD used ("Welcome to this knot!" by default), edit the 207 - `/home/git/motd` file: 208 - 209 - ``` 210 - printf "Hi from this knot!\n" > /home/git/motd 211 - ``` 212 - 213 - Note that you should add a newline at the end if setting a non-empty message 214 - since the knot won't do this for you.
-59
docs/migrations.md
··· 1 - # Migrations 2 - 3 - This document is laid out in reverse-chronological order. 4 - Newer migration guides are listed first, and older guides 5 - are further down the page. 6 - 7 - ## Upgrading from v1.8.x 8 - 9 - After v1.8.2, the HTTP API for knot and spindles have been 10 - deprecated and replaced with XRPC. Repositories on outdated 11 - knots will not be viewable from the appview. Upgrading is 12 - straightforward however. 13 - 14 - For knots: 15 - 16 - - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 18 - hit the "retry" button to verify your knot 19 - 20 - For spindles: 21 - 22 - - Upgrade to latest tag (v1.9.0 or above) 23 - - Head to the [spindle 24 - dashboard](https://tangled.org/settings/spindles) and hit the 25 - "retry" button to verify your spindle 26 - 27 - ## Upgrading from v1.7.x 28 - 29 - After v1.7.0, knot secrets have been deprecated. You no 30 - longer need a secret from the appview to run a knot. All 31 - authorized commands to knots are managed via [Inter-Service 32 - Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 - Knots will be read-only until upgraded. 34 - 35 - Upgrading is quite easy, in essence: 36 - 37 - - `KNOT_SERVER_SECRET` is no more, you can remove this 38 - environment variable entirely 39 - - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 - your DID. You can find your DID in the 41 - [settings](https://tangled.org/settings) page. 42 - - Restart your knot once you have replaced the environment 43 - variable 44 - - Head to the [knot dashboard](https://tangled.org/settings/knots) and 45 - hit the "retry" button to verify your knot. This simply 46 - writes a `sh.tangled.knot` record to your PDS. 47 - 48 - If you use the nix module, simply bump the flake to the 49 - latest revision, and change your config block like so: 50 - 51 - ```diff 52 - services.tangled.knot = { 53 - enable = true; 54 - server = { 55 - - secretFile = /path/to/secret; 56 - + owner = "did:plc:foo"; 57 - }; 58 - }; 59 - ```
-25
docs/spindle/architecture.md
··· 1 - # spindle architecture 2 - 3 - Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 - 5 - * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 - [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 - * when a new repo record comes through (typically when you add a spindle to a 8 - repo from the settings), spindle then resolves the underlying knot and 9 - subscribes to repo events (see: 10 - [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 - * the spindle engine then handles execution of the pipeline, with results and 12 - logs beamed on the spindle event stream over wss 13 - 14 - ### the engine 15 - 16 - At present, the only supported backend is Docker (and Podman, if Docker 17 - compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 - executes each step in the pipeline in a fresh container, with state persisted 19 - across steps within the `/tangled/workspace` directory. 20 - 21 - The base image for the container is constructed on the fly using 22 - [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 - used packages. 24 - 25 - The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
-52
docs/spindle/hosting.md
··· 1 - # spindle self-hosting guide 2 - 3 - ## prerequisites 4 - 5 - * Go 6 - * Docker (the only supported backend currently) 7 - 8 - ## configuration 9 - 10 - Spindle is configured using environment variables. The following environment variables are available: 11 - 12 - * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 - * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 - * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 - * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 - * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 - * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 - * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 - * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 - * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 - 22 - ## running spindle 23 - 24 - 1. **Set the environment variables.** For example: 25 - 26 - ```shell 27 - export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 - export SPINDLE_SERVER_OWNER="your-did" 29 - ``` 30 - 31 - 2. **Build the Spindle binary.** 32 - 33 - ```shell 34 - cd core 35 - go mod download 36 - go build -o cmd/spindle/spindle cmd/spindle/main.go 37 - ``` 38 - 39 - 3. **Create the log directory.** 40 - 41 - ```shell 42 - sudo mkdir -p /var/log/spindle 43 - sudo chown $USER:$USER -R /var/log/spindle 44 - ``` 45 - 46 - 4. **Run the Spindle binary.** 47 - 48 - ```shell 49 - ./cmd/spindle/spindle 50 - ``` 51 - 52 - Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
-285
docs/spindle/openbao.md
··· 1 - # spindle secrets with openbao 2 - 3 - This document covers setting up Spindle to use OpenBao for secrets 4 - management via OpenBao Proxy instead of the default SQLite backend. 5 - 6 - ## overview 7 - 8 - Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 - authentication automatically using AppRole credentials, while Spindle 10 - connects to the local proxy instead of directly to the OpenBao server. 11 - 12 - This approach provides better security, automatic token renewal, and 13 - simplified application code. 14 - 15 - ## installation 16 - 17 - Install OpenBao from nixpkgs: 18 - 19 - ```bash 20 - nix shell nixpkgs#openbao # for a local server 21 - ``` 22 - 23 - ## setup 24 - 25 - The setup process can is documented for both local development and production. 26 - 27 - ### local development 28 - 29 - Start OpenBao in dev mode: 30 - 31 - ```bash 32 - bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 - ``` 34 - 35 - This starts OpenBao on `http://localhost:8201` with a root token. 36 - 37 - Set up environment for bao CLI: 38 - 39 - ```bash 40 - export BAO_ADDR=http://localhost:8200 41 - export BAO_TOKEN=root 42 - ``` 43 - 44 - ### production 45 - 46 - You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 - achieved using Nix. 49 - 50 - Then, initialize the bao server: 51 - ```bash 52 - bao operator init -key-shares=1 -key-threshold=1 53 - ``` 54 - 55 - This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 - ```bash 57 - bao operator unseal <unseal_key> 58 - ``` 59 - 60 - All steps below remain the same across both dev and production setups. 61 - 62 - ### configure openbao server 63 - 64 - Create the spindle KV mount: 65 - 66 - ```bash 67 - bao secrets enable -path=spindle -version=2 kv 68 - ``` 69 - 70 - Set up AppRole authentication and policy: 71 - 72 - Create a policy file `spindle-policy.hcl`: 73 - 74 - ```hcl 75 - # Full access to spindle KV v2 data 76 - path "spindle/data/*" { 77 - capabilities = ["create", "read", "update", "delete"] 78 - } 79 - 80 - # Access to metadata for listing and management 81 - path "spindle/metadata/*" { 82 - capabilities = ["list", "read", "delete", "update"] 83 - } 84 - 85 - # Allow listing at root level 86 - path "spindle/" { 87 - capabilities = ["list"] 88 - } 89 - 90 - # Required for connection testing and health checks 91 - path "auth/token/lookup-self" { 92 - capabilities = ["read"] 93 - } 94 - ``` 95 - 96 - Apply the policy and create an AppRole: 97 - 98 - ```bash 99 - bao policy write spindle-policy spindle-policy.hcl 100 - bao auth enable approle 101 - bao write auth/approle/role/spindle \ 102 - token_policies="spindle-policy" \ 103 - token_ttl=1h \ 104 - token_max_ttl=4h \ 105 - bind_secret_id=true \ 106 - secret_id_ttl=0 \ 107 - secret_id_num_uses=0 108 - ``` 109 - 110 - Get the credentials: 111 - 112 - ```bash 113 - # Get role ID (static) 114 - ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 - 116 - # Generate secret ID 117 - SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 - 119 - echo "Role ID: $ROLE_ID" 120 - echo "Secret ID: $SECRET_ID" 121 - ``` 122 - 123 - ### create proxy configuration 124 - 125 - Create the credential files: 126 - 127 - ```bash 128 - # Create directory for OpenBao files 129 - mkdir -p /tmp/openbao 130 - 131 - # Save credentials 132 - echo "$ROLE_ID" > /tmp/openbao/role-id 133 - echo "$SECRET_ID" > /tmp/openbao/secret-id 134 - chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 - ``` 136 - 137 - Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 - 139 - ```hcl 140 - # OpenBao server connection 141 - vault { 142 - address = "http://localhost:8200" 143 - } 144 - 145 - # Auto-Auth using AppRole 146 - auto_auth { 147 - method "approle" { 148 - mount_path = "auth/approle" 149 - config = { 150 - role_id_file_path = "/tmp/openbao/role-id" 151 - secret_id_file_path = "/tmp/openbao/secret-id" 152 - } 153 - } 154 - 155 - # Optional: write token to file for debugging 156 - sink "file" { 157 - config = { 158 - path = "/tmp/openbao/token" 159 - mode = 0640 160 - } 161 - } 162 - } 163 - 164 - # Proxy listener for Spindle 165 - listener "tcp" { 166 - address = "127.0.0.1:8201" 167 - tls_disable = true 168 - } 169 - 170 - # Enable API proxy with auto-auth token 171 - api_proxy { 172 - use_auto_auth_token = true 173 - } 174 - 175 - # Enable response caching 176 - cache { 177 - use_auto_auth_token = true 178 - } 179 - 180 - # Logging 181 - log_level = "info" 182 - ``` 183 - 184 - ### start the proxy 185 - 186 - Start OpenBao Proxy: 187 - 188 - ```bash 189 - bao proxy -config=/tmp/openbao/proxy.hcl 190 - ``` 191 - 192 - The proxy will authenticate with OpenBao and start listening on 193 - `127.0.0.1:8201`. 194 - 195 - ### configure spindle 196 - 197 - Set these environment variables for Spindle: 198 - 199 - ```bash 200 - export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 - export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 - export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 - ``` 204 - 205 - Start Spindle: 206 - 207 - Spindle will now connect to the local proxy, which handles all 208 - authentication automatically. 209 - 210 - ## production setup for proxy 211 - 212 - For production, you'll want to run the proxy as a service: 213 - 214 - Place your production configuration in `/etc/openbao/proxy.hcl` with 215 - proper TLS settings for the vault connection. 216 - 217 - ## verifying setup 218 - 219 - Test the proxy directly: 220 - 221 - ```bash 222 - # Check proxy health 223 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 - 225 - # Test token lookup through proxy 226 - curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 - ``` 228 - 229 - Test OpenBao operations through the server: 230 - 231 - ```bash 232 - # List all secrets 233 - bao kv list spindle/ 234 - 235 - # Add a test secret via Spindle API, then check it exists 236 - bao kv list spindle/repos/ 237 - 238 - # Get a specific secret 239 - bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 - ``` 241 - 242 - ## how it works 243 - 244 - - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 - - The proxy authenticates with OpenBao using AppRole credentials 246 - - All Spindle requests go through the proxy, which injects authentication tokens 247 - - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 - - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 - - The proxy handles all token renewal automatically 250 - - Spindle no longer manages tokens or authentication directly 251 - 252 - ## troubleshooting 253 - 254 - **Connection refused**: Check that the OpenBao Proxy is running and 255 - listening on the configured address. 256 - 257 - **403 errors**: Verify the AppRole credentials are correct and the policy 258 - has the necessary permissions. 259 - 260 - **404 route errors**: The spindle KV mount probably doesn't exist - run 261 - the mount creation step again. 262 - 263 - **Proxy authentication failures**: Check the proxy logs and verify the 264 - role-id and secret-id files are readable and contain valid credentials. 265 - 266 - **Secret not found after writing**: This can indicate policy permission 267 - issues. Verify the policy includes both `spindle/data/*` and 268 - `spindle/metadata/*` paths with appropriate capabilities. 269 - 270 - Check proxy logs: 271 - 272 - ```bash 273 - # If running as systemd service 274 - journalctl -u openbao-proxy -f 275 - 276 - # If running directly, check the console output 277 - ``` 278 - 279 - Test AppRole authentication manually: 280 - 281 - ```bash 282 - bao write auth/approle/login \ 283 - role_id="$(cat /tmp/openbao/role-id)" \ 284 - secret_id="$(cat /tmp/openbao/secret-id)" 285 - ```
-183
docs/spindle/pipeline.md
··· 1 - # spindle pipelines 2 - 3 - Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 - 5 - The fields are: 6 - 7 - - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 - - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 - - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 - - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 - - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 - - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 - 14 - ## Trigger 15 - 16 - The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 - 18 - - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 - - `push`: The workflow should run every time a commit is pushed to the repository. 20 - - `pull_request`: The workflow should run every time a pull request is made or updated. 21 - - `manual`: The workflow can be triggered manually. 22 - - `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events. 23 - - `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events. 24 - 25 - For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 26 - 27 - ```yaml 28 - when: 29 - - event: ["push", "manual"] 30 - branch: ["main", "develop"] 31 - - event: ["pull_request"] 32 - branch: ["main"] 33 - ``` 34 - 35 - You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed: 36 - 37 - ```yaml 38 - when: 39 - - event: ["push"] 40 - tag: ["v*"] 41 - ``` 42 - 43 - You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches): 44 - 45 - ```yaml 46 - when: 47 - - event: ["push"] 48 - branch: ["main", "release-*"] 49 - tag: ["v*", "stable"] 50 - ``` 51 - 52 - ## Engine 53 - 54 - Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 55 - 56 - - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 57 - 58 - Example: 59 - 60 - ```yaml 61 - engine: "nixery" 62 - ``` 63 - 64 - ## Clone options 65 - 66 - When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 67 - 68 - - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 69 - - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 70 - - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 71 - 72 - The default settings are: 73 - 74 - ```yaml 75 - clone: 76 - skip: false 77 - depth: 1 78 - submodules: false 79 - ``` 80 - 81 - ## Dependencies 82 - 83 - Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 84 - 85 - Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 86 - 87 - ```yaml 88 - dependencies: 89 - # nixpkgs 90 - nixpkgs: 91 - - nodejs 92 - - go 93 - # custom registry 94 - git+https://tangled.org/@example.com/my_pkg: 95 - - my_pkg 96 - ``` 97 - 98 - Now these dependencies are available to use in your workflow! 99 - 100 - ## Environment 101 - 102 - The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 - 104 - Example: 105 - 106 - ```yaml 107 - environment: 108 - GOOS: "linux" 109 - GOARCH: "arm64" 110 - NODE_ENV: "production" 111 - MY_ENV_VAR: "MY_ENV_VALUE" 112 - ``` 113 - 114 - ## Steps 115 - 116 - The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 117 - 118 - - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 119 - - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 120 - - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 121 - 122 - Example: 123 - 124 - ```yaml 125 - steps: 126 - - name: "Build backend" 127 - command: "go build" 128 - environment: 129 - GOOS: "darwin" 130 - GOARCH: "arm64" 131 - - name: "Build frontend" 132 - command: "npm run build" 133 - environment: 134 - NODE_ENV: "production" 135 - ``` 136 - 137 - ## Complete workflow 138 - 139 - ```yaml 140 - # .tangled/workflows/build.yml 141 - 142 - when: 143 - - event: ["push", "manual"] 144 - branch: ["main", "develop"] 145 - - event: ["pull_request"] 146 - branch: ["main"] 147 - 148 - engine: "nixery" 149 - 150 - # using the default values 151 - clone: 152 - skip: false 153 - depth: 1 154 - submodules: false 155 - 156 - dependencies: 157 - # nixpkgs 158 - nixpkgs: 159 - - nodejs 160 - - go 161 - # custom registry 162 - git+https://tangled.org/@example.com/my_pkg: 163 - - my_pkg 164 - 165 - environment: 166 - GOOS: "linux" 167 - GOARCH: "arm64" 168 - NODE_ENV: "production" 169 - MY_ENV_VAR: "MY_ENV_VALUE" 170 - 171 - steps: 172 - - name: "Build backend" 173 - command: "go build" 174 - environment: 175 - GOOS: "darwin" 176 - GOARCH: "arm64" 177 - - name: "Build frontend" 178 - command: "npm run build" 179 - environment: 180 - NODE_ENV: "production" 181 - ``` 182 - 183 - If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+101
docs/styles.css
··· 1 + svg { 2 + width: 16px; 3 + height: 16px; 4 + } 5 + 6 + :root { 7 + --syntax-alert: #d20f39; 8 + --syntax-annotation: #fe640b; 9 + --syntax-attribute: #df8e1d; 10 + --syntax-basen: #40a02b; 11 + --syntax-builtin: #1e66f5; 12 + --syntax-controlflow: #8839ef; 13 + --syntax-char: #04a5e5; 14 + --syntax-constant: #fe640b; 15 + --syntax-comment: #9ca0b0; 16 + --syntax-commentvar: #7c7f93; 17 + --syntax-documentation: #9ca0b0; 18 + --syntax-datatype: #df8e1d; 19 + --syntax-decval: #40a02b; 20 + --syntax-error: #d20f39; 21 + --syntax-extension: #4c4f69; 22 + --syntax-float: #40a02b; 23 + --syntax-function: #1e66f5; 24 + --syntax-import: #40a02b; 25 + --syntax-information: #04a5e5; 26 + --syntax-keyword: #8839ef; 27 + --syntax-operator: #179299; 28 + --syntax-other: #8839ef; 29 + --syntax-preprocessor: #ea76cb; 30 + --syntax-specialchar: #04a5e5; 31 + --syntax-specialstring: #ea76cb; 32 + --syntax-string: #40a02b; 33 + --syntax-variable: #8839ef; 34 + --syntax-verbatimstring: #40a02b; 35 + --syntax-warning: #df8e1d; 36 + } 37 + 38 + @media (prefers-color-scheme: dark) { 39 + :root { 40 + --syntax-alert: #f38ba8; 41 + --syntax-annotation: #fab387; 42 + --syntax-attribute: #f9e2af; 43 + --syntax-basen: #a6e3a1; 44 + --syntax-builtin: #89b4fa; 45 + --syntax-controlflow: #cba6f7; 46 + --syntax-char: #89dceb; 47 + --syntax-constant: #fab387; 48 + --syntax-comment: #6c7086; 49 + --syntax-commentvar: #585b70; 50 + --syntax-documentation: #6c7086; 51 + --syntax-datatype: #f9e2af; 52 + --syntax-decval: #a6e3a1; 53 + --syntax-error: #f38ba8; 54 + --syntax-extension: #cdd6f4; 55 + --syntax-float: #a6e3a1; 56 + --syntax-function: #89b4fa; 57 + --syntax-import: #a6e3a1; 58 + --syntax-information: #89dceb; 59 + --syntax-keyword: #cba6f7; 60 + --syntax-operator: #94e2d5; 61 + --syntax-other: #cba6f7; 62 + --syntax-preprocessor: #f5c2e7; 63 + --syntax-specialchar: #89dceb; 64 + --syntax-specialstring: #f5c2e7; 65 + --syntax-string: #a6e3a1; 66 + --syntax-variable: #cba6f7; 67 + --syntax-verbatimstring: #a6e3a1; 68 + --syntax-warning: #f9e2af; 69 + } 70 + } 71 + 72 + /* pandoc syntax highlighting classes */ 73 + code span.al { color: var(--syntax-alert); font-weight: bold; } /* alert */ 74 + code span.an { color: var(--syntax-annotation); font-weight: bold; font-style: italic; } /* annotation */ 75 + code span.at { color: var(--syntax-attribute); } /* attribute */ 76 + code span.bn { color: var(--syntax-basen); } /* basen */ 77 + code span.bu { color: var(--syntax-builtin); } /* builtin */ 78 + code span.cf { color: var(--syntax-controlflow); font-weight: bold; } /* controlflow */ 79 + code span.ch { color: var(--syntax-char); } /* char */ 80 + code span.cn { color: var(--syntax-constant); } /* constant */ 81 + code span.co { color: var(--syntax-comment); font-style: italic; } /* comment */ 82 + code span.cv { color: var(--syntax-commentvar); font-weight: bold; font-style: italic; } /* commentvar */ 83 + code span.do { color: var(--syntax-documentation); font-style: italic; } /* documentation */ 84 + code span.dt { color: var(--syntax-datatype); } /* datatype */ 85 + code span.dv { color: var(--syntax-decval); } /* decval */ 86 + code span.er { color: var(--syntax-error); font-weight: bold; } /* error */ 87 + code span.ex { color: var(--syntax-extension); } /* extension */ 88 + code span.fl { color: var(--syntax-float); } /* float */ 89 + code span.fu { color: var(--syntax-function); } /* function */ 90 + code span.im { color: var(--syntax-import); font-weight: bold; } /* import */ 91 + code span.in { color: var(--syntax-information); font-weight: bold; font-style: italic; } /* information */ 92 + code span.kw { color: var(--syntax-keyword); font-weight: bold; } /* keyword */ 93 + code span.op { color: var(--syntax-operator); } /* operator */ 94 + code span.ot { color: var(--syntax-other); } /* other */ 95 + code span.pp { color: var(--syntax-preprocessor); } /* preprocessor */ 96 + code span.sc { color: var(--syntax-specialchar); } /* specialchar */ 97 + code span.ss { color: var(--syntax-specialstring); } /* specialstring */ 98 + code span.st { color: var(--syntax-string); } /* string */ 99 + code span.va { color: var(--syntax-variable); } /* variable */ 100 + code span.vs { color: var(--syntax-verbatimstring); } /* verbatimstring */ 101 + code span.wa { color: var(--syntax-warning); font-weight: bold; font-style: italic; } /* warning */
+117
docs/template.html
··· 1 + <!DOCTYPE html> 2 + <html xmlns="http://www.w3.org/1999/xhtml" lang="$lang$" xml:lang="$lang$"$if(dir)$ dir="$dir$"$endif$> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="generator" content="pandoc" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> 7 + $for(author-meta)$ 8 + <meta name="author" content="$author-meta$" /> 9 + $endfor$ 10 + 11 + $if(date-meta)$ 12 + <meta name="dcterms.date" content="$date-meta$" /> 13 + $endif$ 14 + 15 + $if(keywords)$ 16 + <meta name="keywords" content="$for(keywords)$$keywords$$sep$, $endfor$" /> 17 + $endif$ 18 + 19 + $if(description-meta)$ 20 + <meta name="description" content="$description-meta$" /> 21 + $endif$ 22 + 23 + <title>$pagetitle$</title> 24 + 25 + <style> 26 + $styles.css()$ 27 + </style> 28 + 29 + $for(css)$ 30 + <link rel="stylesheet" href="$css$" /> 31 + $endfor$ 32 + 33 + $for(header-includes)$ 34 + $header-includes$ 35 + $endfor$ 36 + 37 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 + 39 + </head> 40 + <body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen"> 41 + $for(include-before)$ 42 + $include-before$ 43 + $endfor$ 44 + 45 + $if(toc)$ 46 + <!-- mobile topbar toc --> 47 + <details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4"> 48 + <summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white"> 49 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 50 + <span class="group-open:hidden inline">${ menu.svg() }</span> 51 + <span class="hidden group-open:inline">${ x.svg() }</span> 52 + </summary> 53 + ${ table-of-contents:toc.html() } 54 + </details> 55 + <!-- desktop sidebar toc --> 56 + <nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50"> 57 + $if(toc-title)$ 58 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 59 + $endif$ 60 + ${ table-of-contents:toc.html() } 61 + </nav> 62 + $endif$ 63 + 64 + <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 65 + <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 66 + $if(top)$ 67 + $-- only print title block if this is NOT the top page 68 + $else$ 69 + $if(title)$ 70 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 71 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 72 + $if(subtitle)$ 73 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 74 + $endif$ 75 + $for(author)$ 76 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 77 + $endfor$ 78 + $if(date)$ 79 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 80 + $endif$ 81 + $if(abstract)$ 82 + <div class="mt-6 p-4 bg-gray-50 rounded-lg"> 83 + <div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div> 84 + <div class="text-gray-700">$abstract$</div> 85 + </div> 86 + $endif$ 87 + $endif$ 88 + </header> 89 + $endif$ 90 + <article class="prose dark:prose-invert max-w-none"> 91 + $body$ 92 + </article> 93 + </main> 94 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 "> 95 + <div class="max-w-4xl mx-auto px-8 py-4"> 96 + <div class="flex justify-between gap-4"> 97 + <span class="flex-1"> 98 + $if(previous.url)$ 99 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Previous</span> 100 + <a href="$previous.url$" accesskey="p" rel="previous">$previous.title$</a> 101 + $endif$ 102 + </span> 103 + <span class="flex-1 text-right"> 104 + $if(next.url)$ 105 + <span class="text-xs text-gray-500 dark:text-gray-400 uppercase block mb-1">Next</span> 106 + <a href="$next.url$" accesskey="n" rel="next">$next.title$</a> 107 + $endif$ 108 + </span> 109 + </div> 110 + </div> 111 + </nav> 112 + </div> 113 + $for(include-after)$ 114 + $include-after$ 115 + $endfor$ 116 + </body> 117 + </html>
+4
docs/toc.html
··· 1 + <div class="[&_ul]:space-y-6 [&_ul]:pl-0 [&_ul]:font-bold [&_ul_ul]:pl-4 [&_ul_ul]:font-normal [&_ul_ul]:space-y-2 [&_li]:space-y-2"> 2 + $table-of-contents$ 3 + </div> 4 +
+9 -9
flake.lock
··· 35 35 "systems": "systems" 36 36 }, 37 37 "locked": { 38 - "lastModified": 1694529238, 39 - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 38 + "lastModified": 1731533236, 39 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 40 40 "owner": "numtide", 41 41 "repo": "flake-utils", 42 - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 42 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 43 43 "type": "github" 44 44 }, 45 45 "original": { ··· 56 56 ] 57 57 }, 58 58 "locked": { 59 - "lastModified": 1754078208, 60 - "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 59 + "lastModified": 1763982521, 60 + "narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=", 61 61 "owner": "nix-community", 62 62 "repo": "gomod2nix", 63 - "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 63 + "rev": "02e63a239d6eabd595db56852535992c898eba72", 64 64 "type": "github" 65 65 }, 66 66 "original": { ··· 150 150 }, 151 151 "nixpkgs": { 152 152 "locked": { 153 - "lastModified": 1751984180, 154 - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 153 + "lastModified": 1766070988, 154 + "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 155 155 "owner": "nixos", 156 156 "repo": "nixpkgs", 157 - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 157 + "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 158 158 "type": "github" 159 159 }, 160 160 "original": {
+34 -4
flake.nix
··· 80 80 }).buildGoApplication; 81 81 modules = ./nix/gomod2nix.toml; 82 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 83 - inherit (pkgs) gcc; 84 83 inherit sqlite-lib-src; 85 84 }; 86 85 lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; ··· 89 88 inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src actor-typeahead-src; 90 89 }; 91 90 appview = self.callPackage ./nix/pkgs/appview.nix {}; 91 + docs = self.callPackage ./nix/pkgs/docs.nix { 92 + inherit inter-fonts-src ibm-plex-mono-src lucide-src; 93 + }; 92 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 93 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 94 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + did-method-plc = self.callPackage ./nix/pkgs/did-method-plc.nix {}; 98 + bluesky-jetstream = self.callPackage ./nix/pkgs/bluesky-jetstream.nix {}; 99 + bluesky-relay = self.callPackage ./nix/pkgs/bluesky-relay.nix {}; 100 + tap = self.callPackage ./nix/pkgs/tap.nix {}; 95 101 }); 96 102 in { 97 103 overlays.default = final: prev: { 98 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview; 104 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap; 99 105 }; 100 106 101 107 packages = forAllSystems (system: let ··· 104 110 staticPackages = mkPackageSet pkgs.pkgsStatic; 105 111 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 106 112 in { 107 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib; 113 + inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap; 108 114 109 115 pkgsStatic-appview = staticPackages.appview; 110 116 pkgsStatic-knot = staticPackages.knot; ··· 156 162 nativeBuildInputs = [ 157 163 pkgs.go 158 164 pkgs.air 159 - pkgs.tilt 160 165 pkgs.gopls 161 166 pkgs.httpie 162 167 pkgs.litecli ··· 304 309 imports = [./nix/modules/spindle.nix]; 305 310 306 311 services.tangled.spindle.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.spindle; 312 + services.tangled.spindle.tap-package = lib.mkDefault self.packages.${pkgs.system}.tap; 313 + }; 314 + nixosModules.did-method-plc = { 315 + lib, 316 + pkgs, 317 + ... 318 + }: { 319 + imports = [./nix/modules/did-method-plc.nix]; 320 + services.did-method-plc.package = lib.mkDefault self.packages.${pkgs.system}.did-method-plc; 321 + }; 322 + nixosModules.bluesky-relay = { 323 + lib, 324 + pkgs, 325 + ... 326 + }: { 327 + imports = [./nix/modules/bluesky-relay.nix]; 328 + services.bluesky-relay.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-relay; 329 + }; 330 + nixosModules.bluesky-jetstream = { 331 + lib, 332 + pkgs, 333 + ... 334 + }: { 335 + imports = [./nix/modules/bluesky-jetstream.nix]; 336 + services.bluesky-jetstream.package = lib.mkDefault self.packages.${pkgs.system}.bluesky-jetstream; 307 337 }; 308 338 }; 309 339 }
+4 -2
go.mod
··· 1 1 module tangled.org/core 2 2 3 - go 1.24.4 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/Blank-Xu/sql-adapter v1.1.1 ··· 29 29 github.com/gorilla/feeds v1.2.0 30 30 github.com/gorilla/sessions v1.4.0 31 31 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 32 + github.com/hashicorp/go-version v1.8.0 32 33 github.com/hiddeco/sshsig v0.2.0 33 34 github.com/hpcloud/tail v1.0.0 34 35 github.com/ipfs/go-cid v0.5.0 ··· 45 46 github.com/urfave/cli/v3 v3.3.3 46 47 github.com/whyrusleeping/cbor-gen v0.3.1 47 48 github.com/yuin/goldmark v1.7.13 49 + github.com/yuin/goldmark-emoji v1.0.6 48 50 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 49 51 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 50 52 golang.org/x/crypto v0.40.0 51 53 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 52 54 golang.org/x/image v0.31.0 53 55 golang.org/x/net v0.42.0 54 - golang.org/x/sync v0.17.0 55 56 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 56 57 gopkg.in/yaml.v3 v3.0.1 57 58 ) ··· 203 204 go.uber.org/atomic v1.11.0 // indirect 204 205 go.uber.org/multierr v1.11.0 // indirect 205 206 go.uber.org/zap v1.27.0 // indirect 207 + golang.org/x/sync v0.17.0 // indirect 206 208 golang.org/x/sys v0.34.0 // indirect 207 209 golang.org/x/text v0.29.0 // indirect 208 210 golang.org/x/time v0.12.0 // indirect
+4
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 267 269 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 268 270 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 269 271 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= ··· 505 507 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 506 508 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 507 509 github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 510 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 511 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 508 512 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 509 513 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 510 514 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4 -4
hook/hook.go
··· 48 48 }, 49 49 Commands: []*cli.Command{ 50 50 { 51 - Name: "post-recieve", 52 - Usage: "sends a post-recieve hook to the knot (waits for stdin)", 53 - Action: postRecieve, 51 + Name: "post-receive", 52 + Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 + Action: postReceive, 54 54 }, 55 55 }, 56 56 } 57 57 } 58 58 59 - func postRecieve(ctx context.Context, cmd *cli.Command) error { 59 + func postReceive(ctx context.Context, cmd *cli.Command) error { 60 60 gitDir := cmd.String("git-dir") 61 61 userDid := cmd.String("user-did") 62 62 userHandle := cmd.String("user-handle")
+1 -1
hook/setup.go
··· 138 138 option_var="GIT_PUSH_OPTION_$i" 139 139 push_options+=(-push-option "${!option_var}") 140 140 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 142 `, executablePath, config.internalApi) 143 143 144 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+1 -1
input.css
··· 162 162 } 163 163 164 164 .prose a.mention { 165 - @apply no-underline hover:underline; 165 + @apply no-underline hover:underline font-bold; 166 166 } 167 167 168 168 .prose li {
+81
knotserver/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "log/slog" 7 + "strings" 8 + 9 + _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + ) 12 + 13 + type DB struct { 14 + db *sql.DB 15 + logger *slog.Logger 16 + } 17 + 18 + func Setup(ctx context.Context, dbPath string) (*DB, error) { 19 + // https://github.com/mattn/go-sqlite3#connection-string 20 + opts := []string{ 21 + "_foreign_keys=1", 22 + "_journal_mode=WAL", 23 + "_synchronous=NORMAL", 24 + "_auto_vacuum=incremental", 25 + } 26 + 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 30 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 40 + 41 + _, err = conn.ExecContext(ctx, ` 42 + create table if not exists known_dids ( 43 + did text primary key 44 + ); 45 + 46 + create table if not exists public_keys ( 47 + id integer primary key autoincrement, 48 + did text not null, 49 + key text not null, 50 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 + unique(did, key), 52 + foreign key (did) references known_dids(did) on delete cascade 53 + ); 54 + 55 + create table if not exists _jetstream ( 56 + id integer primary key autoincrement, 57 + last_time_us integer not null 58 + ); 59 + 60 + create table if not exists events ( 61 + rkey text not null, 62 + nsid text not null, 63 + event text not null, -- json 64 + created integer not null default (strftime('%s', 'now')), 65 + primary key (rkey, nsid) 66 + ); 67 + 68 + create table if not exists migrations ( 69 + id integer primary key autoincrement, 70 + name text unique 71 + ); 72 + `) 73 + if err != nil { 74 + return nil, err 75 + } 76 + 77 + return &DB{ 78 + db: db, 79 + logger: logger, 80 + }, nil 81 + }
-64
knotserver/db/init.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "strings" 6 - 7 - _ "github.com/mattn/go-sqlite3" 8 - ) 9 - 10 - type DB struct { 11 - db *sql.DB 12 - } 13 - 14 - func Setup(dbPath string) (*DB, error) { 15 - // https://github.com/mattn/go-sqlite3#connection-string 16 - opts := []string{ 17 - "_foreign_keys=1", 18 - "_journal_mode=WAL", 19 - "_synchronous=NORMAL", 20 - "_auto_vacuum=incremental", 21 - } 22 - 23 - db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 - if err != nil { 25 - return nil, err 26 - } 27 - 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 31 - 32 - _, err = db.Exec(` 33 - create table if not exists known_dids ( 34 - did text primary key 35 - ); 36 - 37 - create table if not exists public_keys ( 38 - id integer primary key autoincrement, 39 - did text not null, 40 - key text not null, 41 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 42 - unique(did, key), 43 - foreign key (did) references known_dids(did) on delete cascade 44 - ); 45 - 46 - create table if not exists _jetstream ( 47 - id integer primary key autoincrement, 48 - last_time_us integer not null 49 - ); 50 - 51 - create table if not exists events ( 52 - rkey text not null, 53 - nsid text not null, 54 - event text not null, -- json 55 - created integer not null default (strftime('%s', 'now')), 56 - primary key (rkey, nsid) 57 - ); 58 - `) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - return &DB{db: db}, nil 64 - }
+13 -1
knotserver/git/service/service.go
··· 95 95 return c.RunService(cmd) 96 96 } 97 97 98 + func (c *ServiceCommand) UploadArchive() error { 99 + cmd := exec.Command("git", []string{ 100 + "upload-archive", 101 + ".", 102 + }...) 103 + 104 + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 105 + cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol)) 106 + cmd.Dir = c.Dir 107 + 108 + return c.RunService(cmd) 109 + } 110 + 98 111 func (c *ServiceCommand) UploadPack() error { 99 112 cmd := exec.Command("git", []string{ 100 - "-c", "uploadpack.allowFilter=true", 101 113 "upload-pack", 102 114 "--stateless-rpc", 103 115 ".",
+47
knotserver/git.go
··· 56 56 } 57 57 } 58 58 59 + func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 66 + return 67 + } 68 + 69 + const expectedContentType = "application/x-git-upload-archive-request" 70 + contentType := r.Header.Get("Content-Type") 71 + if contentType != expectedContentType { 72 + gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType) 73 + } 74 + 75 + var bodyReader io.ReadCloser = r.Body 76 + if r.Header.Get("Content-Encoding") == "gzip" { 77 + gzipReader, err := gzip.NewReader(r.Body) 78 + if err != nil { 79 + gitError(w, err.Error(), http.StatusInternalServerError) 80 + h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err) 81 + return 82 + } 83 + defer gzipReader.Close() 84 + bodyReader = gzipReader 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/x-git-upload-archive-result") 88 + 89 + h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo) 90 + 91 + cmd := service.ServiceCommand{ 92 + GitProtocol: r.Header.Get("Git-Protocol"), 93 + Dir: repo, 94 + Stdout: w, 95 + Stdin: bodyReader, 96 + } 97 + 98 + w.WriteHeader(http.StatusOK) 99 + 100 + if err := cmd.UploadArchive(); err != nil { 101 + h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 102 + return 103 + } 104 + } 105 + 59 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 107 did := chi.URLParam(r, "did") 61 108 name := chi.URLParam(r, "name")
-136
knotserver/ingester.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 - "path/filepath" 11 10 "strings" 12 11 13 12 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 17 16 securejoin "github.com/cyphar/filepath-securejoin" 18 17 "tangled.org/core/api/tangled" 19 18 "tangled.org/core/knotserver/db" 20 - "tangled.org/core/knotserver/git" 21 19 "tangled.org/core/log" 22 20 "tangled.org/core/rbac" 23 - "tangled.org/core/workflow" 24 21 ) 25 22 26 23 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 85 82 return nil 86 83 } 87 84 88 - func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 89 - raw := json.RawMessage(event.Commit.Record) 90 - did := event.Did 91 - 92 - var record tangled.RepoPull 93 - if err := json.Unmarshal(raw, &record); err != nil { 94 - return fmt.Errorf("failed to unmarshal record: %w", err) 95 - } 96 - 97 - l := log.FromContext(ctx) 98 - l = l.With("handler", "processPull") 99 - l = l.With("did", did) 100 - 101 - if record.Target == nil { 102 - return fmt.Errorf("ignoring pull record: target repo is nil") 103 - } 104 - 105 - l = l.With("target_repo", record.Target.Repo) 106 - l = l.With("target_branch", record.Target.Branch) 107 - 108 - if record.Source == nil { 109 - return fmt.Errorf("ignoring pull record: not a branch-based pull request") 110 - } 111 - 112 - if record.Source.Repo != nil { 113 - return fmt.Errorf("ignoring pull record: fork based pull") 114 - } 115 - 116 - repoAt, err := syntax.ParseATURI(record.Target.Repo) 117 - if err != nil { 118 - return fmt.Errorf("failed to parse ATURI: %w", err) 119 - } 120 - 121 - // resolve this aturi to extract the repo record 122 - ident, err := h.resolver.ResolveIdent(ctx, repoAt.Authority().String()) 123 - if err != nil || ident.Handle.IsInvalidHandle() { 124 - return fmt.Errorf("failed to resolve handle: %w", err) 125 - } 126 - 127 - xrpcc := xrpc.Client{ 128 - Host: ident.PDSEndpoint(), 129 - } 130 - 131 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 132 - if err != nil { 133 - return fmt.Errorf("failed to resolver repo: %w", err) 134 - } 135 - 136 - repo := resp.Value.Val.(*tangled.Repo) 137 - 138 - if repo.Knot != h.c.Server.Hostname { 139 - return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 140 - } 141 - 142 - didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 143 - if err != nil { 144 - return fmt.Errorf("failed to construct relative repo path: %w", err) 145 - } 146 - 147 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 148 - if err != nil { 149 - return fmt.Errorf("failed to construct absolute repo path: %w", err) 150 - } 151 - 152 - gr, err := git.Open(repoPath, record.Source.Sha) 153 - if err != nil { 154 - return fmt.Errorf("failed to open git repository: %w", err) 155 - } 156 - 157 - workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 158 - if err != nil { 159 - return fmt.Errorf("failed to open workflow directory: %w", err) 160 - } 161 - 162 - var pipeline workflow.RawPipeline 163 - for _, e := range workflowDir { 164 - if !e.IsFile() { 165 - continue 166 - } 167 - 168 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 169 - contents, err := gr.RawContent(fpath) 170 - if err != nil { 171 - continue 172 - } 173 - 174 - pipeline = append(pipeline, workflow.RawWorkflow{ 175 - Name: e.Name, 176 - Contents: contents, 177 - }) 178 - } 179 - 180 - trigger := tangled.Pipeline_PullRequestTriggerData{ 181 - Action: "create", 182 - SourceBranch: record.Source.Branch, 183 - SourceSha: record.Source.Sha, 184 - TargetBranch: record.Target.Branch, 185 - } 186 - 187 - compiler := workflow.Compiler{ 188 - Trigger: tangled.Pipeline_TriggerMetadata{ 189 - Kind: string(workflow.TriggerKindPullRequest), 190 - PullRequest: &trigger, 191 - Repo: &tangled.Pipeline_TriggerRepo{ 192 - Did: ident.DID.String(), 193 - Knot: repo.Knot, 194 - Repo: repo.Name, 195 - }, 196 - }, 197 - } 198 - 199 - cp := compiler.Compile(compiler.Parse(pipeline)) 200 - eventJson, err := json.Marshal(cp) 201 - if err != nil { 202 - return fmt.Errorf("failed to marshal pipeline event: %w", err) 203 - } 204 - 205 - // do not run empty pipelines 206 - if cp.Workflows == nil { 207 - return nil 208 - } 209 - 210 - ev := db.Event{ 211 - Rkey: TID(), 212 - Nsid: tangled.PipelineNSID, 213 - EventJson: string(eventJson), 214 - } 215 - 216 - return h.db.InsertEvent(ev, h.n) 217 - } 218 - 219 85 // duplicated from add collaborator 220 86 func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 221 87 raw := json.RawMessage(event.Commit.Record) ··· 338 204 err = h.processPublicKey(ctx, event) 339 205 case tangled.KnotMemberNSID: 340 206 err = h.processKnotMember(ctx, event) 341 - case tangled.RepoPullNSID: 342 - err = h.processPull(ctx, event) 343 207 case tangled.RepoCollaboratorNSID: 344 208 err = h.processCollaborator(ctx, event) 345 209 }
+1 -109
knotserver/internal.go
··· 23 23 "tangled.org/core/log" 24 24 "tangled.org/core/notifier" 25 25 "tangled.org/core/rbac" 26 - "tangled.org/core/workflow" 27 26 ) 28 27 29 28 type InternalHandle struct { ··· 176 175 } 177 176 178 177 for _, line := range lines { 178 + // TODO: pass pushOptions to refUpdate 179 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 180 180 if err != nil { 181 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) ··· 185 185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 186 if err != nil { 187 187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 - // non-fatal 189 - } 190 - 191 - err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 - if err != nil { 193 - l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 194 188 // non-fatal 195 189 } 196 190 } ··· 241 235 } 242 236 243 237 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244 - } 245 - 246 - func (h *InternalHandle) triggerPipeline( 247 - clientMsgs *[]string, 248 - line git.PostReceiveLine, 249 - gitUserDid string, 250 - repoDid string, 251 - repoName string, 252 - pushOptions PushOptions, 253 - ) error { 254 - if pushOptions.skipCi { 255 - return nil 256 - } 257 - 258 - didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 259 - if err != nil { 260 - return err 261 - } 262 - 263 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 264 - if err != nil { 265 - return err 266 - } 267 - 268 - gr, err := git.Open(repoPath, line.Ref) 269 - if err != nil { 270 - return err 271 - } 272 - 273 - workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 274 - if err != nil { 275 - return err 276 - } 277 - 278 - var pipeline workflow.RawPipeline 279 - for _, e := range workflowDir { 280 - if !e.IsFile() { 281 - continue 282 - } 283 - 284 - fpath := filepath.Join(workflow.WorkflowDir, e.Name) 285 - contents, err := gr.RawContent(fpath) 286 - if err != nil { 287 - continue 288 - } 289 - 290 - pipeline = append(pipeline, workflow.RawWorkflow{ 291 - Name: e.Name, 292 - Contents: contents, 293 - }) 294 - } 295 - 296 - trigger := tangled.Pipeline_PushTriggerData{ 297 - Ref: line.Ref, 298 - OldSha: line.OldSha.String(), 299 - NewSha: line.NewSha.String(), 300 - } 301 - 302 - compiler := workflow.Compiler{ 303 - Trigger: tangled.Pipeline_TriggerMetadata{ 304 - Kind: string(workflow.TriggerKindPush), 305 - Push: &trigger, 306 - Repo: &tangled.Pipeline_TriggerRepo{ 307 - Did: repoDid, 308 - Knot: h.c.Server.Hostname, 309 - Repo: repoName, 310 - }, 311 - }, 312 - } 313 - 314 - cp := compiler.Compile(compiler.Parse(pipeline)) 315 - eventJson, err := json.Marshal(cp) 316 - if err != nil { 317 - return err 318 - } 319 - 320 - for _, e := range compiler.Diagnostics.Errors { 321 - *clientMsgs = append(*clientMsgs, e.String()) 322 - } 323 - 324 - if pushOptions.verboseCi { 325 - if compiler.Diagnostics.IsEmpty() { 326 - *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 327 - } 328 - 329 - for _, w := range compiler.Diagnostics.Warnings { 330 - *clientMsgs = append(*clientMsgs, w.String()) 331 - } 332 - } 333 - 334 - // do not run empty pipelines 335 - if cp.Workflows == nil { 336 - return nil 337 - } 338 - 339 - event := db.Event{ 340 - Rkey: TID(), 341 - Nsid: tangled.PipelineNSID, 342 - EventJson: string(eventJson), 343 - } 344 - 345 - return h.db.InsertEvent(event, h.n) 346 238 } 347 239 348 240 func (h *InternalHandle) emitCompareLink(
+1
knotserver/router.go
··· 82 82 r.Route("/{name}", func(r chi.Router) { 83 83 // routes for git operations 84 84 r.Get("/info/refs", h.InfoRefs) 85 + r.Post("/git-upload-archive", h.UploadArchive) 85 86 r.Post("/git-upload-pack", h.UploadPack) 86 87 r.Post("/git-receive-pack", h.ReceivePack) 87 88 })
+1 -2
knotserver/server.go
··· 64 64 logger.Info("running in dev mode, signature verification is disabled") 65 65 } 66 66 67 - db, err := db.Setup(c.Server.DBPath) 67 + db, err := db.Setup(ctx, c.Server.DBPath) 68 68 if err != nil { 69 69 return fmt.Errorf("failed to load db: %w", err) 70 70 } ··· 79 79 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 80 80 tangled.PublicKeyNSID, 81 81 tangled.KnotMemberNSID, 82 - tangled.RepoPullNSID, 83 82 tangled.RepoCollaboratorNSID, 84 83 }, nil, log.SubLogger(logger, "jetstream"), db, true, c.Server.LogDids) 85 84 if err != nil {
+33
lexicons/pipeline/cancelPipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline.cancelPipeline", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Cancel a running pipeline", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["repo", "pipeline", "workflow"], 13 + "properties": { 14 + "repo": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "repo at-uri, spindle can't resolve repo from pipeline at-uri yet" 18 + }, 19 + "pipeline": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "pipeline at-uri" 23 + }, 24 + "workflow": { 25 + "type": "string", 26 + "description": "workflow name" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+6
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 + [mod."github.com/hashicorp/go-version"] 308 + version = "v1.8.0" 309 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 307 310 [mod."github.com/hashicorp/golang-lru"] 308 311 version = "v1.0.2" 309 312 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" ··· 530 533 [mod."github.com/yuin/goldmark"] 531 534 version = "v1.7.13" 532 535 hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE=" 536 + [mod."github.com/yuin/goldmark-emoji"] 537 + version = "v1.0.6" 538 + hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY=" 533 539 [mod."github.com/yuin/goldmark-highlighting/v2"] 534 540 version = "v2.0.0-20230729083705-37449abec8cc" 535 541 hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+64
nix/modules/bluesky-jetstream.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-jetstream; 8 + in 9 + with lib; { 10 + options.services.bluesky-jetstream = { 11 + enable = mkEnableOption "jetstream server"; 12 + package = mkPackageOption pkgs "bluesky-jetstream" {}; 13 + 14 + # dataDir = mkOption { 15 + # type = types.str; 16 + # default = "/var/lib/jetstream"; 17 + # description = "directory to store data (pebbleDB)"; 18 + # }; 19 + livenessTtl = mkOption { 20 + type = types.int; 21 + default = 15; 22 + description = "time to restart when no event detected (seconds)"; 23 + }; 24 + websocketUrl = mkOption { 25 + type = types.str; 26 + default = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos"; 27 + description = "full websocket path to the ATProto SubscribeRepos XRPC endpoint"; 28 + }; 29 + }; 30 + config = mkIf cfg.enable { 31 + systemd.services.bluesky-jetstream = { 32 + description = "bluesky jetstream"; 33 + after = ["network.target" "pds.service"]; 34 + wantedBy = ["multi-user.target"]; 35 + 36 + serviceConfig = { 37 + User = "jetstream"; 38 + Group = "jetstream"; 39 + StateDirectory = "jetstream"; 40 + StateDirectoryMode = "0755"; 41 + # preStart = '' 42 + # mkdir -p "${cfg.dataDir}" 43 + # chown -R jetstream:jetstream "${cfg.dataDir}" 44 + # ''; 45 + # WorkingDirectory = cfg.dataDir; 46 + Environment = [ 47 + "JETSTREAM_DATA_DIR=/var/lib/jetstream/data" 48 + "JETSTREAM_LIVENESS_TTL=${toString cfg.livenessTtl}s" 49 + "JETSTREAM_WS_URL=${cfg.websocketUrl}" 50 + ]; 51 + ExecStart = getExe cfg.package; 52 + Restart = "always"; 53 + RestartSec = 5; 54 + }; 55 + }; 56 + users = { 57 + users.jetstream = { 58 + group = "jetstream"; 59 + isSystemUser = true; 60 + }; 61 + groups.jetstream = {}; 62 + }; 63 + }; 64 + }
+48
nix/modules/bluesky-relay.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.bluesky-relay; 8 + in 9 + with lib; { 10 + options.services.bluesky-relay = { 11 + enable = mkEnableOption "relay server"; 12 + package = mkPackageOption pkgs "bluesky-relay" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + systemd.services.bluesky-relay = { 16 + description = "bluesky relay"; 17 + after = ["network.target" "pds.service"]; 18 + wantedBy = ["multi-user.target"]; 19 + 20 + serviceConfig = { 21 + User = "relay"; 22 + Group = "relay"; 23 + StateDirectory = "relay"; 24 + StateDirectoryMode = "0755"; 25 + Environment = [ 26 + "RELAY_ADMIN_PASSWORD=password" 27 + "RELAY_PLC_HOST=https://plc.tngl.boltless.dev" 28 + "DATABASE_URL=sqlite:///var/lib/relay/relay.sqlite" 29 + "RELAY_IP_BIND=:2470" 30 + "RELAY_PERSIST_DIR=/var/lib/relay" 31 + "RELAY_DISABLE_REQUEST_CRAWL=0" 32 + "RELAY_INITIAL_SEQ_NUMBER=1" 33 + "RELAY_ALLOW_INSECURE_HOSTS=1" 34 + ]; 35 + ExecStart = "${getExe cfg.package} serve"; 36 + Restart = "always"; 37 + RestartSec = 5; 38 + }; 39 + }; 40 + users = { 41 + users.relay = { 42 + group = "relay"; 43 + isSystemUser = true; 44 + }; 45 + groups.relay = {}; 46 + }; 47 + }; 48 + }
+76
nix/modules/did-method-plc.nix
··· 1 + { 2 + config, 3 + pkgs, 4 + lib, 5 + ... 6 + }: let 7 + cfg = config.services.did-method-plc; 8 + in 9 + with lib; { 10 + options.services.did-method-plc = { 11 + enable = mkEnableOption "did-method-plc server"; 12 + package = mkPackageOption pkgs "did-method-plc" {}; 13 + }; 14 + config = mkIf cfg.enable { 15 + services.postgresql = { 16 + enable = true; 17 + package = pkgs.postgresql_14; 18 + ensureDatabases = ["plc"]; 19 + ensureUsers = [ 20 + { 21 + name = "pg"; 22 + # ensurePermissions."DATABASE plc" = "ALL PRIVILEGES"; 23 + } 24 + ]; 25 + authentication = '' 26 + local all all trust 27 + host all all 127.0.0.1/32 trust 28 + ''; 29 + }; 30 + systemd.services.did-method-plc = { 31 + description = "did-method-plc"; 32 + 33 + after = ["postgresql.service"]; 34 + wants = ["postgresql.service"]; 35 + wantedBy = ["multi-user.target"]; 36 + 37 + environment = let 38 + db_creds_json = builtins.toJSON { 39 + username = "pg"; 40 + password = ""; 41 + host = "127.0.0.1"; 42 + port = 5432; 43 + }; 44 + in { 45 + # TODO: inherit from config 46 + DEBUG_MODE = "1"; 47 + LOG_ENABLED = "true"; 48 + LOG_LEVEL = "debug"; 49 + LOG_DESTINATION = "1"; 50 + ENABLE_MIGRATIONS = "true"; 51 + DB_CREDS_JSON = db_creds_json; 52 + DB_MIGRATE_CREDS_JSON = db_creds_json; 53 + PLC_VERSION = "0.0.1"; 54 + PORT = "8080"; 55 + }; 56 + 57 + serviceConfig = { 58 + ExecStart = getExe cfg.package; 59 + User = "plc"; 60 + Group = "plc"; 61 + StateDirectory = "plc"; 62 + StateDirectoryMode = "0755"; 63 + Restart = "always"; 64 + 65 + # Hardening 66 + }; 67 + }; 68 + users = { 69 + users.plc = { 70 + group = "plc"; 71 + isSystemUser = true; 72 + }; 73 + groups.plc = {}; 74 + }; 75 + }; 76 + }
+46 -12
nix/modules/spindle.nix
··· 1 1 { 2 2 config, 3 + pkgs, 3 4 lib, 4 5 ... 5 6 }: let ··· 17 18 type = types.package; 18 19 description = "Package to use for the spindle"; 19 20 }; 21 + tap-package = mkOption { 22 + type = types.package; 23 + description = "Package to use for the spindle"; 24 + }; 25 + 26 + atpRelayUrl = mkOption { 27 + type = types.str; 28 + default = "https://relay1.us-east.bsky.network"; 29 + description = "atproto relay"; 30 + }; 20 31 21 32 server = { 22 33 listenAddr = mkOption { ··· 25 36 description = "Address to listen on"; 26 37 }; 27 38 28 - dbPath = mkOption { 39 + stateDir = mkOption { 29 40 type = types.path; 30 - default = "/var/lib/spindle/spindle.db"; 31 - description = "Path to the database file"; 41 + default = "/var/lib/spindle"; 42 + description = "Tangled spindle data directory"; 32 43 }; 33 44 34 45 hostname = mkOption { ··· 41 52 type = types.str; 42 53 default = "https://plc.directory"; 43 54 description = "atproto PLC directory"; 44 - }; 45 - 46 - jetstreamEndpoint = mkOption { 47 - type = types.str; 48 - default = "wss://jetstream1.us-west.bsky.network/subscribe"; 49 - description = "Jetstream endpoint to subscribe to"; 50 55 }; 51 56 52 57 dev = mkOption { ··· 114 119 config = mkIf cfg.enable { 115 120 virtualisation.docker.enable = true; 116 121 122 + systemd.services.spindle-tap = { 123 + description = "spindle tap service"; 124 + after = ["network.target" "docker.service"]; 125 + wantedBy = ["multi-user.target"]; 126 + serviceConfig = { 127 + LogsDirectory = "spindle-tap"; 128 + StateDirectory = "spindle-tap"; 129 + Environment = [ 130 + "TAP_BIND=:2480" 131 + "TAP_PLC_URL=${cfg.server.plcUrl}" 132 + "TAP_RELAY_URL=${cfg.atpRelayUrl}" 133 + "TAP_DATABASE_URL=sqlite:///var/lib/spindle-tap/tap.db" 134 + "TAP_RETRY_TIMEOUT=3s" 135 + "TAP_COLLECTION_FILTERS=${concatStringsSep "," [ 136 + "sh.tangled.repo" 137 + "sh.tangled.repo.collaborator" 138 + "sh.tangled.spindle.member" 139 + "sh.tangled.repo.pull" 140 + ]}" 141 + # temporary hack to listen for repo.pull from non-tangled users 142 + "TAP_SIGNAL_COLLECTION=sh.tangled.repo.pull" 143 + ]; 144 + ExecStart = "${getExe cfg.tap-package} run"; 145 + }; 146 + }; 147 + 117 148 systemd.services.spindle = { 118 149 description = "spindle service"; 119 - after = ["network.target" "docker.service"]; 150 + after = ["network.target" "docker.service" "spindle-tap.service"]; 120 151 wantedBy = ["multi-user.target"]; 152 + path = [ 153 + pkgs.git 154 + ]; 121 155 serviceConfig = { 122 156 LogsDirectory = "spindle"; 123 157 StateDirectory = "spindle"; 124 158 Environment = [ 125 159 "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 126 - "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 160 + "SPINDLE_SERVER_DATA_DIR=${cfg.server.stateDir}" 127 161 "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 128 162 "SPINDLE_SERVER_PLC_URL=${cfg.server.plcUrl}" 129 - "SPINDLE_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 130 163 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 131 164 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 132 165 "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" ··· 134 167 "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 135 168 "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 136 169 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 170 + "SPINDLE_SERVER_TAP_URL=http://localhost:2480" 137 171 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 138 172 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 139 173 ];
+20
nix/pkgs/bluesky-jetstream.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-jetstream"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "jetstream"; 11 + rev = "7d7efa58d7f14101a80ccc4f1085953948b7d5de"; 12 + sha256 = "sha256-1e9SL/8gaDPMA4YZed51ffzgpkptbMd0VTbTTDbPTFw="; 13 + }; 14 + subPackages = ["cmd/jetstream"]; 15 + vendorHash = "sha256-/21XJQH6fo9uPzlABUAbdBwt1O90odmppH6gXu2wkiQ="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "jetstream"; 19 + }; 20 + }
+20
nix/pkgs/bluesky-relay.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "bluesky-relay"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "boltlessengineer"; 10 + repo = "indigo"; 11 + rev = "7fe70a304d795b998f354d2b7b2050b909709c99"; 12 + sha256 = "sha256-+h34x67cqH5t30+8rua53/ucvbn3BanrmH0Og3moHok="; 13 + }; 14 + subPackages = ["cmd/relay"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "relay"; 19 + }; 20 + }
+65
nix/pkgs/did-method-plc.nix
··· 1 + # inspired by https://github.com/NixOS/nixpkgs/blob/333bfb7c258fab089a834555ea1c435674c459b4/pkgs/by-name/ga/gatsby-cli/package.nix 2 + { 3 + lib, 4 + stdenv, 5 + fetchFromGitHub, 6 + fetchYarnDeps, 7 + yarnConfigHook, 8 + yarnBuildHook, 9 + nodejs, 10 + makeBinaryWrapper, 11 + }: 12 + stdenv.mkDerivation (finalAttrs: { 13 + pname = "did-method-plc"; 14 + version = "0.0.1"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "did-method-plc"; 18 + repo = "did-method-plc"; 19 + rev = "158ba5535ac3da4fd4309954bde41deab0b45972"; 20 + sha256 = "sha256-O5smubbrnTDMCvL6iRyMXkddr5G7YHxkQRVMRULHanQ="; 21 + }; 22 + postPatch = '' 23 + # remove dd-trace dependency 24 + sed -i '3d' packages/server/service/index.js 25 + ''; 26 + 27 + yarnOfflineCache = fetchYarnDeps { 28 + yarnLock = finalAttrs.src + "/yarn.lock"; 29 + hash = "sha256-g8GzaAbWSnWwbQjJMV2DL5/ZlWCCX0sRkjjvX3tqU4Y="; 30 + }; 31 + 32 + nativeBuildInputs = [ 33 + yarnConfigHook 34 + yarnBuildHook 35 + nodejs 36 + makeBinaryWrapper 37 + ]; 38 + yarnBuildScript = "lerna"; 39 + yarnBuildFlags = [ 40 + "run" 41 + "build" 42 + "--scope" 43 + "@did-plc/server" 44 + "--include-dependencies" 45 + ]; 46 + 47 + installPhase = '' 48 + runHook preInstall 49 + 50 + mkdir -p $out/lib/node_modules/ 51 + mv packages/ $out/lib/packages/ 52 + mv node_modules/* $out/lib/node_modules/ 53 + 54 + makeWrapper ${lib.getExe nodejs} $out/bin/plc \ 55 + --add-flags $out/lib/packages/server/service/index.js \ 56 + --add-flags --enable-source-maps \ 57 + --set NODE_PATH $out/lib/node_modules 58 + 59 + runHook postInstall 60 + ''; 61 + 62 + meta = { 63 + mainProgram = "plc"; 64 + }; 65 + })
+41
nix/pkgs/docs.nix
··· 1 + { 2 + pandoc, 3 + tailwindcss, 4 + runCommandLocal, 5 + inter-fonts-src, 6 + ibm-plex-mono-src, 7 + lucide-src, 8 + src, 9 + }: 10 + runCommandLocal "docs" {} '' 11 + mkdir -p working 12 + 13 + # copy templates, themes, styles, filters to working directory 14 + cp ${src}/docs/*.html working/ 15 + cp ${src}/docs/*.theme working/ 16 + cp ${src}/docs/*.css working/ 17 + 18 + # icons 19 + cp -rf ${lucide-src}/*.svg working/ 20 + 21 + # content 22 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 + -o $out/ \ 24 + -t chunkedhtml \ 25 + --variable toc \ 26 + --toc-depth=2 \ 27 + --css=stylesheet.css \ 28 + --chunk-template="%i.html" \ 29 + --highlight-style=working/highlight.theme \ 30 + --template=working/template.html 31 + 32 + # fonts 33 + mkdir -p $out/static/fonts 34 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 $out/static/fonts/ 35 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 $out/static/fonts/ 36 + cp -f ${inter-fonts-src}/InterVariable*.ttf $out/static/fonts/ 37 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 $out/static/fonts/ 38 + 39 + # styles 40 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/stylesheet.css 41 + ''
+7 -5
nix/pkgs/sqlite-lib.nix
··· 1 1 { 2 - gcc, 3 2 stdenv, 4 3 sqlite-lib-src, 5 4 }: 6 5 stdenv.mkDerivation { 7 6 name = "sqlite-lib"; 8 7 src = sqlite-lib-src; 9 - nativeBuildInputs = [gcc]; 8 + 10 9 buildPhase = '' 11 - gcc -c sqlite3.c 12 - ar rcs libsqlite3.a sqlite3.o 13 - ranlib libsqlite3.a 10 + $CC -c sqlite3.c 11 + $AR rcs libsqlite3.a sqlite3.o 12 + $RANLIB libsqlite3.a 13 + ''; 14 + 15 + installPhase = '' 14 16 mkdir -p $out/include $out/lib 15 17 cp *.h $out/include 16 18 cp libsqlite3.a $out/lib
+20
nix/pkgs/tap.nix
··· 1 + { 2 + buildGoModule, 3 + fetchFromGitHub, 4 + }: 5 + buildGoModule { 6 + pname = "tap"; 7 + version = "0.1.0"; 8 + src = fetchFromGitHub { 9 + owner = "bluesky-social"; 10 + repo = "indigo"; 11 + rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; 12 + sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; 13 + }; 14 + subPackages = ["cmd/tap"]; 15 + vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 + doCheck = false; 17 + meta = { 18 + mainProgram = "tap"; 19 + }; 20 + }
+9 -3
nix/vm.nix
··· 8 8 var = builtins.getEnv name; 9 9 in 10 10 if var == "" 11 - then throw "\$${name} must be defined, see docs/hacking.md for more details" 11 + then throw "\$${name} must be defined, see https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled for more details" 12 12 else var; 13 13 envVarOr = name: default: let 14 14 var = builtins.getEnv name; ··· 19 19 20 20 plcUrl = envVarOr "TANGLED_VM_PLC_URL" "https://plc.directory"; 21 21 jetstream = envVarOr "TANGLED_VM_JETSTREAM_ENDPOINT" "wss://jetstream1.us-west.bsky.network/subscribe"; 22 + relayUrl = envVarOr "TANGLED_VM_RELAY_URL" "https://relay1.us-east.bsky.network"; 22 23 in 23 24 nixpkgs.lib.nixosSystem { 24 25 inherit system; ··· 57 58 host.port = 6555; 58 59 guest.port = 6555; 59 60 } 61 + { 62 + from = "host"; 63 + host.port = 6556; 64 + guest.port = 2480; 65 + } 60 66 ]; 61 67 sharedDirectories = { 62 68 # We can't use the 9p mounts directly for most of these ··· 95 101 }; 96 102 services.tangled.spindle = { 97 103 enable = true; 104 + atpRelayUrl = relayUrl; 98 105 server = { 99 106 owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 100 107 hostname = envVarOr "TANGLED_VM_SPINDLE_HOST" "localhost:6555"; 101 108 plcUrl = plcUrl; 102 - jetstreamEndpoint = jetstream; 103 109 listenAddr = "0.0.0.0:6555"; 104 110 dev = true; 105 111 queueSize = 100; ··· 134 140 }; 135 141 in { 136 142 knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled.knot.stateDir; 137 - spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled.spindle.server.dbPath); 143 + spindle = mkDataSyncScripts "/mnt/spindle-data" config.services.tangled.spindle.server.stateDir; 138 144 }; 139 145 }) 140 146 ];
+10
orm/orm.go
··· 20 20 } 21 21 defer tx.Rollback() 22 22 23 + _, err = tx.Exec(` 24 + create table if not exists migrations ( 25 + id integer primary key autoincrement, 26 + name text unique 27 + ); 28 + `) 29 + if err != nil { 30 + return fmt.Errorf("creating migrations table: %w", err) 31 + } 32 + 23 33 var exists bool 24 34 err = tx.QueryRow("select exists (select 1 from migrations where name = ?)", name).Scan(&exists) 25 35 if err != nil {
+52
rbac2/bytesadapter/adapter.go
··· 1 + package bytesadapter 2 + 3 + import ( 4 + "bufio" 5 + "bytes" 6 + "errors" 7 + "strings" 8 + 9 + "github.com/casbin/casbin/v2/model" 10 + "github.com/casbin/casbin/v2/persist" 11 + ) 12 + 13 + var ( 14 + errNotImplemented = errors.New("not implemented") 15 + ) 16 + 17 + type Adapter struct { 18 + b []byte 19 + } 20 + 21 + var _ persist.Adapter = &Adapter{} 22 + 23 + func NewAdapter(b []byte) *Adapter { 24 + return &Adapter{b} 25 + } 26 + 27 + func (a *Adapter) LoadPolicy(model model.Model) error { 28 + scanner := bufio.NewScanner(bytes.NewReader(a.b)) 29 + for scanner.Scan() { 30 + line := strings.TrimSpace(scanner.Text()) 31 + if err := persist.LoadPolicyLine(line, model); err != nil { 32 + return err 33 + } 34 + } 35 + return scanner.Err() 36 + } 37 + 38 + func (a *Adapter) AddPolicy(sec string, ptype string, rule []string) error { 39 + return errNotImplemented 40 + } 41 + 42 + func (a *Adapter) RemoveFilteredPolicy(sec string, ptype string, fieldIndex int, fieldValues ...string) error { 43 + return errNotImplemented 44 + } 45 + 46 + func (a *Adapter) RemovePolicy(sec string, ptype string, rule []string) error { 47 + return errNotImplemented 48 + } 49 + 50 + func (a *Adapter) SavePolicy(model model.Model) error { 51 + return errNotImplemented 52 + }
+139
rbac2/rbac2.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "database/sql" 5 + _ "embed" 6 + "fmt" 7 + 8 + adapter "github.com/Blank-Xu/sql-adapter" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/casbin/casbin/v2" 11 + "github.com/casbin/casbin/v2/model" 12 + "github.com/casbin/casbin/v2/util" 13 + "tangled.org/core/rbac2/bytesadapter" 14 + ) 15 + 16 + const ( 17 + Model = ` 18 + [request_definition] 19 + r = sub, dom, obj, act 20 + 21 + [policy_definition] 22 + p = sub, dom, obj, act 23 + 24 + [role_definition] 25 + g = _, _, _ 26 + 27 + [policy_effect] 28 + e = some(where (p.eft == allow)) 29 + 30 + [matchers] 31 + m = g(r.sub, p.sub, r.dom) && keyMatch4(r.dom, p.dom) && r.obj == p.obj && r.act == p.act 32 + ` 33 + ) 34 + 35 + type Enforcer struct { 36 + e *casbin.Enforcer 37 + } 38 + 39 + //go:embed tangled_policy.csv 40 + var tangledPolicy []byte 41 + 42 + func NewEnforcer(path string) (*Enforcer, error) { 43 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 44 + if err != nil { 45 + return nil, err 46 + } 47 + return NewEnforcerWithDB(db) 48 + } 49 + 50 + func NewEnforcerWithDB(db *sql.DB) (*Enforcer, error) { 51 + m, err := model.NewModelFromString(Model) 52 + if err != nil { 53 + return nil, err 54 + } 55 + 56 + a, err := adapter.NewAdapter(db, "sqlite3", "acl") 57 + if err != nil { 58 + return nil, err 59 + } 60 + 61 + // // PATCH: create unique index to make `AddPoliciesEx` work 62 + // _, err = db.Exec(fmt.Sprintf( 63 + // `create unique index if not exists uq_%[1]s on %[1]s (p_type,v0,v1,v2,v3,v4,v5);`, 64 + // tableName, 65 + // )) 66 + // if err != nil { 67 + // return nil, err 68 + // } 69 + 70 + e, _ := casbin.NewEnforcer() // NewEnforcer() without param won't return error 71 + // e.EnableLog(true) 72 + 73 + // NOTE: casbin clears the model on init, so we should intialize with temporary adapter first 74 + // and then override the adapter to sql-adapter. 75 + // `e.SetModel(m)` after init doesn't work for some reason 76 + if err := e.InitWithModelAndAdapter(m, bytesadapter.NewAdapter(tangledPolicy)); err != nil { 77 + return nil, err 78 + } 79 + 80 + // load dynamic policy from db 81 + e.EnableAutoSave(false) 82 + if err := a.LoadPolicy(e.GetModel()); err != nil { 83 + return nil, err 84 + } 85 + e.AddNamedDomainMatchingFunc("g", "keyMatch4", util.KeyMatch4) 86 + e.BuildRoleLinks() 87 + e.SetAdapter(a) 88 + e.EnableAutoSave(true) 89 + 90 + return &Enforcer{e}, nil 91 + } 92 + 93 + // CaptureModel returns copy of current model. Used for testing 94 + func (e *Enforcer) CaptureModel() model.Model { 95 + return e.e.GetModel().Copy() 96 + } 97 + 98 + func (e *Enforcer) hasImplicitRoleForUser(name string, role string, domain ...string) (bool, error) { 99 + roles, err := e.e.GetImplicitRolesForUser(name, domain...) 100 + if err != nil { 101 + return false, err 102 + } 103 + for _, r := range roles { 104 + if r == role { 105 + return true, nil 106 + } 107 + } 108 + return false, nil 109 + } 110 + 111 + // setRoleForUser sets single user role for specified domain. 112 + // All existing users with that role will be removed. 113 + func (e *Enforcer) setRoleForUser(name string, role string, domain ...string) error { 114 + currentUsers, err := e.e.GetUsersForRole(role, domain...) 115 + if err != nil { 116 + return err 117 + } 118 + 119 + for _, oldUser := range currentUsers { 120 + _, err = e.e.DeleteRoleForUser(oldUser, role, domain...) 121 + if err != nil { 122 + return err 123 + } 124 + } 125 + 126 + _, err = e.e.AddRoleForUser(name, role, domain...) 127 + return err 128 + } 129 + 130 + // validateAtUri enforeces AT-URI to have valid did as authority and match collection NSID. 131 + func validateAtUri(uri syntax.ATURI, expected string) error { 132 + if !uri.Authority().IsDID() { 133 + return fmt.Errorf("expected at-uri with did") 134 + } 135 + if expected != "" && uri.Collection().String() != expected { 136 + return fmt.Errorf("incorrect repo at-uri collection nsid '%s' (expected '%s')", uri.Collection(), expected) 137 + } 138 + return nil 139 + }
+150
rbac2/rbac2_test.go
··· 1 + package rbac2_test 2 + 3 + import ( 4 + "database/sql" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + _ "github.com/mattn/go-sqlite3" 9 + "github.com/stretchr/testify/assert" 10 + "tangled.org/core/rbac2" 11 + ) 12 + 13 + func setup(t *testing.T) *rbac2.Enforcer { 14 + enforcer, err := rbac2.NewEnforcer(":memory:") 15 + assert.NoError(t, err) 16 + 17 + return enforcer 18 + } 19 + 20 + func TestNewEnforcer(t *testing.T) { 21 + db, err := sql.Open("sqlite3", "/tmp/test/test.db?_foreign_keys=1") 22 + assert.NoError(t, err) 23 + 24 + enforcer1, err := rbac2.NewEnforcerWithDB(db) 25 + assert.NoError(t, err) 26 + enforcer1.AddRepo(syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey")) 27 + model1 := enforcer1.CaptureModel() 28 + 29 + enforcer2, err := rbac2.NewEnforcerWithDB(db) 30 + assert.NoError(t, err) 31 + model2 := enforcer2.CaptureModel() 32 + 33 + // model1.GetLogger().EnableLog(true) 34 + // model1.PrintModel() 35 + // model1.PrintPolicy() 36 + // model1.GetLogger().EnableLog(false) 37 + 38 + model2.GetLogger().EnableLog(true) 39 + model2.PrintModel() 40 + model2.PrintPolicy() 41 + model2.GetLogger().EnableLog(false) 42 + 43 + assert.Equal(t, model1, model2) 44 + } 45 + 46 + func TestRepoOwnerPermissions(t *testing.T) { 47 + var ( 48 + e = setup(t) 49 + ok bool 50 + err error 51 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 52 + fooUser = syntax.DID("did:plc:foo") 53 + ) 54 + 55 + assert.NoError(t, e.AddRepo(fooRepo)) 56 + 57 + ok, err = e.IsRepoOwner(fooUser, fooRepo) 58 + assert.NoError(t, err) 59 + assert.True(t, ok, "repo author should be repo owner") 60 + 61 + ok, err = e.IsRepoWriteAllowed(fooUser, fooRepo) 62 + assert.NoError(t, err) 63 + assert.True(t, ok, "repo owner should be able to modify the repo itself") 64 + 65 + ok, err = e.IsRepoCollaborator(fooUser, fooRepo) 66 + assert.NoError(t, err) 67 + assert.True(t, ok, "repo owner should inherit role role:collaborator") 68 + 69 + ok, err = e.IsRepoSettingsWriteAllowed(fooUser, fooRepo) 70 + assert.NoError(t, err) 71 + assert.True(t, ok, "repo owner should inherit collaborator permissions") 72 + } 73 + 74 + func TestRepoCollaboratorPermissions(t *testing.T) { 75 + var ( 76 + e = setup(t) 77 + ok bool 78 + err error 79 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 80 + barUser = syntax.DID("did:plc:bar") 81 + ) 82 + 83 + assert.NoError(t, e.AddRepo(fooRepo)) 84 + assert.NoError(t, e.AddRepoCollaborator(barUser, fooRepo)) 85 + 86 + ok, err = e.IsRepoCollaborator(barUser, fooRepo) 87 + assert.NoError(t, err) 88 + assert.True(t, ok, "should set repo collaborator") 89 + 90 + ok, err = e.IsRepoSettingsWriteAllowed(barUser, fooRepo) 91 + assert.NoError(t, err) 92 + assert.True(t, ok, "repo collaborator should be able to edit repo settings") 93 + 94 + ok, err = e.IsRepoWriteAllowed(barUser, fooRepo) 95 + assert.NoError(t, err) 96 + assert.False(t, ok, "repo collaborator shouldn't be able to modify the repo itself") 97 + } 98 + 99 + func TestGetByRole(t *testing.T) { 100 + var ( 101 + e = setup(t) 102 + err error 103 + fooRepo = syntax.ATURI("at://did:plc:foo/sh.tangled.repo/reporkey") 104 + owner = syntax.DID("did:plc:foo") 105 + collaborator1 = syntax.DID("did:plc:bar") 106 + collaborator2 = syntax.DID("did:plc:baz") 107 + ) 108 + 109 + assert.NoError(t, e.AddRepo(fooRepo)) 110 + assert.NoError(t, e.AddRepoCollaborator(collaborator1, fooRepo)) 111 + assert.NoError(t, e.AddRepoCollaborator(collaborator2, fooRepo)) 112 + 113 + collaborators, err := e.GetRepoCollaborators(fooRepo) 114 + assert.NoError(t, err) 115 + assert.ElementsMatch(t, []syntax.DID{ 116 + owner, 117 + collaborator1, 118 + collaborator2, 119 + }, collaborators) 120 + } 121 + 122 + func TestSpindleOwnerPermissions(t *testing.T) { 123 + var ( 124 + e = setup(t) 125 + ok bool 126 + err error 127 + spindle = syntax.DID("did:web:spindle.example.com") 128 + owner = syntax.DID("did:plc:foo") 129 + member = syntax.DID("did:plc:bar") 130 + ) 131 + 132 + assert.NoError(t, e.SetSpindleOwner(owner, spindle)) 133 + assert.NoError(t, e.AddSpindleMember(member, spindle)) 134 + 135 + ok, err = e.IsSpindleMember(owner, spindle) 136 + assert.NoError(t, err) 137 + assert.True(t, ok, "spindle owner is spindle member") 138 + 139 + ok, err = e.IsSpindleMember(member, spindle) 140 + assert.NoError(t, err) 141 + assert.True(t, ok, "spindle member is spindle member") 142 + 143 + ok, err = e.IsSpindleMemberInviteAllowed(owner, spindle) 144 + assert.NoError(t, err) 145 + assert.True(t, ok, "spindle owner can invite members") 146 + 147 + ok, err = e.IsSpindleMemberInviteAllowed(member, spindle) 148 + assert.NoError(t, err) 149 + assert.False(t, ok, "spindle member cannot invite members") 150 + }
+91
rbac2/repo.go
··· 1 + package rbac2 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // AddRepo adds new repo with its owner to rbac enforcer 12 + func (e *Enforcer) AddRepo(repo syntax.ATURI) error { 13 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 14 + return err 15 + } 16 + user := repo.Authority() 17 + 18 + return e.setRoleForUser(user.String(), "repo:owner", repo.String()) 19 + } 20 + 21 + // DeleteRepo deletes all policies related to the repo 22 + func (e *Enforcer) DeleteRepo(repo syntax.ATURI) error { 23 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 24 + return err 25 + } 26 + 27 + _, err := e.e.DeleteDomains(repo.String()) 28 + return err 29 + } 30 + 31 + // AddRepoCollaborator adds new collaborator to the repo 32 + func (e *Enforcer) AddRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 33 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 34 + return err 35 + } 36 + 37 + _, err := e.e.AddRoleForUser(user.String(), "repo:collaborator", repo.String()) 38 + return err 39 + } 40 + 41 + // RemoveRepoCollaborator removes the collaborator from the repo. 42 + // This won't remove inherited roles like repository owner. 43 + func (e *Enforcer) RemoveRepoCollaborator(user syntax.DID, repo syntax.ATURI) error { 44 + if err := validateAtUri(repo, tangled.RepoNSID); err != nil { 45 + return err 46 + } 47 + 48 + _, err := e.e.DeleteRoleForUser(user.String(), "repo:collaborator", repo.String()) 49 + return err 50 + } 51 + 52 + func (e *Enforcer) GetRepoCollaborators(repo syntax.ATURI) ([]syntax.DID, error) { 53 + var collaborators []syntax.DID 54 + members, err := e.e.GetImplicitUsersForRole("repo:collaborator", repo.String()) 55 + if err != nil { 56 + return nil, err 57 + } 58 + for _, m := range members { 59 + if !strings.HasPrefix(m, "did:") { // skip non-user subjects like 'repo:owner' 60 + continue 61 + } 62 + collaborators = append(collaborators, syntax.DID(m)) 63 + } 64 + 65 + slices.Sort(collaborators) 66 + return slices.Compact(collaborators), nil 67 + } 68 + 69 + func (e *Enforcer) IsRepoOwner(user syntax.DID, repo syntax.ATURI) (bool, error) { 70 + return e.e.HasRoleForUser(user.String(), "repo:owner", repo.String()) 71 + } 72 + 73 + func (e *Enforcer) IsRepoCollaborator(user syntax.DID, repo syntax.ATURI) (bool, error) { 74 + return e.hasImplicitRoleForUser(user.String(), "repo:collaborator", repo.String()) 75 + } 76 + 77 + func (e *Enforcer) IsRepoWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 78 + return e.e.Enforce(user.String(), repo.String(), "/", "write") 79 + } 80 + 81 + func (e *Enforcer) IsRepoSettingsWriteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 82 + return e.e.Enforce(user.String(), repo.String(), "/settings", "write") 83 + } 84 + 85 + func (e *Enforcer) IsRepoCollaboratorInviteAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 86 + return e.e.Enforce(user.String(), repo.String(), "/collaborator", "write") 87 + } 88 + 89 + func (e *Enforcer) IsRepoGitPushAllowed(user syntax.DID, repo syntax.ATURI) (bool, error) { 90 + return e.e.Enforce(user.String(), repo.String(), "/git", "write") 91 + }
+29
rbac2/spindle.go
··· 1 + package rbac2 2 + 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 5 + func (e *Enforcer) SetSpindleOwner(user syntax.DID, spindle syntax.DID) error { 6 + return e.setRoleForUser(user.String(), "server:owner", intoSpindle(spindle)) 7 + } 8 + 9 + func (e *Enforcer) IsSpindleMember(user syntax.DID, spindle syntax.DID) (bool, error) { 10 + return e.hasImplicitRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 11 + } 12 + 13 + func (e *Enforcer) AddSpindleMember(user syntax.DID, spindle syntax.DID) error { 14 + _, err := e.e.AddRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 15 + return err 16 + } 17 + 18 + func (e *Enforcer) RemoveSpindleMember(user syntax.DID, spindle syntax.DID) error { 19 + _, err := e.e.DeleteRoleForUser(user.String(), "server:member", intoSpindle(spindle)) 20 + return err 21 + } 22 + 23 + func (e *Enforcer) IsSpindleMemberInviteAllowed(user syntax.DID, spindle syntax.DID) (bool, error) { 24 + return e.e.Enforce(user.String(), intoSpindle(spindle), "/member", "write") 25 + } 26 + 27 + func intoSpindle(did syntax.DID) string { 28 + return "/spindle/" + did.String() 29 + }
+19
rbac2/tangled_policy.csv
··· 1 + #, policies 2 + #, sub, dom, obj, act 3 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /, write 4 + p, repo:owner, at://{did}/sh.tangled.repo/{rkey}, /collaborator, write 5 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /settings, write 6 + p, repo:collaborator, at://{did}/sh.tangled.repo/{rkey}, /git, write 7 + 8 + p, server:owner, /knot/{did}, /member, write 9 + p, server:member, /knot/{did}, /git, write 10 + 11 + p, server:owner, /spindle/{did}, /member, write 12 + 13 + 14 + #, group policies 15 + #, sub, role, dom 16 + g, repo:owner, repo:collaborator, at://{did}/sh.tangled.repo/{rkey} 17 + 18 + g, server:owner, server:member, /knot/{did} 19 + g, server:owner, server:member, /spindle/{did}
+3 -3
readme.md
··· 10 10 11 11 ## docs 12 12 13 - * [knot hosting guide](/docs/knot-hosting.md) 14 - * [contributing guide](/docs/contributing.md) **please read before opening a PR!** 15 - * [hacking on tangled](/docs/hacking.md) 13 + - [knot hosting guide](https://docs.tangled.org/knot-self-hosting-guide.html#knot-self-hosting-guide) 14 + - [contributing guide](https://docs.tangled.org/contribution-guide.html#contribution-guide) **please read before opening a PR!** 15 + - [hacking on tangled](https://docs.tangled.org/hacking-on-tangled.html#hacking-on-tangled) 16 16 17 17 ## security 18 18
+31
sets/gen.go
··· 1 + package sets 2 + 3 + import ( 4 + "math/rand" 5 + "reflect" 6 + "testing/quick" 7 + ) 8 + 9 + func (_ Set[T]) Generate(rand *rand.Rand, size int) reflect.Value { 10 + s := New[T]() 11 + 12 + var zero T 13 + itemType := reflect.TypeOf(zero) 14 + 15 + for { 16 + if s.Len() >= size { 17 + break 18 + } 19 + 20 + item, ok := quick.Value(itemType, rand) 21 + if !ok { 22 + continue 23 + } 24 + 25 + if val, ok := item.Interface().(T); ok { 26 + s.Insert(val) 27 + } 28 + } 29 + 30 + return reflect.ValueOf(s) 31 + }
+35
sets/readme.txt
··· 1 + sets 2 + ---- 3 + set datastructure for go with generics and iterators. the 4 + api is supposed to mimic rust's std::collections::HashSet api. 5 + 6 + s1 := sets.Collect(slices.Values([]int{1, 2, 3, 4})) 7 + s2 := sets.Collect(slices.Values([]int{1, 2, 3, 4, 5, 6})) 8 + 9 + union := sets.Collect(s1.Union(s2)) 10 + intersect := sets.Collect(s1.Intersection(s2)) 11 + diff := sets.Collect(s1.Difference(s2)) 12 + symdiff := sets.Collect(s1.SymmetricDifference(s2)) 13 + 14 + s1.Len() // 4 15 + s1.Contains(1) // true 16 + s1.IsEmpty() // false 17 + s1.IsSubset(s2) // true 18 + s1.IsSuperset(s2) // false 19 + s1.IsDisjoint(s2) // false 20 + 21 + if exists := s1.Insert(1); exists { 22 + // already existed in set 23 + } 24 + 25 + if existed := s1.Remove(1); existed { 26 + // existed in set, now removed 27 + } 28 + 29 + 30 + testing 31 + ------- 32 + includes property-based tests using the wonderful 33 + testing/quick module! 34 + 35 + go test -v
+174
sets/set.go
··· 1 + package sets 2 + 3 + import ( 4 + "iter" 5 + "maps" 6 + ) 7 + 8 + type Set[T comparable] struct { 9 + data map[T]struct{} 10 + } 11 + 12 + func New[T comparable]() Set[T] { 13 + return Set[T]{ 14 + data: make(map[T]struct{}), 15 + } 16 + } 17 + 18 + func (s *Set[T]) Insert(item T) bool { 19 + _, exists := s.data[item] 20 + s.data[item] = struct{}{} 21 + return !exists 22 + } 23 + 24 + func Singleton[T comparable](item T) Set[T] { 25 + n := New[T]() 26 + _ = n.Insert(item) 27 + return n 28 + } 29 + 30 + func (s *Set[T]) Remove(item T) bool { 31 + _, exists := s.data[item] 32 + if exists { 33 + delete(s.data, item) 34 + } 35 + return exists 36 + } 37 + 38 + func (s Set[T]) Contains(item T) bool { 39 + _, exists := s.data[item] 40 + return exists 41 + } 42 + 43 + func (s Set[T]) Len() int { 44 + return len(s.data) 45 + } 46 + 47 + func (s Set[T]) IsEmpty() bool { 48 + return len(s.data) == 0 49 + } 50 + 51 + func (s *Set[T]) Clear() { 52 + s.data = make(map[T]struct{}) 53 + } 54 + 55 + func (s Set[T]) All() iter.Seq[T] { 56 + return func(yield func(T) bool) { 57 + for item := range s.data { 58 + if !yield(item) { 59 + return 60 + } 61 + } 62 + } 63 + } 64 + 65 + func (s Set[T]) Clone() Set[T] { 66 + return Set[T]{ 67 + data: maps.Clone(s.data), 68 + } 69 + } 70 + 71 + func (s Set[T]) Union(other Set[T]) iter.Seq[T] { 72 + if s.Len() >= other.Len() { 73 + return chain(s.All(), other.Difference(s)) 74 + } else { 75 + return chain(other.All(), s.Difference(other)) 76 + } 77 + } 78 + 79 + func chain[T any](seqs ...iter.Seq[T]) iter.Seq[T] { 80 + return func(yield func(T) bool) { 81 + for _, seq := range seqs { 82 + for item := range seq { 83 + if !yield(item) { 84 + return 85 + } 86 + } 87 + } 88 + } 89 + } 90 + 91 + func (s Set[T]) Intersection(other Set[T]) iter.Seq[T] { 92 + return func(yield func(T) bool) { 93 + for item := range s.data { 94 + if other.Contains(item) { 95 + if !yield(item) { 96 + return 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + func (s Set[T]) Difference(other Set[T]) iter.Seq[T] { 104 + return func(yield func(T) bool) { 105 + for item := range s.data { 106 + if !other.Contains(item) { 107 + if !yield(item) { 108 + return 109 + } 110 + } 111 + } 112 + } 113 + } 114 + 115 + func (s Set[T]) SymmetricDifference(other Set[T]) iter.Seq[T] { 116 + return func(yield func(T) bool) { 117 + for item := range s.data { 118 + if !other.Contains(item) { 119 + if !yield(item) { 120 + return 121 + } 122 + } 123 + } 124 + for item := range other.data { 125 + if !s.Contains(item) { 126 + if !yield(item) { 127 + return 128 + } 129 + } 130 + } 131 + } 132 + } 133 + 134 + func (s Set[T]) IsSubset(other Set[T]) bool { 135 + for item := range s.data { 136 + if !other.Contains(item) { 137 + return false 138 + } 139 + } 140 + return true 141 + } 142 + 143 + func (s Set[T]) IsSuperset(other Set[T]) bool { 144 + return other.IsSubset(s) 145 + } 146 + 147 + func (s Set[T]) IsDisjoint(other Set[T]) bool { 148 + for item := range s.data { 149 + if other.Contains(item) { 150 + return false 151 + } 152 + } 153 + return true 154 + } 155 + 156 + func (s Set[T]) Equal(other Set[T]) bool { 157 + if s.Len() != other.Len() { 158 + return false 159 + } 160 + for item := range s.data { 161 + if !other.Contains(item) { 162 + return false 163 + } 164 + } 165 + return true 166 + } 167 + 168 + func Collect[T comparable](seq iter.Seq[T]) Set[T] { 169 + result := New[T]() 170 + for item := range seq { 171 + result.Insert(item) 172 + } 173 + return result 174 + }
+411
sets/set_test.go
··· 1 + package sets 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + "testing/quick" 7 + ) 8 + 9 + func TestNew(t *testing.T) { 10 + s := New[int]() 11 + if s.Len() != 0 { 12 + t.Errorf("New set should be empty, got length %d", s.Len()) 13 + } 14 + if !s.IsEmpty() { 15 + t.Error("New set should be empty") 16 + } 17 + } 18 + 19 + func TestFromSlice(t *testing.T) { 20 + s := Collect(slices.Values([]int{1, 2, 3, 2, 1})) 21 + if s.Len() != 3 { 22 + t.Errorf("Expected length 3, got %d", s.Len()) 23 + } 24 + if !s.Contains(1) || !s.Contains(2) || !s.Contains(3) { 25 + t.Error("Set should contain all unique elements from slice") 26 + } 27 + } 28 + 29 + func TestInsert(t *testing.T) { 30 + s := New[string]() 31 + 32 + if !s.Insert("hello") { 33 + t.Error("First insert should return true") 34 + } 35 + if s.Insert("hello") { 36 + t.Error("Duplicate insert should return false") 37 + } 38 + if s.Len() != 1 { 39 + t.Errorf("Expected length 1, got %d", s.Len()) 40 + } 41 + } 42 + 43 + func TestRemove(t *testing.T) { 44 + s := Collect(slices.Values([]int{1, 2, 3})) 45 + 46 + if !s.Remove(2) { 47 + t.Error("Remove existing element should return true") 48 + } 49 + if s.Remove(2) { 50 + t.Error("Remove non-existing element should return false") 51 + } 52 + if s.Contains(2) { 53 + t.Error("Element should be removed") 54 + } 55 + if s.Len() != 2 { 56 + t.Errorf("Expected length 2, got %d", s.Len()) 57 + } 58 + } 59 + 60 + func TestContains(t *testing.T) { 61 + s := Collect(slices.Values([]int{1, 2, 3})) 62 + 63 + if !s.Contains(1) { 64 + t.Error("Should contain 1") 65 + } 66 + if s.Contains(4) { 67 + t.Error("Should not contain 4") 68 + } 69 + } 70 + 71 + func TestClear(t *testing.T) { 72 + s := Collect(slices.Values([]int{1, 2, 3})) 73 + s.Clear() 74 + 75 + if !s.IsEmpty() { 76 + t.Error("Set should be empty after clear") 77 + } 78 + if s.Len() != 0 { 79 + t.Errorf("Expected length 0, got %d", s.Len()) 80 + } 81 + } 82 + 83 + func TestIterator(t *testing.T) { 84 + s := Collect(slices.Values([]int{1, 2, 3})) 85 + var items []int 86 + 87 + for item := range s.All() { 88 + items = append(items, item) 89 + } 90 + 91 + slices.Sort(items) 92 + expected := []int{1, 2, 3} 93 + if !slices.Equal(items, expected) { 94 + t.Errorf("Expected %v, got %v", expected, items) 95 + } 96 + } 97 + 98 + func TestClone(t *testing.T) { 99 + s1 := Collect(slices.Values([]int{1, 2, 3})) 100 + s2 := s1.Clone() 101 + 102 + if !s1.Equal(s2) { 103 + t.Error("Cloned set should be equal to original") 104 + } 105 + 106 + s2.Insert(4) 107 + if s1.Contains(4) { 108 + t.Error("Modifying clone should not affect original") 109 + } 110 + } 111 + 112 + func TestUnion(t *testing.T) { 113 + s1 := Collect(slices.Values([]int{1, 2})) 114 + s2 := Collect(slices.Values([]int{2, 3})) 115 + 116 + result := Collect(s1.Union(s2)) 117 + expected := Collect(slices.Values([]int{1, 2, 3})) 118 + 119 + if !result.Equal(expected) { 120 + t.Errorf("Expected %v, got %v", expected, result) 121 + } 122 + } 123 + 124 + func TestIntersection(t *testing.T) { 125 + s1 := Collect(slices.Values([]int{1, 2, 3})) 126 + s2 := Collect(slices.Values([]int{2, 3, 4})) 127 + 128 + expected := Collect(slices.Values([]int{2, 3})) 129 + result := Collect(s1.Intersection(s2)) 130 + 131 + if !result.Equal(expected) { 132 + t.Errorf("Expected %v, got %v", expected, result) 133 + } 134 + } 135 + 136 + func TestDifference(t *testing.T) { 137 + s1 := Collect(slices.Values([]int{1, 2, 3})) 138 + s2 := Collect(slices.Values([]int{2, 3, 4})) 139 + 140 + expected := Collect(slices.Values([]int{1})) 141 + result := Collect(s1.Difference(s2)) 142 + 143 + if !result.Equal(expected) { 144 + t.Errorf("Expected %v, got %v", expected, result) 145 + } 146 + } 147 + 148 + func TestSymmetricDifference(t *testing.T) { 149 + s1 := Collect(slices.Values([]int{1, 2, 3})) 150 + s2 := Collect(slices.Values([]int{2, 3, 4})) 151 + 152 + expected := Collect(slices.Values([]int{1, 4})) 153 + result := Collect(s1.SymmetricDifference(s2)) 154 + 155 + if !result.Equal(expected) { 156 + t.Errorf("Expected %v, got %v", expected, result) 157 + } 158 + } 159 + 160 + func TestSymmetricDifferenceCommutativeProperty(t *testing.T) { 161 + s1 := Collect(slices.Values([]int{1, 2, 3})) 162 + s2 := Collect(slices.Values([]int{2, 3, 4})) 163 + 164 + result1 := Collect(s1.SymmetricDifference(s2)) 165 + result2 := Collect(s2.SymmetricDifference(s1)) 166 + 167 + if !result1.Equal(result2) { 168 + t.Errorf("Expected %v, got %v", result1, result2) 169 + } 170 + } 171 + 172 + func TestIsSubset(t *testing.T) { 173 + s1 := Collect(slices.Values([]int{1, 2})) 174 + s2 := Collect(slices.Values([]int{1, 2, 3})) 175 + 176 + if !s1.IsSubset(s2) { 177 + t.Error("s1 should be subset of s2") 178 + } 179 + if s2.IsSubset(s1) { 180 + t.Error("s2 should not be subset of s1") 181 + } 182 + } 183 + 184 + func TestIsSuperset(t *testing.T) { 185 + s1 := Collect(slices.Values([]int{1, 2, 3})) 186 + s2 := Collect(slices.Values([]int{1, 2})) 187 + 188 + if !s1.IsSuperset(s2) { 189 + t.Error("s1 should be superset of s2") 190 + } 191 + if s2.IsSuperset(s1) { 192 + t.Error("s2 should not be superset of s1") 193 + } 194 + } 195 + 196 + func TestIsDisjoint(t *testing.T) { 197 + s1 := Collect(slices.Values([]int{1, 2})) 198 + s2 := Collect(slices.Values([]int{3, 4})) 199 + s3 := Collect(slices.Values([]int{2, 3})) 200 + 201 + if !s1.IsDisjoint(s2) { 202 + t.Error("s1 and s2 should be disjoint") 203 + } 204 + if s1.IsDisjoint(s3) { 205 + t.Error("s1 and s3 should not be disjoint") 206 + } 207 + } 208 + 209 + func TestEqual(t *testing.T) { 210 + s1 := Collect(slices.Values([]int{1, 2, 3})) 211 + s2 := Collect(slices.Values([]int{3, 2, 1})) 212 + s3 := Collect(slices.Values([]int{1, 2})) 213 + 214 + if !s1.Equal(s2) { 215 + t.Error("s1 and s2 should be equal") 216 + } 217 + if s1.Equal(s3) { 218 + t.Error("s1 and s3 should not be equal") 219 + } 220 + } 221 + 222 + func TestCollect(t *testing.T) { 223 + s1 := Collect(slices.Values([]int{1, 2})) 224 + s2 := Collect(slices.Values([]int{2, 3})) 225 + 226 + unionSet := Collect(s1.Union(s2)) 227 + if unionSet.Len() != 3 { 228 + t.Errorf("Expected union set length 3, got %d", unionSet.Len()) 229 + } 230 + if !unionSet.Contains(1) || !unionSet.Contains(2) || !unionSet.Contains(3) { 231 + t.Error("Union set should contain 1, 2, and 3") 232 + } 233 + 234 + diffSet := Collect(s1.Difference(s2)) 235 + if diffSet.Len() != 1 { 236 + t.Errorf("Expected difference set length 1, got %d", diffSet.Len()) 237 + } 238 + if !diffSet.Contains(1) { 239 + t.Error("Difference set should contain 1") 240 + } 241 + } 242 + 243 + func TestPropertySingleonLen(t *testing.T) { 244 + f := func(item int) bool { 245 + single := Singleton(item) 246 + return single.Len() == 1 247 + } 248 + 249 + if err := quick.Check(f, nil); err != nil { 250 + t.Error(err) 251 + } 252 + } 253 + 254 + func TestPropertyInsertIdempotent(t *testing.T) { 255 + f := func(s Set[int], item int) bool { 256 + clone := s.Clone() 257 + 258 + clone.Insert(item) 259 + firstLen := clone.Len() 260 + 261 + clone.Insert(item) 262 + secondLen := clone.Len() 263 + 264 + return firstLen == secondLen 265 + } 266 + 267 + if err := quick.Check(f, nil); err != nil { 268 + t.Error(err) 269 + } 270 + } 271 + 272 + func TestPropertyUnionCommutative(t *testing.T) { 273 + f := func(s1 Set[int], s2 Set[int]) bool { 274 + union1 := Collect(s1.Union(s2)) 275 + union2 := Collect(s2.Union(s1)) 276 + return union1.Equal(union2) 277 + } 278 + 279 + if err := quick.Check(f, nil); err != nil { 280 + t.Error(err) 281 + } 282 + } 283 + 284 + func TestPropertyIntersectionCommutative(t *testing.T) { 285 + f := func(s1 Set[int], s2 Set[int]) bool { 286 + inter1 := Collect(s1.Intersection(s2)) 287 + inter2 := Collect(s2.Intersection(s1)) 288 + return inter1.Equal(inter2) 289 + } 290 + 291 + if err := quick.Check(f, nil); err != nil { 292 + t.Error(err) 293 + } 294 + } 295 + 296 + func TestPropertyCloneEquals(t *testing.T) { 297 + f := func(s Set[int]) bool { 298 + clone := s.Clone() 299 + return s.Equal(clone) 300 + } 301 + 302 + if err := quick.Check(f, nil); err != nil { 303 + t.Error(err) 304 + } 305 + } 306 + 307 + func TestPropertyIntersectionIsSubset(t *testing.T) { 308 + f := func(s1 Set[int], s2 Set[int]) bool { 309 + inter := Collect(s1.Intersection(s2)) 310 + return inter.IsSubset(s1) && inter.IsSubset(s2) 311 + } 312 + 313 + if err := quick.Check(f, nil); err != nil { 314 + t.Error(err) 315 + } 316 + } 317 + 318 + func TestPropertyUnionIsSuperset(t *testing.T) { 319 + f := func(s1 Set[int], s2 Set[int]) bool { 320 + union := Collect(s1.Union(s2)) 321 + return union.IsSuperset(s1) && union.IsSuperset(s2) 322 + } 323 + 324 + if err := quick.Check(f, nil); err != nil { 325 + t.Error(err) 326 + } 327 + } 328 + 329 + func TestPropertyDifferenceDisjoint(t *testing.T) { 330 + f := func(s1 Set[int], s2 Set[int]) bool { 331 + diff := Collect(s1.Difference(s2)) 332 + return diff.IsDisjoint(s2) 333 + } 334 + 335 + if err := quick.Check(f, nil); err != nil { 336 + t.Error(err) 337 + } 338 + } 339 + 340 + func TestPropertySymmetricDifferenceCommutative(t *testing.T) { 341 + f := func(s1 Set[int], s2 Set[int]) bool { 342 + symDiff1 := Collect(s1.SymmetricDifference(s2)) 343 + symDiff2 := Collect(s2.SymmetricDifference(s1)) 344 + return symDiff1.Equal(symDiff2) 345 + } 346 + 347 + if err := quick.Check(f, nil); err != nil { 348 + t.Error(err) 349 + } 350 + } 351 + 352 + func TestPropertyRemoveWorks(t *testing.T) { 353 + f := func(s Set[int], item int) bool { 354 + clone := s.Clone() 355 + clone.Insert(item) 356 + clone.Remove(item) 357 + return !clone.Contains(item) 358 + } 359 + 360 + if err := quick.Check(f, nil); err != nil { 361 + t.Error(err) 362 + } 363 + } 364 + 365 + func TestPropertyClearEmpty(t *testing.T) { 366 + f := func(s Set[int]) bool { 367 + s.Clear() 368 + return s.IsEmpty() && s.Len() == 0 369 + } 370 + 371 + if err := quick.Check(f, nil); err != nil { 372 + t.Error(err) 373 + } 374 + } 375 + 376 + func TestPropertyIsSubsetReflexive(t *testing.T) { 377 + f := func(s Set[int]) bool { 378 + return s.IsSubset(s) 379 + } 380 + 381 + if err := quick.Check(f, nil); err != nil { 382 + t.Error(err) 383 + } 384 + } 385 + 386 + func TestPropertyDeMorganUnion(t *testing.T) { 387 + f := func(s1 Set[int], s2 Set[int], universe Set[int]) bool { 388 + // create a universe that contains both sets 389 + u := universe.Clone() 390 + for item := range s1.All() { 391 + u.Insert(item) 392 + } 393 + for item := range s2.All() { 394 + u.Insert(item) 395 + } 396 + 397 + // (A u B)' = A' n B' 398 + union := Collect(s1.Union(s2)) 399 + complementUnion := Collect(u.Difference(union)) 400 + 401 + complementS1 := Collect(u.Difference(s1)) 402 + complementS2 := Collect(u.Difference(s2)) 403 + intersectionComplements := Collect(complementS1.Intersection(complementS2)) 404 + 405 + return complementUnion.Equal(intersectionComplements) 406 + } 407 + 408 + if err := quick.Check(f, nil); err != nil { 409 + t.Error(err) 410 + } 411 + }
+20 -11
spindle/config/config.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "path/filepath" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 8 9 "github.com/sethvargo/go-envconfig" 9 10 ) 10 11 11 12 type Server struct { 12 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 - DBPath string `env:"DB_PATH, default=spindle.db"` 14 - Hostname string `env:"HOSTNAME, required"` 15 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 - PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 - Dev bool `env:"DEV, default=false"` 18 - Owner string `env:"OWNER, required"` 19 - Secrets Secrets `env:",prefix=SECRETS_"` 20 - LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 - QueueSize int `env:"QUEUE_SIZE, default=100"` 22 - MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 13 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + TapUrl string `env:"TAP_URL, required"` 16 + PlcUrl string `env:"PLC_URL, default=https://plc.directory"` 17 + Dev bool `env:"DEV, default=false"` 18 + Owner syntax.DID `env:"OWNER, required"` 19 + Secrets Secrets `env:",prefix=SECRETS_"` 20 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 21 + DataDir string `env:"DATA_DIR, default=/var/lib/spindle"` 22 + QueueSize int `env:"QUEUE_SIZE, default=100"` 23 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 23 24 } 24 25 25 26 func (s Server) Did() syntax.DID { 26 27 return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 28 + } 29 + 30 + func (s Server) RepoDir() string { 31 + return filepath.Join(s.DataDir, "repos") 32 + } 33 + 34 + func (s Server) DBPath() string { 35 + return filepath.Join(s.DataDir, "spindle.db") 27 36 } 28 37 29 38 type Secrets struct {
+73 -18
spindle/db/db.go
··· 1 1 package db 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 _ "github.com/mattn/go-sqlite3" 10 + "tangled.org/core/log" 11 + "tangled.org/core/orm" 8 12 ) 9 13 10 14 type DB struct { 11 15 *sql.DB 12 16 } 13 17 14 - func Make(dbPath string) (*DB, error) { 18 + func Make(ctx context.Context, dbPath string) (*DB, error) { 15 19 // https://github.com/mattn/go-sqlite3#connection-string 16 20 opts := []string{ 17 21 "_foreign_keys=1", ··· 20 24 "_auto_vacuum=incremental", 21 25 } 22 26 27 + logger := log.FromContext(ctx) 28 + logger = log.SubLogger(logger, "db") 29 + 23 30 db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 24 31 if err != nil { 25 32 return nil, err 26 33 } 27 34 28 - // NOTE: If any other migration is added here, you MUST 29 - // copy the pattern in appview: use a single sql.Conn 30 - // for every migration. 35 + conn, err := db.Conn(ctx) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer conn.Close() 31 40 32 41 _, err = db.Exec(` 33 42 create table if not exists _jetstream ( ··· 49 58 unique(owner, name) 50 59 ); 51 60 61 + create table if not exists repo_collaborators ( 62 + -- identifiers 63 + id integer primary key autoincrement, 64 + did text not null, 65 + rkey text not null, 66 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.collaborator' || '/' || rkey) stored, 67 + 68 + repo text not null, 69 + subject text not null, 70 + 71 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 72 + unique(did, rkey) 73 + ); 74 + 52 75 create table if not exists spindle_members ( 53 76 -- identifiers for the record 54 77 id integer primary key autoincrement, ··· 76 99 return nil, err 77 100 } 78 101 102 + // run migrations 103 + 104 + // NOTE: this won't migrate existing records 105 + // they will be fetched again with tap instead 106 + orm.RunMigration(conn, logger, "add-rkey-to-repos", func(tx *sql.Tx) error { 107 + // archive legacy repos (just in case) 108 + _, err = tx.Exec(`alter table repos rename to repos_old`) 109 + if err != nil { 110 + return err 111 + } 112 + 113 + _, err := tx.Exec(` 114 + create table repos ( 115 + -- identifiers 116 + id integer primary key autoincrement, 117 + did text not null, 118 + rkey text not null, 119 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 120 + 121 + name text not null, 122 + knot text not null, 123 + 124 + addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 125 + unique(did, rkey) 126 + ); 127 + `) 128 + if err != nil { 129 + return err 130 + } 131 + 132 + return nil 133 + }) 134 + 79 135 return &DB{db}, nil 80 136 } 81 137 82 - func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 83 - _, err := d.Exec(` 84 - insert into _jetstream (id, last_time_us) 85 - values (1, ?) 86 - on conflict(id) do update set last_time_us = excluded.last_time_us 87 - `, lastTimeUs) 88 - return err 89 - } 90 - 91 - func (d *DB) GetLastTimeUs() (int64, error) { 92 - var lastTimeUs int64 93 - row := d.QueryRow(`select last_time_us from _jetstream where id = 1;`) 94 - err := row.Scan(&lastTimeUs) 95 - return lastTimeUs, err 138 + func (d *DB) IsKnownDid(did syntax.DID) (bool, error) { 139 + // is spindle member / repo collaborator 140 + var exists bool 141 + err := d.QueryRow( 142 + `select exists ( 143 + select 1 from repo_collaborators where subject = ? 144 + union all 145 + select 1 from spindle_members where did = ? 146 + )`, 147 + did, 148 + did, 149 + ).Scan(&exists) 150 + return exists, err 96 151 }
+10 -8
spindle/db/events.go
··· 18 18 EventJson string `json:"event"` 19 19 } 20 20 21 - func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error { 21 + func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error { 22 22 _, err := d.Exec( 23 23 `insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`, 24 24 event.Rkey, ··· 70 70 return evts, nil 71 71 } 72 72 73 - func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error { 74 - eventJson, err := json.Marshal(s) 73 + func (d *DB) CreatePipelineEvent(rkey string, pipeline tangled.Pipeline, n *notifier.Notifier) error { 74 + eventJson, err := json.Marshal(pipeline) 75 75 if err != nil { 76 76 return err 77 77 } 78 - 79 78 event := Event{ 80 79 Rkey: rkey, 81 - Nsid: tangled.PipelineStatusNSID, 80 + Nsid: tangled.PipelineNSID, 82 81 Created: time.Now().UnixNano(), 83 82 EventJson: string(eventJson), 84 83 } 85 - 86 - return d.InsertEvent(event, n) 84 + return d.insertEvent(event, n) 87 85 } 88 86 89 87 func (d *DB) createStatusEvent( ··· 116 114 EventJson: string(eventJson), 117 115 } 118 116 119 - return d.InsertEvent(event, n) 117 + return d.insertEvent(event, n) 120 118 121 119 } 122 120 ··· 164 162 165 163 func (d *DB) StatusFailed(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 166 164 return d.createStatusEvent(workflowId, models.StatusKindFailed, &workflowError, &exitCode, n) 165 + } 166 + 167 + func (d *DB) StatusCancelled(workflowId models.WorkflowId, workflowError string, exitCode int64, n *notifier.Notifier) error { 168 + return d.createStatusEvent(workflowId, models.StatusKindCancelled, &workflowError, &exitCode, n) 167 169 } 168 170 169 171 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error {
-44
spindle/db/known_dids.go
··· 1 - package db 2 - 3 - func (d *DB) AddDid(did string) error { 4 - _, err := d.Exec(`insert or ignore into known_dids (did) values (?)`, did) 5 - return err 6 - } 7 - 8 - func (d *DB) RemoveDid(did string) error { 9 - _, err := d.Exec(`delete from known_dids where did = ?`, did) 10 - return err 11 - } 12 - 13 - func (d *DB) GetAllDids() ([]string, error) { 14 - var dids []string 15 - 16 - rows, err := d.Query(`select did from known_dids`) 17 - if err != nil { 18 - return nil, err 19 - } 20 - defer rows.Close() 21 - 22 - for rows.Next() { 23 - var did string 24 - if err := rows.Scan(&did); err != nil { 25 - return nil, err 26 - } 27 - dids = append(dids, did) 28 - } 29 - 30 - if err := rows.Err(); err != nil { 31 - return nil, err 32 - } 33 - 34 - return dids, nil 35 - } 36 - 37 - func (d *DB) HasKnownDids() bool { 38 - var count int 39 - err := d.QueryRow(`select count(*) from known_dids`).Scan(&count) 40 - if err != nil { 41 - return false 42 - } 43 - return count > 0 44 - }
+120 -11
spindle/db/repos.go
··· 1 1 package db 2 2 3 + import "github.com/bluesky-social/indigo/atproto/syntax" 4 + 3 5 type Repo struct { 4 - Knot string 5 - Owner string 6 - Name string 6 + Did syntax.DID 7 + Rkey syntax.RecordKey 8 + Name string 9 + Knot string 10 + } 11 + 12 + type RepoCollaborator struct { 13 + Did syntax.DID 14 + Rkey syntax.RecordKey 15 + Repo syntax.ATURI 16 + Subject syntax.DID 7 17 } 8 18 9 - func (d *DB) AddRepo(knot, owner, name string) error { 10 - _, err := d.Exec(`insert or ignore into repos (knot, owner, name) values (?, ?, ?)`, knot, owner, name) 19 + func (d *DB) PutRepo(repo *Repo) error { 20 + _, err := d.Exec( 21 + `insert or ignore into repos (did, rkey, name, knot) 22 + values (?, ?, ?, ?) 23 + on conflict(did, rkey) do update set 24 + name = excluded.name, 25 + knot = excluded.knot`, 26 + repo.Did, 27 + repo.Rkey, 28 + repo.Name, 29 + repo.Knot, 30 + ) 31 + return err 32 + } 33 + 34 + func (d *DB) DeleteRepo(did syntax.DID, rkey syntax.RecordKey) error { 35 + _, err := d.Exec( 36 + `delete from repos where did = ? and rkey = ?`, 37 + did, 38 + rkey, 39 + ) 11 40 return err 12 41 } 13 42 ··· 16 45 if err != nil { 17 46 return nil, err 18 47 } 48 + defer rows.Close() 19 49 20 50 var knots []string 21 51 for rows.Next() { ··· 33 63 return knots, nil 34 64 } 35 65 36 - func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) { 66 + func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) { 37 67 var repo Repo 68 + err := d.DB.QueryRow( 69 + `select 70 + did, 71 + rkey, 72 + name, 73 + knot 74 + from repos where at_uri = ?`, 75 + repoAt, 76 + ).Scan( 77 + &repo.Did, 78 + &repo.Rkey, 79 + &repo.Name, 80 + &repo.Knot, 81 + ) 82 + if err != nil { 83 + return nil, err 84 + } 85 + return &repo, nil 86 + } 38 87 39 - query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?" 40 - err := d.DB.QueryRow(query, knot, owner, name). 41 - Scan(&repo.Knot, &repo.Owner, &repo.Name) 88 + func (d *DB) GetRepoWithName(did syntax.DID, name string) (*Repo, error) { 89 + var repo Repo 90 + err := d.DB.QueryRow( 91 + `select 92 + did, 93 + rkey, 94 + name, 95 + knot 96 + from repos where did = ? and name = ?`, 97 + did, 98 + name, 99 + ).Scan( 100 + &repo.Did, 101 + &repo.Rkey, 102 + &repo.Name, 103 + &repo.Knot, 104 + ) 105 + if err != nil { 106 + return nil, err 107 + } 108 + return &repo, nil 109 + } 110 + 111 + func (d *DB) PutRepoCollaborator(collaborator *RepoCollaborator) error { 112 + _, err := d.Exec( 113 + `insert into repo_collaborators (did, rkey, repo, subject) 114 + values (?, ?, ?, ?) 115 + on conflict(did, rkey) do update set 116 + repo = excluded.repo, 117 + subject = excluded.subject`, 118 + collaborator.Did, 119 + collaborator.Rkey, 120 + collaborator.Repo, 121 + collaborator.Subject, 122 + ) 123 + return err 124 + } 125 + 126 + func (d *DB) RemoveRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) error { 127 + _, err := d.Exec( 128 + `delete from repo_collaborators where did = ? and rkey = ?`, 129 + did, 130 + rkey, 131 + ) 132 + return err 133 + } 42 134 135 + func (d *DB) GetRepoCollaborator(did syntax.DID, rkey syntax.RecordKey) (*RepoCollaborator, error) { 136 + var collaborator RepoCollaborator 137 + err := d.DB.QueryRow( 138 + `select 139 + did, 140 + rkey, 141 + repo, 142 + subject 143 + from repo_collaborators 144 + where did = ? and rkey = ?`, 145 + did, 146 + rkey, 147 + ).Scan( 148 + &collaborator.Did, 149 + &collaborator.Rkey, 150 + &collaborator.Repo, 151 + &collaborator.Subject, 152 + ) 43 153 if err != nil { 44 154 return nil, err 45 155 } 46 - 47 - return &repo, nil 156 + return &collaborator, nil 48 157 }
+22 -21
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 - "fmt" 7 6 "log/slog" 7 + "sync" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "golang.org/x/sync/errgroup" 11 10 "tangled.org/core/notifier" 12 11 "tangled.org/core/spindle/config" 13 12 "tangled.org/core/spindle/db" ··· 31 30 } 32 31 } 33 32 34 - eg, ctx := errgroup.WithContext(ctx) 33 + var wg sync.WaitGroup 35 34 for eng, wfs := range pipeline.Workflows { 36 35 workflowTimeout := eng.WorkflowTimeout() 37 36 l.Info("using workflow timeout", "timeout", workflowTimeout) 38 37 39 38 for _, w := range wfs { 40 - eg.Go(func() error { 39 + wg.Add(1) 40 + go func() { 41 + defer wg.Done() 42 + 41 43 wid := models.WorkflowId{ 42 44 PipelineId: pipelineId, 43 45 Name: w.Name, ··· 45 47 46 48 err := db.StatusRunning(wid, n) 47 49 if err != nil { 48 - return err 50 + l.Error("failed to set workflow status to running", "wid", wid, "err", err) 51 + return 49 52 } 50 53 51 54 err = eng.SetupWorkflow(ctx, wid, &w) ··· 61 64 62 65 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 66 if dbErr != nil { 64 - return dbErr 67 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 65 68 } 66 - return err 69 + return 67 70 } 68 71 defer eng.DestroyWorkflow(ctx, wid) 69 72 70 - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 73 + secretValues := make([]string, len(allSecrets)) 74 + for i, s := range allSecrets { 75 + secretValues[i] = s.Value 76 + } 77 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 71 78 if err != nil { 72 79 l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 80 wfLogger = nil ··· 99 106 if errors.Is(err, ErrTimedOut) { 100 107 dbErr := db.StatusTimeout(wid, n) 101 108 if dbErr != nil { 102 - return dbErr 109 + l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr) 103 110 } 104 111 } else { 105 112 dbErr := db.StatusFailed(wid, err.Error(), -1, n) 106 113 if dbErr != nil { 107 - return dbErr 114 + l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr) 108 115 } 109 116 } 110 - 111 - return fmt.Errorf("starting steps image: %w", err) 117 + return 112 118 } 113 119 } 114 120 115 121 err = db.StatusSuccess(wid, n) 116 122 if err != nil { 117 - return err 123 + l.Error("failed to set workflow status to success", "wid", wid, "err", err) 118 124 } 119 - 120 - return nil 121 - }) 125 + }() 122 126 } 123 127 } 124 128 125 - if err := eg.Wait(); err != nil { 126 - l.Error("failed to run one or more workflows", "err", err) 127 - } else { 128 - l.Info("successfully ran full pipeline") 129 - } 129 + wg.Wait() 130 + l.Info("all workflows completed") 130 131 }
+29 -16
spindle/engines/nixery/engine.go
··· 179 179 return err 180 180 } 181 181 e.registerCleanup(wid, func(ctx context.Context) error { 182 - return e.docker.NetworkRemove(ctx, networkName(wid)) 182 + if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 183 + return fmt.Errorf("removing network: %w", err) 184 + } 185 + return nil 183 186 }) 184 187 185 188 addl := wf.Data.(addlFields) ··· 229 232 return fmt.Errorf("creating container: %w", err) 230 233 } 231 234 e.registerCleanup(wid, func(ctx context.Context) error { 232 - err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 233 - if err != nil { 234 - return err 235 + if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 236 + return fmt.Errorf("stopping container: %w", err) 235 237 } 236 238 237 - return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 238 240 RemoveVolumes: true, 239 241 RemoveLinks: false, 240 242 Force: false, 241 243 }) 244 + if err != nil { 245 + return fmt.Errorf("removing container: %w", err) 246 + } 247 + return nil 242 248 }) 243 249 244 - err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 245 - if err != nil { 250 + if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 246 251 return fmt.Errorf("starting container: %w", err) 247 252 } 248 253 ··· 294 299 workflowEnvs.AddEnv(s.Key, s.Value) 295 300 } 296 301 297 - step := w.Steps[idx].(Step) 302 + step := w.Steps[idx] 298 303 299 304 select { 300 305 case <-ctx.Done(): ··· 303 308 } 304 309 305 310 envs := append(EnvVars(nil), workflowEnvs...) 306 - for k, v := range step.environment { 307 - envs.AddEnv(k, v) 311 + if nixStep, ok := step.(Step); ok { 312 + for k, v := range nixStep.environment { 313 + envs.AddEnv(k, v) 314 + } 308 315 } 309 316 envs.AddEnv("HOME", homeDir) 310 317 ··· 392 399 } 393 400 394 401 func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 395 - e.cleanupMu.Lock() 396 - key := wid.String() 397 - 398 - fns := e.cleanup[key] 399 - delete(e.cleanup, key) 400 - e.cleanupMu.Unlock() 402 + fns := e.drainCleanups(wid) 401 403 402 404 for _, fn := range fns { 403 405 if err := fn(ctx); err != nil { ··· 413 415 414 416 key := wid.String() 415 417 e.cleanup[key] = append(e.cleanup[key], fn) 418 + } 419 + 420 + func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 421 + e.cleanupMu.Lock() 422 + key := wid.String() 423 + 424 + fns := e.cleanup[key] 425 + delete(e.cleanup, key) 426 + e.cleanupMu.Unlock() 427 + 428 + return fns 416 429 } 417 430 418 431 func networkName(wid models.WorkflowId) string {
+73
spindle/git/git.go
··· 1 + package git 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + "strings" 10 + 11 + "github.com/hashicorp/go-version" 12 + ) 13 + 14 + func Version() (*version.Version, error) { 15 + var buf bytes.Buffer 16 + cmd := exec.Command("git", "version") 17 + cmd.Stdout = &buf 18 + cmd.Stderr = os.Stderr 19 + err := cmd.Run() 20 + if err != nil { 21 + return nil, err 22 + } 23 + fields := strings.Fields(buf.String()) 24 + if len(fields) < 3 { 25 + return nil, fmt.Errorf("invalid git version: %s", buf.String()) 26 + } 27 + 28 + // version string is like: "git version 2.29.3" or "git version 2.29.3.windows.1" 29 + versionString := fields[2] 30 + if pos := strings.Index(versionString, "windows"); pos >= 1 { 31 + versionString = versionString[:pos-1] 32 + } 33 + return version.NewVersion(versionString) 34 + } 35 + 36 + const WorkflowDir = `/.tangled/workflows` 37 + 38 + func SparseSyncGitRepo(ctx context.Context, cloneUri, path, rev string) error { 39 + exist, err := isDir(path) 40 + if err != nil { 41 + return err 42 + } 43 + if rev == "" { 44 + rev = "HEAD" 45 + } 46 + if !exist { 47 + if err := exec.Command("git", "clone", "--no-checkout", "--depth=1", "--filter=tree:0", "--revision="+rev, cloneUri, path).Run(); err != nil { 48 + return fmt.Errorf("git clone: %w", err) 49 + } 50 + if err := exec.Command("git", "-C", path, "sparse-checkout", "set", "--no-cone", WorkflowDir).Run(); err != nil { 51 + return fmt.Errorf("git sparse-checkout set: %w", err) 52 + } 53 + } else { 54 + if err := exec.Command("git", "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev).Run(); err != nil { 55 + return fmt.Errorf("git pull: %w", err) 56 + } 57 + } 58 + if err := exec.Command("git", "-C", path, "checkout", rev).Run(); err != nil { 59 + return fmt.Errorf("git checkout: %w", err) 60 + } 61 + return nil 62 + } 63 + 64 + func isDir(path string) (bool, error) { 65 + info, err := os.Stat(path) 66 + if err == nil && info.IsDir() { 67 + return true, nil 68 + } 69 + if os.IsNotExist(err) { 70 + return false, nil 71 + } 72 + return false, err 73 + }
-300
spindle/ingester.go
··· 1 - package spindle 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "time" 9 - 10 - "tangled.org/core/api/tangled" 11 - "tangled.org/core/eventconsumer" 12 - "tangled.org/core/rbac" 13 - "tangled.org/core/spindle/db" 14 - 15 - comatproto "github.com/bluesky-social/indigo/api/atproto" 16 - "github.com/bluesky-social/indigo/atproto/identity" 17 - "github.com/bluesky-social/indigo/atproto/syntax" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - "github.com/bluesky-social/jetstream/pkg/models" 20 - securejoin "github.com/cyphar/filepath-securejoin" 21 - ) 22 - 23 - type Ingester func(ctx context.Context, e *models.Event) error 24 - 25 - func (s *Spindle) ingest() Ingester { 26 - return func(ctx context.Context, e *models.Event) error { 27 - var err error 28 - defer func() { 29 - eventTime := e.TimeUS 30 - lastTimeUs := eventTime + 1 31 - if err := s.db.SaveLastTimeUs(lastTimeUs); err != nil { 32 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 - } 34 - }() 35 - 36 - if e.Kind != models.EventKindCommit { 37 - return nil 38 - } 39 - 40 - switch e.Commit.Collection { 41 - case tangled.SpindleMemberNSID: 42 - err = s.ingestMember(ctx, e) 43 - case tangled.RepoNSID: 44 - err = s.ingestRepo(ctx, e) 45 - case tangled.RepoCollaboratorNSID: 46 - err = s.ingestCollaborator(ctx, e) 47 - } 48 - 49 - if err != nil { 50 - s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 51 - } 52 - 53 - return nil 54 - } 55 - } 56 - 57 - func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 58 - var err error 59 - did := e.Did 60 - rkey := e.Commit.RKey 61 - 62 - l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 63 - 64 - switch e.Commit.Operation { 65 - case models.CommitOperationCreate, models.CommitOperationUpdate: 66 - raw := e.Commit.Record 67 - record := tangled.SpindleMember{} 68 - err = json.Unmarshal(raw, &record) 69 - if err != nil { 70 - l.Error("invalid record", "error", err) 71 - return err 72 - } 73 - 74 - domain := s.cfg.Server.Hostname 75 - recordInstance := record.Instance 76 - 77 - if recordInstance != domain { 78 - l.Error("domain mismatch", "domain", recordInstance, "expected", domain) 79 - return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 80 - } 81 - 82 - ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 83 - if err != nil || !ok { 84 - l.Error("failed to add member", "did", did, "error", err) 85 - return fmt.Errorf("failed to enforce permissions: %w", err) 86 - } 87 - 88 - if err := db.AddSpindleMember(s.db, db.SpindleMember{ 89 - Did: syntax.DID(did), 90 - Rkey: rkey, 91 - Instance: recordInstance, 92 - Subject: syntax.DID(record.Subject), 93 - Created: time.Now(), 94 - }); err != nil { 95 - l.Error("failed to add member", "error", err) 96 - return fmt.Errorf("failed to add member: %w", err) 97 - } 98 - 99 - if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 100 - l.Error("failed to add member", "error", err) 101 - return fmt.Errorf("failed to add member: %w", err) 102 - } 103 - l.Info("added member from firehose", "member", record.Subject) 104 - 105 - if err := s.db.AddDid(record.Subject); err != nil { 106 - l.Error("failed to add did", "error", err) 107 - return fmt.Errorf("failed to add did: %w", err) 108 - } 109 - s.jc.AddDid(record.Subject) 110 - 111 - return nil 112 - 113 - case models.CommitOperationDelete: 114 - record, err := db.GetSpindleMember(s.db, did, rkey) 115 - if err != nil { 116 - l.Error("failed to find member", "error", err) 117 - return fmt.Errorf("failed to find member: %w", err) 118 - } 119 - 120 - if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 121 - l.Error("failed to remove member", "error", err) 122 - return fmt.Errorf("failed to remove member: %w", err) 123 - } 124 - 125 - if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 126 - l.Error("failed to add member", "error", err) 127 - return fmt.Errorf("failed to add member: %w", err) 128 - } 129 - l.Info("added member from firehose", "member", record.Subject) 130 - 131 - if err := s.db.RemoveDid(record.Subject.String()); err != nil { 132 - l.Error("failed to add did", "error", err) 133 - return fmt.Errorf("failed to add did: %w", err) 134 - } 135 - s.jc.RemoveDid(record.Subject.String()) 136 - 137 - } 138 - return nil 139 - } 140 - 141 - func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 142 - var err error 143 - did := e.Did 144 - 145 - l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 146 - 147 - l.Info("ingesting repo record", "did", did) 148 - 149 - switch e.Commit.Operation { 150 - case models.CommitOperationCreate, models.CommitOperationUpdate: 151 - raw := e.Commit.Record 152 - record := tangled.Repo{} 153 - err = json.Unmarshal(raw, &record) 154 - if err != nil { 155 - l.Error("invalid record", "error", err) 156 - return err 157 - } 158 - 159 - domain := s.cfg.Server.Hostname 160 - 161 - // no spindle configured for this repo 162 - if record.Spindle == nil { 163 - l.Info("no spindle configured", "name", record.Name) 164 - return nil 165 - } 166 - 167 - // this repo did not want this spindle 168 - if *record.Spindle != domain { 169 - l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 170 - return nil 171 - } 172 - 173 - // add this repo to the watch list 174 - if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 175 - l.Error("failed to add repo", "error", err) 176 - return fmt.Errorf("failed to add repo: %w", err) 177 - } 178 - 179 - didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 180 - if err != nil { 181 - return err 182 - } 183 - 184 - // add repo to rbac 185 - if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 186 - l.Error("failed to add repo to enforcer", "error", err) 187 - return fmt.Errorf("failed to add repo: %w", err) 188 - } 189 - 190 - // add collaborators to rbac 191 - owner, err := s.res.ResolveIdent(ctx, did) 192 - if err != nil || owner.Handle.IsInvalidHandle() { 193 - return err 194 - } 195 - if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 196 - return err 197 - } 198 - 199 - // add this knot to the event consumer 200 - src := eventconsumer.NewKnotSource(record.Knot) 201 - s.ks.AddSource(context.Background(), src) 202 - 203 - return nil 204 - 205 - } 206 - return nil 207 - } 208 - 209 - func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 210 - var err error 211 - 212 - l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 213 - 214 - l.Info("ingesting collaborator record") 215 - 216 - switch e.Commit.Operation { 217 - case models.CommitOperationCreate, models.CommitOperationUpdate: 218 - raw := e.Commit.Record 219 - record := tangled.RepoCollaborator{} 220 - err = json.Unmarshal(raw, &record) 221 - if err != nil { 222 - l.Error("invalid record", "error", err) 223 - return err 224 - } 225 - 226 - subjectId, err := s.res.ResolveIdent(ctx, record.Subject) 227 - if err != nil || subjectId.Handle.IsInvalidHandle() { 228 - return err 229 - } 230 - 231 - repoAt, err := syntax.ParseATURI(record.Repo) 232 - if err != nil { 233 - l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 234 - return nil 235 - } 236 - 237 - // TODO: get rid of this entirely 238 - // resolve this aturi to extract the repo record 239 - owner, err := s.res.ResolveIdent(ctx, repoAt.Authority().String()) 240 - if err != nil || owner.Handle.IsInvalidHandle() { 241 - return fmt.Errorf("failed to resolve handle: %w", err) 242 - } 243 - 244 - xrpcc := xrpc.Client{ 245 - Host: owner.PDSEndpoint(), 246 - } 247 - 248 - resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 249 - if err != nil { 250 - return err 251 - } 252 - 253 - repo := resp.Value.Val.(*tangled.Repo) 254 - didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 255 - 256 - // check perms for this user 257 - if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 258 - return fmt.Errorf("insufficient permissions: %w", err) 259 - } 260 - 261 - // add collaborator to rbac 262 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 263 - l.Error("failed to add repo to enforcer", "error", err) 264 - return fmt.Errorf("failed to add repo: %w", err) 265 - } 266 - 267 - return nil 268 - } 269 - return nil 270 - } 271 - 272 - func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 273 - l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 274 - 275 - l.Info("fetching and adding existing collaborators") 276 - 277 - xrpcc := xrpc.Client{ 278 - Host: owner.PDSEndpoint(), 279 - } 280 - 281 - resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 282 - if err != nil { 283 - return err 284 - } 285 - 286 - var errs error 287 - for _, r := range resp.Records { 288 - if r == nil { 289 - continue 290 - } 291 - record := r.Value.Val.(*tangled.RepoCollaborator) 292 - 293 - if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 294 - l.Error("failed to add repo to enforcer", "error", err) 295 - errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 296 - } 297 - } 298 - 299 - return errs 300 - }
+6 -1
spindle/models/logger.go
··· 12 12 type WorkflowLogger struct { 13 13 file *os.File 14 14 encoder *json.Encoder 15 + mask *SecretMask 15 16 } 16 17 17 - func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { 18 19 path := LogFilePath(baseDir, wid) 19 20 20 21 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) ··· 25 26 return &WorkflowLogger{ 26 27 file: file, 27 28 encoder: json.NewEncoder(file), 29 + mask: NewSecretMask(secretValues), 28 30 }, nil 29 31 } 30 32 ··· 62 64 63 65 func (w *dataWriter) Write(p []byte) (int, error) { 64 66 line := strings.TrimRight(string(p), "\r\n") 67 + if w.logger.mask != nil { 68 + line = w.logger.mask.Mask(line) 69 + } 65 70 entry := NewDataLogLine(w.idx, line, w.stream) 66 71 if err := w.logger.encoder.Encode(entry); err != nil { 67 72 return 0, err
+1 -1
spindle/models/pipeline_env.go
··· 20 20 // Standard CI environment variable 21 21 env["CI"] = "true" 22 22 23 - env["TANGLED_PIPELINE_ID"] = pipelineId.Rkey 23 + env["TANGLED_PIPELINE_ID"] = pipelineId.AtUri().String() 24 24 25 25 // Repo info 26 26 if tr.Repo != nil {
+51
spindle/models/secret_mask.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "strings" 6 + ) 7 + 8 + // SecretMask replaces secret values in strings with "***". 9 + type SecretMask struct { 10 + replacer *strings.Replacer 11 + } 12 + 13 + // NewSecretMask creates a mask for the given secret values. 14 + // Also registers base64-encoded variants of each secret. 15 + func NewSecretMask(values []string) *SecretMask { 16 + var pairs []string 17 + 18 + for _, value := range values { 19 + if value == "" { 20 + continue 21 + } 22 + 23 + pairs = append(pairs, value, "***") 24 + 25 + b64 := base64.StdEncoding.EncodeToString([]byte(value)) 26 + if b64 != value { 27 + pairs = append(pairs, b64, "***") 28 + } 29 + 30 + b64NoPad := strings.TrimRight(b64, "=") 31 + if b64NoPad != b64 && b64NoPad != value { 32 + pairs = append(pairs, b64NoPad, "***") 33 + } 34 + } 35 + 36 + if len(pairs) == 0 { 37 + return nil 38 + } 39 + 40 + return &SecretMask{ 41 + replacer: strings.NewReplacer(pairs...), 42 + } 43 + } 44 + 45 + // Mask replaces all registered secret values with "***". 46 + func (m *SecretMask) Mask(input string) string { 47 + if m == nil || m.replacer == nil { 48 + return input 49 + } 50 + return m.replacer.Replace(input) 51 + }
+135
spindle/models/secret_mask_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + ) 7 + 8 + func TestSecretMask_BasicMasking(t *testing.T) { 9 + mask := NewSecretMask([]string{"mysecret123"}) 10 + 11 + input := "The password is mysecret123 in this log" 12 + expected := "The password is *** in this log" 13 + 14 + result := mask.Mask(input) 15 + if result != expected { 16 + t.Errorf("expected %q, got %q", expected, result) 17 + } 18 + } 19 + 20 + func TestSecretMask_Base64Encoded(t *testing.T) { 21 + secret := "mysecret123" 22 + mask := NewSecretMask([]string{secret}) 23 + 24 + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) 25 + input := "Encoded: " + b64 26 + expected := "Encoded: ***" 27 + 28 + result := mask.Mask(input) 29 + if result != expected { 30 + t.Errorf("expected %q, got %q", expected, result) 31 + } 32 + } 33 + 34 + func TestSecretMask_Base64NoPadding(t *testing.T) { 35 + // "test" encodes to "dGVzdA==" with padding 36 + secret := "test" 37 + mask := NewSecretMask([]string{secret}) 38 + 39 + b64NoPad := "dGVzdA" // base64 without padding 40 + input := "Token: " + b64NoPad 41 + expected := "Token: ***" 42 + 43 + result := mask.Mask(input) 44 + if result != expected { 45 + t.Errorf("expected %q, got %q", expected, result) 46 + } 47 + } 48 + 49 + func TestSecretMask_MultipleSecrets(t *testing.T) { 50 + mask := NewSecretMask([]string{"password1", "apikey123"}) 51 + 52 + input := "Using password1 and apikey123 for auth" 53 + expected := "Using *** and *** for auth" 54 + 55 + result := mask.Mask(input) 56 + if result != expected { 57 + t.Errorf("expected %q, got %q", expected, result) 58 + } 59 + } 60 + 61 + func TestSecretMask_MultipleOccurrences(t *testing.T) { 62 + mask := NewSecretMask([]string{"secret"}) 63 + 64 + input := "secret appears twice: secret" 65 + expected := "*** appears twice: ***" 66 + 67 + result := mask.Mask(input) 68 + if result != expected { 69 + t.Errorf("expected %q, got %q", expected, result) 70 + } 71 + } 72 + 73 + func TestSecretMask_ShortValues(t *testing.T) { 74 + mask := NewSecretMask([]string{"abc", "xy", ""}) 75 + 76 + if mask == nil { 77 + t.Fatal("expected non-nil mask") 78 + } 79 + 80 + input := "abc xy test" 81 + expected := "*** *** test" 82 + result := mask.Mask(input) 83 + if result != expected { 84 + t.Errorf("expected %q, got %q", expected, result) 85 + } 86 + } 87 + 88 + func TestSecretMask_NilMask(t *testing.T) { 89 + var mask *SecretMask 90 + 91 + input := "some input text" 92 + result := mask.Mask(input) 93 + if result != input { 94 + t.Errorf("expected %q, got %q", input, result) 95 + } 96 + } 97 + 98 + func TestSecretMask_EmptyInput(t *testing.T) { 99 + mask := NewSecretMask([]string{"secret"}) 100 + 101 + result := mask.Mask("") 102 + if result != "" { 103 + t.Errorf("expected empty string, got %q", result) 104 + } 105 + } 106 + 107 + func TestSecretMask_NoMatch(t *testing.T) { 108 + mask := NewSecretMask([]string{"secretvalue"}) 109 + 110 + input := "nothing to mask here" 111 + result := mask.Mask(input) 112 + if result != input { 113 + t.Errorf("expected %q, got %q", input, result) 114 + } 115 + } 116 + 117 + func TestSecretMask_EmptySecretsList(t *testing.T) { 118 + mask := NewSecretMask([]string{}) 119 + 120 + if mask != nil { 121 + t.Error("expected nil mask for empty secrets list") 122 + } 123 + } 124 + 125 + func TestSecretMask_EmptySecretsFiltered(t *testing.T) { 126 + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) 127 + 128 + input := "Using validpassword here" 129 + expected := "Using *** here" 130 + 131 + result := mask.Mask(input) 132 + if result != expected { 133 + t.Errorf("expected %q, got %q", expected, result) 134 + } 135 + }
+1 -1
spindle/motd
··· 20 20 ** 21 21 ******** 22 22 23 - This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 23 + This is a spindle server. More info at https://docs.tangled.org/spindles.html#spindles 24 24 25 25 Most API routes are under /xrpc/
+223 -150
spindle/server.go
··· 4 4 "context" 5 5 _ "embed" 6 6 "encoding/json" 7 + "errors" 7 8 "fmt" 8 9 "log/slog" 9 10 "maps" 10 11 "net/http" 12 + "path/filepath" 11 13 14 + "github.com/bluesky-social/indigo/atproto/syntax" 12 15 "github.com/go-chi/chi/v5" 16 + "github.com/go-git/go-git/v5/plumbing/object" 17 + "github.com/hashicorp/go-version" 13 18 "tangled.org/core/api/tangled" 14 19 "tangled.org/core/eventconsumer" 15 20 "tangled.org/core/eventconsumer/cursor" 16 21 "tangled.org/core/idresolver" 17 - "tangled.org/core/jetstream" 22 + kgit "tangled.org/core/knotserver/git" 18 23 "tangled.org/core/log" 19 24 "tangled.org/core/notifier" 20 - "tangled.org/core/rbac" 25 + "tangled.org/core/rbac2" 21 26 "tangled.org/core/spindle/config" 22 27 "tangled.org/core/spindle/db" 23 28 "tangled.org/core/spindle/engine" 24 29 "tangled.org/core/spindle/engines/nixery" 30 + "tangled.org/core/spindle/git" 25 31 "tangled.org/core/spindle/models" 26 32 "tangled.org/core/spindle/queue" 27 33 "tangled.org/core/spindle/secrets" 28 34 "tangled.org/core/spindle/xrpc" 35 + "tangled.org/core/tap" 36 + "tangled.org/core/tid" 37 + "tangled.org/core/workflow" 29 38 "tangled.org/core/xrpc/serviceauth" 30 39 ) 31 40 32 41 //go:embed motd 33 42 var motd []byte 34 - 35 - const ( 36 - rbacDomain = "thisserver" 37 - ) 38 43 39 44 type Spindle struct { 40 - jc *jetstream.JetstreamClient 45 + tap *tap.Client 41 46 db *db.DB 42 - e *rbac.Enforcer 47 + e *rbac2.Enforcer 43 48 l *slog.Logger 44 49 n *notifier.Notifier 45 50 engs map[string]models.Engine ··· 54 59 func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) { 55 60 logger := log.FromContext(ctx) 56 61 57 - d, err := db.Make(cfg.Server.DBPath) 62 + if err := ensureGitVersion(); err != nil { 63 + return nil, fmt.Errorf("ensuring git version: %w", err) 64 + } 65 + 66 + d, err := db.Make(ctx, cfg.Server.DBPath()) 58 67 if err != nil { 59 68 return nil, fmt.Errorf("failed to setup db: %w", err) 60 69 } 61 70 62 - e, err := rbac.NewEnforcer(cfg.Server.DBPath) 71 + e, err := rbac2.NewEnforcer(cfg.Server.DBPath()) 63 72 if err != nil { 64 73 return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err) 65 74 } 66 - e.E.EnableAutoSave(true) 67 75 68 76 n := notifier.New() 69 77 ··· 83 91 } 84 92 logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 85 93 case "sqlite", "": 86 - vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 94 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets")) 87 95 if err != nil { 88 96 return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 89 97 } 90 - logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 98 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath()) 91 99 default: 92 100 return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 93 101 } ··· 95 103 jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 96 104 logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 97 105 98 - collections := []string{ 99 - tangled.SpindleMemberNSID, 100 - tangled.RepoNSID, 101 - tangled.RepoCollaboratorNSID, 102 - } 103 - jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true) 104 - if err != nil { 105 - return nil, fmt.Errorf("failed to setup jetstream client: %w", err) 106 - } 107 - jc.AddDid(cfg.Server.Owner) 108 - 109 - // Check if the spindle knows about any Dids; 110 - dids, err := d.GetAllDids() 111 - if err != nil { 112 - return nil, fmt.Errorf("failed to get all dids: %w", err) 113 - } 114 - for _, d := range dids { 115 - jc.AddDid(d) 116 - } 106 + tap := tap.NewClient(cfg.Server.TapUrl, "") 117 107 118 108 resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl) 119 109 120 110 spindle := &Spindle{ 121 - jc: jc, 111 + tap: &tap, 122 112 e: e, 123 113 db: d, 124 114 l: logger, ··· 130 120 vault: vault, 131 121 } 132 122 133 - err = e.AddSpindle(rbacDomain) 134 - if err != nil { 135 - return nil, fmt.Errorf("failed to set rbac domain: %w", err) 136 - } 137 - err = spindle.configureOwner() 123 + err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did()) 138 124 if err != nil { 139 125 return nil, err 140 126 } 141 127 logger.Info("owner set", "did", cfg.Server.Owner) 142 128 143 - cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 129 + cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath()) 144 130 if err != nil { 145 131 return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err) 146 132 } 147 133 148 - err = jc.StartJetstream(ctx, spindle.ingest()) 149 - if err != nil { 150 - return nil, fmt.Errorf("failed to start jetstream consumer: %w", err) 151 - } 152 - 153 - // for each incoming sh.tangled.pipeline, we execute 154 - // spindle.processPipeline, which in turn enqueues the pipeline 155 - // job in the above registered queue. 134 + // spindle listen to knot stream for sh.tangled.git.refUpdate 135 + // which will sync the local workflow files in spindle and enqueues the 136 + // pipeline job for on-push workflows 156 137 ccfg := eventconsumer.NewConsumerConfig() 157 138 ccfg.Logger = log.SubLogger(logger, "eventconsumer") 158 139 ccfg.Dev = cfg.Server.Dev 159 - ccfg.ProcessFunc = spindle.processPipeline 140 + ccfg.ProcessFunc = spindle.processKnotStream 160 141 ccfg.CursorStore = cursorStore 161 142 knownKnots, err := d.Knots() 162 143 if err != nil { ··· 197 178 } 198 179 199 180 // Enforcer returns the RBAC enforcer instance. 200 - func (s *Spindle) Enforcer() *rbac.Enforcer { 181 + func (s *Spindle) Enforcer() *rbac2.Enforcer { 201 182 return s.e 202 183 } 203 184 ··· 217 198 s.ks.Start(ctx) 218 199 }() 219 200 201 + // ensure server owner is tracked 202 + if err := s.tap.AddRepos(ctx, []syntax.DID{s.cfg.Server.Owner}); err != nil { 203 + return err 204 + } 205 + 206 + go func() { 207 + s.l.Info("starting tap stream consumer") 208 + s.tap.Connect(ctx, &tap.SimpleIndexer{ 209 + EventHandler: s.processEvent, 210 + }) 211 + }() 212 + 220 213 s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr) 221 214 return http.ListenAndServe(s.cfg.Server.ListenAddr, s.Router()) 222 215 } ··· 268 261 Config: s.cfg, 269 262 Resolver: s.res, 270 263 Vault: s.vault, 264 + Notifier: s.Notifier(), 271 265 ServiceAuth: serviceAuth, 272 266 } 273 267 274 268 return x.Router() 275 269 } 276 270 277 - func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 278 - if msg.Nsid == tangled.PipelineNSID { 279 - tpl := tangled.Pipeline{} 280 - err := json.Unmarshal(msg.EventJson, &tpl) 281 - if err != nil { 282 - fmt.Println("error unmarshalling", err) 271 + func (s *Spindle) processKnotStream(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { 272 + l := log.FromContext(ctx).With("handler", "processKnotStream") 273 + l = l.With("src", src.Key(), "msg.Nsid", msg.Nsid, "msg.Rkey", msg.Rkey) 274 + if msg.Nsid == tangled.GitRefUpdateNSID { 275 + event := tangled.GitRefUpdate{} 276 + if err := json.Unmarshal(msg.EventJson, &event); err != nil { 277 + l.Error("error unmarshalling", "err", err) 283 278 return err 284 279 } 280 + l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName) 285 281 286 - if tpl.TriggerMetadata == nil { 287 - return fmt.Errorf("no trigger metadata found") 282 + // resolve repo name to rkey 283 + // TODO: git.refUpdate should respond with rkey instead of repo name 284 + repo, err := s.db.GetRepoWithName(syntax.DID(event.RepoDid), event.RepoName) 285 + if err != nil { 286 + return fmt.Errorf("get repo with did and name (%s/%s): %w", event.RepoDid, event.RepoName, err) 288 287 } 289 288 290 - if tpl.TriggerMetadata.Repo == nil { 291 - return fmt.Errorf("no repo data found") 289 + // NOTE: we are blindly trusting the knot that it will return only repos it own 290 + repoCloneUri := s.newRepoCloneUrl(src.Key(), event.RepoDid, event.RepoName) 291 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 292 + if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, event.NewSha); err != nil { 293 + return fmt.Errorf("sync git repo: %w", err) 292 294 } 295 + l.Info("synced git repo") 293 296 294 - if src.Key() != tpl.TriggerMetadata.Repo.Knot { 295 - return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 297 + compiler := workflow.Compiler{ 298 + Trigger: tangled.Pipeline_TriggerMetadata{ 299 + Kind: string(workflow.TriggerKindPush), 300 + Push: &tangled.Pipeline_PushTriggerData{ 301 + Ref: event.Ref, 302 + OldSha: event.OldSha, 303 + NewSha: event.NewSha, 304 + }, 305 + Repo: &tangled.Pipeline_TriggerRepo{ 306 + Did: repo.Did.String(), 307 + Knot: repo.Knot, 308 + Repo: repo.Name, 309 + }, 310 + }, 296 311 } 297 312 298 - // filter by repos 299 - _, err = s.db.GetRepo( 300 - tpl.TriggerMetadata.Repo.Knot, 301 - tpl.TriggerMetadata.Repo.Did, 302 - tpl.TriggerMetadata.Repo.Repo, 303 - ) 313 + // load workflow definitions from rev (without spindle context) 314 + rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha) 304 315 if err != nil { 305 - return err 316 + return fmt.Errorf("loading pipeline: %w", err) 317 + } 318 + if len(rawPipeline) == 0 { 319 + l.Info("no workflow definition find for the repo. skipping the event") 320 + return nil 321 + } 322 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 323 + // TODO: pass compile error to workflow log 324 + for _, w := range compiler.Diagnostics.Errors { 325 + l.Error(w.String()) 326 + } 327 + for _, w := range compiler.Diagnostics.Warnings { 328 + l.Warn(w.String()) 306 329 } 307 330 308 331 pipelineId := models.PipelineId{ 309 - Knot: src.Key(), 310 - Rkey: msg.Rkey, 332 + Knot: tpl.TriggerMetadata.Repo.Knot, 333 + Rkey: tid.TID(), 311 334 } 335 + if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 336 + l.Error("failed to create pipeline event", "err", err) 337 + return nil 338 + } 339 + err = s.processPipeline(ctx, tpl, pipelineId) 340 + if err != nil { 341 + return err 342 + } 343 + } 312 344 313 - workflows := make(map[models.Engine][]models.Workflow) 345 + return nil 346 + } 347 + 348 + func (s *Spindle) loadPipeline(ctx context.Context, repoUri, repoPath, rev string) (workflow.RawPipeline, error) { 349 + if err := git.SparseSyncGitRepo(ctx, repoUri, repoPath, rev); err != nil { 350 + return nil, fmt.Errorf("syncing git repo: %w", err) 351 + } 352 + gr, err := kgit.Open(repoPath, rev) 353 + if err != nil { 354 + return nil, fmt.Errorf("opening git repo: %w", err) 355 + } 356 + 357 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 358 + if errors.Is(err, object.ErrDirectoryNotFound) { 359 + // return empty RawPipeline when directory doesn't exist 360 + return nil, nil 361 + } else if err != nil { 362 + return nil, fmt.Errorf("loading file tree: %w", err) 363 + } 314 364 315 - // Build pipeline environment variables once for all workflows 316 - pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 365 + var rawPipeline workflow.RawPipeline 366 + for _, e := range workflowDir { 367 + if !e.IsFile() { 368 + continue 369 + } 317 370 318 - for _, w := range tpl.Workflows { 319 - if w != nil { 320 - if _, ok := s.engs[w.Engine]; !ok { 321 - err = s.db.StatusFailed(models.WorkflowId{ 322 - PipelineId: pipelineId, 323 - Name: w.Name, 324 - }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 325 - if err != nil { 326 - return err 327 - } 371 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 372 + contents, err := gr.RawContent(fpath) 373 + if err != nil { 374 + return nil, fmt.Errorf("reading raw content of '%s': %w", fpath, err) 375 + } 328 376 329 - continue 330 - } 377 + rawPipeline = append(rawPipeline, workflow.RawWorkflow{ 378 + Name: e.Name, 379 + Contents: contents, 380 + }) 381 + } 331 382 332 - eng := s.engs[w.Engine] 383 + return rawPipeline, nil 384 + } 333 385 334 - if _, ok := workflows[eng]; !ok { 335 - workflows[eng] = []models.Workflow{} 336 - } 386 + func (s *Spindle) processPipeline(ctx context.Context, tpl tangled.Pipeline, pipelineId models.PipelineId) error { 387 + // Build pipeline environment variables once for all workflows 388 + pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev) 337 389 338 - ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 339 - if err != nil { 340 - return err 341 - } 390 + // filter & init workflows 391 + workflows := make(map[models.Engine][]models.Workflow) 392 + for _, w := range tpl.Workflows { 393 + if w == nil { 394 + continue 395 + } 396 + if _, ok := s.engs[w.Engine]; !ok { 397 + err := s.db.StatusFailed(models.WorkflowId{ 398 + PipelineId: pipelineId, 399 + Name: w.Name, 400 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 401 + if err != nil { 402 + return fmt.Errorf("db.StatusFailed: %w", err) 403 + } 342 404 343 - // inject TANGLED_* env vars after InitWorkflow 344 - // This prevents user-defined env vars from overriding them 345 - if ewf.Environment == nil { 346 - ewf.Environment = make(map[string]string) 347 - } 348 - maps.Copy(ewf.Environment, pipelineEnv) 405 + continue 406 + } 349 407 350 - workflows[eng] = append(workflows[eng], *ewf) 408 + eng := s.engs[w.Engine] 351 409 352 - err = s.db.StatusPending(models.WorkflowId{ 353 - PipelineId: pipelineId, 354 - Name: w.Name, 355 - }, s.n) 356 - if err != nil { 357 - return err 358 - } 359 - } 410 + if _, ok := workflows[eng]; !ok { 411 + workflows[eng] = []models.Workflow{} 360 412 } 361 413 362 - ok := s.jq.Enqueue(queue.Job{ 363 - Run: func() error { 364 - engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 365 - RepoOwner: tpl.TriggerMetadata.Repo.Did, 366 - RepoName: tpl.TriggerMetadata.Repo.Repo, 367 - Workflows: workflows, 368 - }, pipelineId) 369 - return nil 370 - }, 371 - OnFail: func(jobError error) { 372 - s.l.Error("pipeline run failed", "error", jobError) 373 - }, 374 - }) 375 - if ok { 376 - s.l.Info("pipeline enqueued successfully", "id", msg.Rkey) 377 - } else { 378 - s.l.Error("failed to enqueue pipeline: queue is full") 414 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 415 + if err != nil { 416 + return fmt.Errorf("init workflow: %w", err) 379 417 } 418 + 419 + // inject TANGLED_* env vars after InitWorkflow 420 + // This prevents user-defined env vars from overriding them 421 + if ewf.Environment == nil { 422 + ewf.Environment = make(map[string]string) 423 + } 424 + maps.Copy(ewf.Environment, pipelineEnv) 425 + 426 + workflows[eng] = append(workflows[eng], *ewf) 380 427 } 381 428 429 + // enqueue pipeline 430 + ok := s.jq.Enqueue(queue.Job{ 431 + Run: func() error { 432 + engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 433 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 434 + RepoName: tpl.TriggerMetadata.Repo.Repo, 435 + Workflows: workflows, 436 + }, pipelineId) 437 + return nil 438 + }, 439 + OnFail: func(jobError error) { 440 + s.l.Error("pipeline run failed", "error", jobError) 441 + }, 442 + }) 443 + if !ok { 444 + return fmt.Errorf("failed to enqueue pipeline: queue is full") 445 + } 446 + s.l.Info("pipeline enqueued successfully", "id", pipelineId) 447 + 448 + // emit StatusPending for all workflows here (after successful enqueue) 449 + for _, ewfs := range workflows { 450 + for _, ewf := range ewfs { 451 + err := s.db.StatusPending(models.WorkflowId{ 452 + PipelineId: pipelineId, 453 + Name: ewf.Name, 454 + }, s.n) 455 + if err != nil { 456 + return fmt.Errorf("db.StatusPending: %w", err) 457 + } 458 + } 459 + } 382 460 return nil 383 461 } 384 462 385 - func (s *Spindle) configureOwner() error { 386 - cfgOwner := s.cfg.Server.Owner 463 + // newRepoPath creates a path to store repository by its did and rkey. 464 + // The path format would be: `/data/repos/did:plc:foo/sh.tangled.repo/repo-rkey 465 + func (s *Spindle) newRepoPath(did syntax.DID, rkey syntax.RecordKey) string { 466 + return filepath.Join(s.cfg.Server.RepoDir(), did.String(), tangled.RepoNSID, rkey.String()) 467 + } 387 468 388 - existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 389 - if err != nil { 390 - return err 469 + func (s *Spindle) newRepoCloneUrl(knot, did, name string) string { 470 + scheme := "https://" 471 + if s.cfg.Server.Dev { 472 + scheme = "http://" 391 473 } 392 - 393 - switch len(existing) { 394 - case 0: 395 - // no owner configured, continue 396 - case 1: 397 - // find existing owner 398 - existingOwner := existing[0] 474 + return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name) 475 + } 399 476 400 - // no ownership change, this is okay 401 - if existingOwner == s.cfg.Server.Owner { 402 - break 403 - } 477 + const RequiredVersion = "2.49.0" 404 478 405 - // remove existing owner 406 - err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 407 - if err != nil { 408 - return nil 409 - } 410 - default: 411 - return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 479 + func ensureGitVersion() error { 480 + v, err := git.Version() 481 + if err != nil { 482 + return fmt.Errorf("fetching git version: %w", err) 483 + } 484 + if v.LessThan(version.Must(version.NewVersion(RequiredVersion))) { 485 + return fmt.Errorf("installed git version %q is not supported, Spindle requires git version >= %q", v, RequiredVersion) 412 486 } 413 - 414 - return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 487 + return nil 415 488 }
+391
spindle/tap.go
··· 1 + package spindle 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/spindle/db" 13 + "tangled.org/core/spindle/git" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/tap" 16 + "tangled.org/core/tid" 17 + "tangled.org/core/workflow" 18 + ) 19 + 20 + func (s *Spindle) processEvent(ctx context.Context, evt tap.Event) error { 21 + l := s.l.With("component", "tapIndexer") 22 + 23 + var err error 24 + switch evt.Type { 25 + case tap.EvtRecord: 26 + switch evt.Record.Collection.String() { 27 + case tangled.SpindleMemberNSID: 28 + err = s.processMember(ctx, evt) 29 + case tangled.RepoNSID: 30 + err = s.processRepo(ctx, evt) 31 + case tangled.RepoCollaboratorNSID: 32 + err = s.processCollaborator(ctx, evt) 33 + case tangled.RepoPullNSID: 34 + err = s.processPull(ctx, evt) 35 + } 36 + case tap.EvtIdentity: 37 + // no-op 38 + } 39 + 40 + if err != nil { 41 + l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 42 + return err 43 + } 44 + return nil 45 + } 46 + 47 + // NOTE: make sure to return nil if we don't need to retry (e.g. forbidden, unrelated) 48 + 49 + func (s *Spindle) processMember(ctx context.Context, evt tap.Event) error { 50 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 51 + 52 + l.Info("processing spindle.member record") 53 + 54 + // only listen to members 55 + if ok, err := s.e.IsSpindleMemberInviteAllowed(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 56 + l.Warn("forbidden request: member invite not allowed", "did", evt.Record.Did, "error", err) 57 + return nil 58 + } 59 + 60 + switch evt.Record.Action { 61 + case tap.RecordCreateAction, tap.RecordUpdateAction: 62 + record := tangled.SpindleMember{} 63 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 64 + return fmt.Errorf("parsing record: %w", err) 65 + } 66 + 67 + domain := s.cfg.Server.Hostname 68 + if record.Instance != domain { 69 + l.Info("domain mismatch", "domain", record.Instance, "expected", domain) 70 + return nil 71 + } 72 + 73 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 74 + if err != nil { 75 + created = time.Now() 76 + } 77 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 78 + Did: evt.Record.Did, 79 + Rkey: evt.Record.Rkey.String(), 80 + Instance: record.Instance, 81 + Subject: syntax.DID(record.Subject), 82 + Created: created, 83 + }); err != nil { 84 + l.Error("failed to add member", "error", err) 85 + return fmt.Errorf("adding member to db: %w", err) 86 + } 87 + if err := s.e.AddSpindleMember(syntax.DID(record.Subject), s.cfg.Server.Did()); err != nil { 88 + return fmt.Errorf("adding member to rbac: %w", err) 89 + } 90 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 91 + return fmt.Errorf("adding did to tap", err) 92 + } 93 + 94 + l.Info("added member", "member", record.Subject) 95 + return nil 96 + 97 + case tap.RecordDeleteAction: 98 + var ( 99 + did = evt.Record.Did.String() 100 + rkey = evt.Record.Rkey.String() 101 + ) 102 + member, err := db.GetSpindleMember(s.db, did, rkey) 103 + if err != nil { 104 + return fmt.Errorf("finding member: %w", err) 105 + } 106 + 107 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 108 + return fmt.Errorf("removing member from db: %w", err) 109 + } 110 + if err := s.e.RemoveSpindleMember(member.Subject, s.cfg.Server.Did()); err != nil { 111 + return fmt.Errorf("removing member from rbac: %w", err) 112 + } 113 + if err := s.tapSafeRemoveDid(ctx, member.Subject); err != nil { 114 + return fmt.Errorf("removing did from tap: %w", err) 115 + } 116 + 117 + l.Info("removed member", "member", member.Subject) 118 + return nil 119 + } 120 + return nil 121 + } 122 + 123 + func (s *Spindle) processCollaborator(ctx context.Context, evt tap.Event) error { 124 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 125 + 126 + l.Info("processing repo.collaborator record") 127 + 128 + // only listen to members 129 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 130 + l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 131 + return nil 132 + } 133 + 134 + switch evt.Record.Action { 135 + case tap.RecordCreateAction, tap.RecordUpdateAction: 136 + record := tangled.RepoCollaborator{} 137 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 138 + l.Error("invalid record", "err", err) 139 + return fmt.Errorf("parsing record: %w", err) 140 + } 141 + 142 + // retry later if target repo is not ingested yet 143 + if _, err := s.db.GetRepo(syntax.ATURI(record.Repo)); err != nil { 144 + l.Warn("target repo is not ingested yet", "repo", record.Repo, "err", err) 145 + return fmt.Errorf("target repo is unknown") 146 + } 147 + 148 + // check perms for this user 149 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, syntax.ATURI(record.Repo)); !ok || err != nil { 150 + l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 151 + return nil 152 + } 153 + 154 + if err := s.db.PutRepoCollaborator(&db.RepoCollaborator{ 155 + Did: evt.Record.Did, 156 + Rkey: evt.Record.Rkey, 157 + Repo: syntax.ATURI(record.Repo), 158 + Subject: syntax.DID(record.Subject), 159 + }); err != nil { 160 + return fmt.Errorf("adding collaborator to db: %w", err) 161 + } 162 + if err := s.e.AddRepoCollaborator(syntax.DID(record.Subject), syntax.ATURI(record.Repo)); err != nil { 163 + return fmt.Errorf("adding collaborator to rbac: %w", err) 164 + } 165 + if err := s.tap.AddRepos(ctx, []syntax.DID{syntax.DID(record.Subject)}); err != nil { 166 + return fmt.Errorf("adding did to tap: %w", err) 167 + } 168 + 169 + l.Info("add repo collaborator", "subejct", record.Subject, "repo", record.Repo) 170 + return nil 171 + 172 + case tap.RecordDeleteAction: 173 + // get existing collaborator 174 + collaborator, err := s.db.GetRepoCollaborator(evt.Record.Did, evt.Record.Rkey) 175 + if err != nil { 176 + return fmt.Errorf("failed to get existing collaborator info: %w", err) 177 + } 178 + 179 + // check perms for this user 180 + if ok, err := s.e.IsRepoCollaboratorInviteAllowed(evt.Record.Did, collaborator.Repo); !ok || err != nil { 181 + l.Warn("forbidden request collaborator invite not allowed", "did", evt.Record.Did, "err", err) 182 + return nil 183 + } 184 + 185 + if err := s.db.RemoveRepoCollaborator(collaborator.Subject, collaborator.Rkey); err != nil { 186 + return fmt.Errorf("removing collaborator from db: %w", err) 187 + } 188 + if err := s.e.RemoveRepoCollaborator(collaborator.Subject, collaborator.Repo); err != nil { 189 + return fmt.Errorf("removing collaborator from rbac: %w", err) 190 + } 191 + if err := s.tapSafeRemoveDid(ctx, collaborator.Subject); err != nil { 192 + return fmt.Errorf("removing did from tap: %w", err) 193 + } 194 + 195 + l.Info("removed repo collaborator", "subejct", collaborator.Subject, "repo", collaborator.Repo) 196 + return nil 197 + } 198 + return nil 199 + } 200 + 201 + func (s *Spindle) processRepo(ctx context.Context, evt tap.Event) error { 202 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 203 + 204 + l.Info("processing repo record") 205 + 206 + // only listen to members 207 + if ok, err := s.e.IsSpindleMember(evt.Record.Did, s.cfg.Server.Did()); !ok || err != nil { 208 + l.Warn("forbidden request: not spindle member", "did", evt.Record.Did, "err", err) 209 + return nil 210 + } 211 + 212 + switch evt.Record.Action { 213 + case tap.RecordCreateAction, tap.RecordUpdateAction: 214 + record := tangled.Repo{} 215 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 216 + return fmt.Errorf("parsing record: %w", err) 217 + } 218 + 219 + domain := s.cfg.Server.Hostname 220 + if record.Spindle == nil || *record.Spindle != domain { 221 + if record.Spindle == nil { 222 + l.Info("spindle isn't configured", "name", record.Name) 223 + } else { 224 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 225 + } 226 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 227 + return fmt.Errorf("deleting repo from db: %w", err) 228 + } 229 + return nil 230 + } 231 + 232 + repo := &db.Repo{ 233 + Did: evt.Record.Did, 234 + Rkey: evt.Record.Rkey, 235 + Name: record.Name, 236 + Knot: record.Knot, 237 + } 238 + 239 + if err := s.db.PutRepo(repo); err != nil { 240 + return fmt.Errorf("adding repo to db: %w", err) 241 + } 242 + 243 + if err := s.e.AddRepo(evt.Record.AtUri()); err != nil { 244 + return fmt.Errorf("adding repo to rbac") 245 + } 246 + 247 + // add this knot to the event consumer 248 + src := eventconsumer.NewKnotSource(record.Knot) 249 + s.ks.AddSource(context.Background(), src) 250 + 251 + // setup sparse sync 252 + repoCloneUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 253 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 254 + if err := git.SparseSyncGitRepo(ctx, repoCloneUri, repoPath, ""); err != nil { 255 + return fmt.Errorf("setting up sparse-clone git repo: %w", err) 256 + } 257 + 258 + l.Info("added repo", "repo", evt.Record.AtUri()) 259 + return nil 260 + 261 + case tap.RecordDeleteAction: 262 + // check perms for this user 263 + if ok, err := s.e.IsRepoOwner(evt.Record.Did, evt.Record.AtUri()); !ok || err != nil { 264 + l.Warn("forbidden request: not repo owner", "did", evt.Record.Did, "err", err) 265 + return nil 266 + } 267 + 268 + if err := s.db.DeleteRepo(evt.Record.Did, evt.Record.Rkey); err != nil { 269 + return fmt.Errorf("deleting repo from db: %w", err) 270 + } 271 + 272 + if err := s.e.DeleteRepo(evt.Record.AtUri()); err != nil { 273 + return fmt.Errorf("deleting repo from rbac: %w", err) 274 + } 275 + 276 + l.Info("deleted repo", "repo", evt.Record.AtUri()) 277 + return nil 278 + } 279 + return nil 280 + } 281 + 282 + func (s *Spindle) processPull(ctx context.Context, evt tap.Event) error { 283 + l := s.l.With("component", "tapIndexer", "record", evt.Record.AtUri()) 284 + 285 + l.Info("processing pull record") 286 + 287 + // only listen to live events 288 + if !evt.Record.Live { 289 + l.Info("skipping backfill event", "event", evt.Record.AtUri()) 290 + return nil 291 + } 292 + 293 + switch evt.Record.Action { 294 + case tap.RecordCreateAction, tap.RecordUpdateAction: 295 + record := tangled.RepoPull{} 296 + if err := json.Unmarshal(evt.Record.Record, &record); err != nil { 297 + l.Error("invalid record", "err", err) 298 + return fmt.Errorf("parsing record: %w", err) 299 + } 300 + 301 + // ignore legacy records 302 + if record.Target == nil { 303 + l.Info("ignoring pull record: target repo is nil") 304 + return nil 305 + } 306 + 307 + // ignore patch-based and fork-based PRs 308 + if record.Source == nil || record.Source.Repo != nil { 309 + l.Info("ignoring pull record: not a branch-based pull request") 310 + return nil 311 + } 312 + 313 + // skip if target repo is unknown 314 + repo, err := s.db.GetRepo(syntax.ATURI(record.Target.Repo)) 315 + if err != nil { 316 + l.Warn("target repo is not ingested yet", "repo", record.Target.Repo, "err", err) 317 + return fmt.Errorf("target repo is unknown") 318 + } 319 + 320 + compiler := workflow.Compiler{ 321 + Trigger: tangled.Pipeline_TriggerMetadata{ 322 + Kind: string(workflow.TriggerKindPullRequest), 323 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 324 + Action: "create", 325 + SourceBranch: record.Source.Branch, 326 + SourceSha: record.Source.Sha, 327 + TargetBranch: record.Target.Branch, 328 + }, 329 + Repo: &tangled.Pipeline_TriggerRepo{ 330 + Did: repo.Did.String(), 331 + Knot: repo.Knot, 332 + Repo: repo.Name, 333 + }, 334 + }, 335 + } 336 + 337 + repoUri := s.newRepoCloneUrl(repo.Knot, repo.Did.String(), repo.Name) 338 + repoPath := s.newRepoPath(repo.Did, repo.Rkey) 339 + 340 + // load workflow definitions from rev (without spindle context) 341 + rawPipeline, err := s.loadPipeline(ctx, repoUri, repoPath, record.Source.Sha) 342 + if err != nil { 343 + // don't retry 344 + l.Error("failed loading pipeline", "err", err) 345 + return nil 346 + } 347 + if len(rawPipeline) == 0 { 348 + l.Info("no workflow definition find for the repo. skipping the event") 349 + return nil 350 + } 351 + tpl := compiler.Compile(compiler.Parse(rawPipeline)) 352 + // TODO: pass compile error to workflow log 353 + for _, w := range compiler.Diagnostics.Errors { 354 + l.Error(w.String()) 355 + } 356 + for _, w := range compiler.Diagnostics.Warnings { 357 + l.Warn(w.String()) 358 + } 359 + 360 + pipelineId := models.PipelineId{ 361 + Knot: tpl.TriggerMetadata.Repo.Knot, 362 + Rkey: tid.TID(), 363 + } 364 + if err := s.db.CreatePipelineEvent(pipelineId.Rkey, tpl, s.n); err != nil { 365 + l.Error("failed to create pipeline event", "err", err) 366 + return nil 367 + } 368 + err = s.processPipeline(ctx, tpl, pipelineId) 369 + if err != nil { 370 + // don't retry 371 + l.Error("failed processing pipeline", "err", err) 372 + return nil 373 + } 374 + case tap.RecordDeleteAction: 375 + // no-op 376 + } 377 + return nil 378 + } 379 + 380 + func (s *Spindle) tapSafeRemoveDid(ctx context.Context, did syntax.DID) error { 381 + known, err := s.db.IsKnownDid(syntax.DID(did)) 382 + if err != nil { 383 + return fmt.Errorf("ensuring did known state: %w", err) 384 + } 385 + if !known { 386 + if err := s.tap.RemoveRepos(ctx, []syntax.DID{did}); err != nil { 387 + return fmt.Errorf("removing did from tap: %w", err) 388 + } 389 + } 390 + return nil 391 + }
+1 -2
spindle/xrpc/add_secret.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 68 67 return 69 68 } 70 69 71 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 70 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 72 71 l.Error("insufficent permissions", "did", actorDid.String()) 73 72 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 73 return
+1 -2
spindle/xrpc/list_secrets.go
··· 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 13 "tangled.org/core/api/tangled" 14 - "tangled.org/core/rbac" 15 14 "tangled.org/core/spindle/secrets" 16 15 xrpcerr "tangled.org/core/xrpc/errors" 17 16 ) ··· 63 62 return 64 63 } 65 64 66 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 65 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 67 66 l.Error("insufficent permissions", "did", actorDid.String()) 68 67 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 68 return
+1 -1
spindle/xrpc/owner.go
··· 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 - owner := x.Config.Server.Owner 12 + owner := x.Config.Server.Owner.String() 13 13 if owner == "" { 14 14 writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 15 return
+72
spindle/xrpc/pipeline_cancelPipeline.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/spindle/models" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) CancelPipeline(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + l.Debug("cancel pipeline") 22 + 23 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 24 + if !ok { 25 + fail(xrpcerr.MissingActorDidError) 26 + return 27 + } 28 + 29 + var input tangled.PipelineCancelPipeline_Input 30 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 31 + fail(xrpcerr.GenericError(err)) 32 + return 33 + } 34 + 35 + aturi := syntax.ATURI(input.Pipeline) 36 + wid := models.WorkflowId{ 37 + PipelineId: models.PipelineId{ 38 + Knot: strings.TrimPrefix(aturi.Authority().String(), "did:web:"), 39 + Rkey: aturi.RecordKey().String(), 40 + }, 41 + Name: input.Workflow, 42 + } 43 + l.Debug("cancel pipeline", "wid", wid) 44 + 45 + // unfortunately we have to resolve repo-at here 46 + repoAt, err := syntax.ParseATURI(input.Repo) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(input.Repo)) 49 + return 50 + } 51 + 52 + isRepoOwner, err := x.Enforcer.IsRepoOwner(actorDid, repoAt) 53 + if err != nil || !isRepoOwner { 54 + fail(xrpcerr.AccessControlError(actorDid.String())) 55 + return 56 + } 57 + for _, engine := range x.Engines { 58 + l.Debug("destorying workflow", "wid", wid) 59 + err = engine.DestroyWorkflow(r.Context(), wid) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to destroy workflow: %w", err))) 62 + return 63 + } 64 + err = x.Db.StatusCancelled(wid, "User canceled the workflow", -1, x.Notifier) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("dailed to emit status failed: %w", err))) 67 + return 68 + } 69 + } 70 + 71 + w.WriteHeader(http.StatusOK) 72 + }
+1 -2
spindle/xrpc/remove_secret.go
··· 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "tangled.org/core/api/tangled" 13 - "tangled.org/core/rbac" 14 13 "tangled.org/core/spindle/secrets" 15 14 xrpcerr "tangled.org/core/xrpc/errors" 16 15 ) ··· 62 61 return 63 62 } 64 63 65 - if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 64 + if ok, err := x.Enforcer.IsRepoSettingsWriteAllowed(actorDid, repoAt); !ok || err != nil { 66 65 l.Error("insufficent permissions", "did", actorDid.String()) 67 66 writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 67 return
+5 -2
spindle/xrpc/xrpc.go
··· 10 10 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/idresolver" 13 - "tangled.org/core/rbac" 13 + "tangled.org/core/notifier" 14 + "tangled.org/core/rbac2" 14 15 "tangled.org/core/spindle/config" 15 16 "tangled.org/core/spindle/db" 16 17 "tangled.org/core/spindle/models" ··· 24 25 type Xrpc struct { 25 26 Logger *slog.Logger 26 27 Db *db.DB 27 - Enforcer *rbac.Enforcer 28 + Enforcer *rbac2.Enforcer 28 29 Engines map[string]models.Engine 29 30 Config *config.Config 30 31 Resolver *idresolver.Resolver 31 32 Vault secrets.Manager 33 + Notifier *notifier.Notifier 32 34 ServiceAuth *serviceauth.ServiceAuth 33 35 } 34 36 ··· 41 43 r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 44 r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 45 r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 46 + r.Post("/"+tangled.PipelineCancelPipelineNSID, x.CancelPipeline) 44 47 }) 45 48 46 49 // service query endpoints (no auth required)
+1 -1
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 6 darkMode: "media", 7 7 theme: { 8 8 container: {
+24
tap/simpleIndexer.go
··· 1 + package tap 2 + 3 + import "context" 4 + 5 + type SimpleIndexer struct { 6 + EventHandler func(ctx context.Context, evt Event) error 7 + ErrorHandler func(ctx context.Context, err error) 8 + } 9 + 10 + var _ Handler = (*SimpleIndexer)(nil) 11 + 12 + func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 + if i.EventHandler == nil { 14 + return nil 15 + } 16 + return i.EventHandler(ctx, evt) 17 + } 18 + 19 + func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 20 + if i.ErrorHandler == nil { 21 + return 22 + } 23 + i.ErrorHandler(ctx, err) 24 + }
+169
tap/tap.go
··· 1 + /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 + 3 + package tap 4 + 5 + import ( 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "net/url" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/gorilla/websocket" 15 + "tangled.org/core/log" 16 + ) 17 + 18 + // type WebsocketOptions struct { 19 + // maxReconnectSeconds int 20 + // heartbeatIntervalMs int 21 + // // onReconnectError 22 + // } 23 + 24 + type Handler interface { 25 + OnEvent(ctx context.Context, evt Event) error 26 + OnError(ctx context.Context, err error) 27 + } 28 + 29 + type Client struct { 30 + Url string 31 + AdminPassword string 32 + HTTPClient *http.Client 33 + } 34 + 35 + func NewClient(url, adminPassword string) Client { 36 + return Client{ 37 + Url: url, 38 + AdminPassword: adminPassword, 39 + HTTPClient: &http.Client{}, 40 + } 41 + } 42 + 43 + func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 44 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 45 + if err != nil { 46 + return err 47 + } 48 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 49 + if err != nil { 50 + return err 51 + } 52 + req.SetBasicAuth("admin", c.AdminPassword) 53 + req.Header.Set("Content-Type", "application/json") 54 + 55 + resp, err := c.HTTPClient.Do(req) 56 + if err != nil { 57 + return err 58 + } 59 + defer resp.Body.Close() 60 + if resp.StatusCode != http.StatusOK { 61 + return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 62 + } 63 + return nil 64 + } 65 + 66 + func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 67 + body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 68 + if err != nil { 69 + return err 70 + } 71 + req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 72 + if err != nil { 73 + return err 74 + } 75 + req.SetBasicAuth("admin", c.AdminPassword) 76 + req.Header.Set("Content-Type", "application/json") 77 + 78 + resp, err := c.HTTPClient.Do(req) 79 + if err != nil { 80 + return err 81 + } 82 + defer resp.Body.Close() 83 + if resp.StatusCode != http.StatusOK { 84 + return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 85 + } 86 + return nil 87 + } 88 + 89 + func (c *Client) Connect(ctx context.Context, handler Handler) error { 90 + l := log.FromContext(ctx) 91 + 92 + u, err := url.Parse(c.Url) 93 + if err != nil { 94 + return err 95 + } 96 + if u.Scheme == "https" { 97 + u.Scheme = "wss" 98 + } else { 99 + u.Scheme = "ws" 100 + } 101 + u.Path = "/channel" 102 + 103 + // TODO: set auth on dial 104 + 105 + url := u.String() 106 + 107 + // var backoff int 108 + // for { 109 + // select { 110 + // case <-ctx.Done(): 111 + // return ctx.Err() 112 + // default: 113 + // } 114 + // 115 + // header := http.Header{ 116 + // "Authorization": []string{""}, 117 + // } 118 + // conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 119 + // if err != nil { 120 + // l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 121 + // time.Sleep(time.Duration(5+backoff) * time.Second) 122 + // backoff++ 123 + // 124 + // continue 125 + // } else { 126 + // backoff = 0 127 + // } 128 + // 129 + // l.Info("event subscription response", "code", res.StatusCode) 130 + // } 131 + 132 + // TODO: keep websocket connection alive 133 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 134 + if err != nil { 135 + return err 136 + } 137 + defer conn.Close() 138 + 139 + for { 140 + select { 141 + case <-ctx.Done(): 142 + return ctx.Err() 143 + default: 144 + } 145 + _, message, err := conn.ReadMessage() 146 + if err != nil { 147 + return err 148 + } 149 + 150 + var ev Event 151 + if err := json.Unmarshal(message, &ev); err != nil { 152 + handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 153 + continue 154 + } 155 + if err := handler.OnEvent(ctx, ev); err != nil { 156 + handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 157 + continue 158 + } 159 + 160 + ack := map[string]any{ 161 + "type": "ack", 162 + "id": ev.ID, 163 + } 164 + if err := conn.WriteJSON(ack); err != nil { 165 + l.Warn("failed to send ack", "err", err) 166 + continue 167 + } 168 + } 169 + }
+62
tap/types.go
··· 1 + package tap 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type EventType string 11 + 12 + const ( 13 + EvtRecord EventType = "record" 14 + EvtIdentity EventType = "identity" 15 + ) 16 + 17 + type Event struct { 18 + ID int64 `json:"id"` 19 + Type EventType `json:"type"` 20 + Record *RecordEventData `json:"record,omitempty"` 21 + Identity *IdentityEventData `json:"identity,omitempty"` 22 + } 23 + 24 + type RecordEventData struct { 25 + Live bool `json:"live"` 26 + Did syntax.DID `json:"did"` 27 + Rev string `json:"rev"` 28 + Collection syntax.NSID `json:"collection"` 29 + Rkey syntax.RecordKey `json:"rkey"` 30 + Action RecordAction `json:"action"` 31 + Record json.RawMessage `json:"record,omitempty"` 32 + CID *syntax.CID `json:"cid,omitempty"` 33 + } 34 + 35 + func (r *RecordEventData) AtUri() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 + } 38 + 39 + type RecordAction string 40 + 41 + const ( 42 + RecordCreateAction RecordAction = "create" 43 + RecordUpdateAction RecordAction = "update" 44 + RecordDeleteAction RecordAction = "delete" 45 + ) 46 + 47 + type IdentityEventData struct { 48 + DID syntax.DID `json:"did"` 49 + Handle string `json:"handle"` 50 + IsActive bool `json:"is_active"` 51 + Status RepoStatus `json:"status"` 52 + } 53 + 54 + type RepoStatus string 55 + 56 + const ( 57 + RepoStatusActive RepoStatus = "active" 58 + RepoStatusTakendown RepoStatus = "takendown" 59 + RepoStatusSuspended RepoStatus = "suspended" 60 + RepoStatusDeactivated RepoStatus = "deactivated" 61 + RepoStatusDeleted RepoStatus = "deleted" 62 + )
+6 -1
types/commit.go
··· 174 174 175 175 func (commit Commit) CoAuthors() []object.Signature { 176 176 var coAuthors []object.Signature 177 - 177 + seen := make(map[string]bool) 178 178 matches := coAuthorRegex.FindAllStringSubmatch(commit.Message, -1) 179 179 180 180 for _, match := range matches { 181 181 if len(match) >= 3 { 182 182 name := strings.TrimSpace(match[1]) 183 183 email := strings.TrimSpace(match[2]) 184 + 185 + if seen[email] { 186 + continue 187 + } 188 + seen[email] = true 184 189 185 190 coAuthors = append(coAuthors, object.Signature{ 186 191 Name: name,