+20
-79
api/tangled/cbor_gen.go
+20
-79
api/tangled/cbor_gen.go
···
7934
7934
}
7935
7935
7936
7936
cw := cbg.NewCborWriter(w)
7937
-
fieldCount := 10
7937
+
fieldCount := 9
7938
7938
7939
7939
if t.Body == nil {
7940
7940
fieldCount--
7941
7941
}
7942
7942
7943
7943
if t.Mentions == nil {
7944
-
fieldCount--
7945
-
}
7946
-
7947
-
if t.Patch == nil {
7948
7944
fieldCount--
7949
7945
}
7950
7946
···
8012
8008
}
8013
8009
8014
8010
// t.Patch (string) (string)
8015
-
if t.Patch != nil {
8011
+
if len("patch") > 1000000 {
8012
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8013
+
}
8016
8014
8017
-
if len("patch") > 1000000 {
8018
-
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
-
}
8015
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8016
+
return err
8017
+
}
8018
+
if _, err := cw.WriteString(string("patch")); err != nil {
8019
+
return err
8020
+
}
8020
8021
8021
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
-
return err
8023
-
}
8024
-
if _, err := cw.WriteString(string("patch")); err != nil {
8025
-
return err
8026
-
}
8022
+
if len(t.Patch) > 1000000 {
8023
+
return xerrors.Errorf("Value in field t.Patch was too long")
8024
+
}
8027
8025
8028
-
if t.Patch == nil {
8029
-
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
-
return err
8031
-
}
8032
-
} else {
8033
-
if len(*t.Patch) > 1000000 {
8034
-
return xerrors.Errorf("Value in field t.Patch was too long")
8035
-
}
8036
-
8037
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
-
return err
8039
-
}
8040
-
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
-
return err
8042
-
}
8043
-
}
8026
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
8027
+
return err
8028
+
}
8029
+
if _, err := cw.WriteString(string(t.Patch)); err != nil {
8030
+
return err
8044
8031
}
8045
8032
8046
8033
// t.Title (string) (string)
···
8160
8147
return err
8161
8148
}
8162
8149
8163
-
// t.PatchBlob (util.LexBlob) (struct)
8164
-
if len("patchBlob") > 1000000 {
8165
-
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
-
}
8167
-
8168
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
-
return err
8170
-
}
8171
-
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
-
return err
8173
-
}
8174
-
8175
-
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
-
return err
8177
-
}
8178
-
8179
8150
// t.References ([]string) (slice)
8180
8151
if t.References != nil {
8181
8152
···
8291
8262
case "patch":
8292
8263
8293
8264
{
8294
-
b, err := cr.ReadByte()
8265
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8295
8266
if err != nil {
8296
8267
return err
8297
8268
}
8298
-
if b != cbg.CborNull[0] {
8299
-
if err := cr.UnreadByte(); err != nil {
8300
-
return err
8301
-
}
8302
8269
8303
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
-
if err != nil {
8305
-
return err
8306
-
}
8307
-
8308
-
t.Patch = (*string)(&sval)
8309
-
}
8270
+
t.Patch = string(sval)
8310
8271
}
8311
8272
// t.Title (string) (string)
8312
8273
case "title":
···
8409
8370
}
8410
8371
8411
8372
t.CreatedAt = string(sval)
8412
-
}
8413
-
// t.PatchBlob (util.LexBlob) (struct)
8414
-
case "patchBlob":
8415
-
8416
-
{
8417
-
8418
-
b, err := cr.ReadByte()
8419
-
if err != nil {
8420
-
return err
8421
-
}
8422
-
if b != cbg.CborNull[0] {
8423
-
if err := cr.UnreadByte(); err != nil {
8424
-
return err
8425
-
}
8426
-
t.PatchBlob = new(util.LexBlob)
8427
-
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
-
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
-
}
8430
-
}
8431
-
8432
8373
}
8433
8374
// t.References ([]string) (slice)
8434
8375
case "references":
+34
api/tangled/pipelinecancelPipeline.go
+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
+
}
+9
-12
api/tangled/repopull.go
+9
-12
api/tangled/repopull.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPull
19
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
// patch: (deprecated) use patchBlob instead
25
-
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
-
// patchBlob: patch content
27
-
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
-
Title string `json:"title" cborgen:"title"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
Patch string `json:"patch" cborgen:"patch"`
25
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
27
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
28
+
Title string `json:"title" cborgen:"title"`
32
29
}
33
30
34
31
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+6
-6
appview/db/pipeline.go
+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
+11
-18
appview/db/profile.go
+11
-18
appview/db/profile.go
···
20
20
timeline := models.ProfileTimeline{
21
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
22
}
23
-
now := time.Now()
23
+
currentMonth := time.Now().Month()
24
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
25
26
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
30
31
31
// group pulls by month
32
32
for _, pull := range pulls {
33
-
monthsAgo := monthsBetween(pull.Created, now)
33
+
pullMonth := pull.Created.Month()
34
34
35
-
if monthsAgo >= TimeframeMonths {
35
+
if currentMonth-pullMonth >= TimeframeMonths {
36
36
// shouldn't happen; but times are weird
37
37
continue
38
38
}
39
39
40
-
idx := monthsAgo
40
+
idx := currentMonth - pullMonth
41
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
42
43
43
*items = append(*items, &pull)
···
53
53
}
54
54
55
55
for _, issue := range issues {
56
-
monthsAgo := monthsBetween(issue.Created, now)
56
+
issueMonth := issue.Created.Month()
57
57
58
-
if monthsAgo >= TimeframeMonths {
58
+
if currentMonth-issueMonth >= TimeframeMonths {
59
59
// shouldn't happen; but times are weird
60
60
continue
61
61
}
62
62
63
-
idx := monthsAgo
63
+
idx := currentMonth - issueMonth
64
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
65
66
66
*items = append(*items, &issue)
···
77
77
if repo.Source != "" {
78
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
79
if err != nil {
80
-
// the source repo was not found, skip this bit
81
-
log.Println("profile", "err", err)
80
+
return nil, err
82
81
}
83
82
}
84
83
85
-
monthsAgo := monthsBetween(repo.Created, now)
84
+
repoMonth := repo.Created.Month()
86
85
87
-
if monthsAgo >= TimeframeMonths {
86
+
if currentMonth-repoMonth >= TimeframeMonths {
88
87
// shouldn't happen; but times are weird
89
88
continue
90
89
}
91
90
92
-
idx := monthsAgo
91
+
idx := currentMonth - repoMonth
93
92
94
93
items := &timeline.ByMonth[idx].RepoEvents
95
94
*items = append(*items, models.RepoEvent{
···
99
98
}
100
99
101
100
return &timeline, nil
102
-
}
103
-
104
-
func monthsBetween(from, to time.Time) int {
105
-
years := to.Year() - from.Year()
106
-
months := int(to.Month() - from.Month())
107
-
return years*12 + months
108
101
}
109
102
110
103
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+1
-1
appview/db/punchcard.go
+1
-1
appview/db/punchcard.go
+5
appview/knots/knots.go
+5
appview/knots/knots.go
···
666
666
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
667
667
return
668
668
}
669
+
if memberId.Handle.IsInvalidHandle() {
670
+
l.Error("failed to resolve member identity to handle")
671
+
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
672
+
return
673
+
}
669
674
670
675
// remove from enforcer
671
676
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
-4
appview/middleware/middleware.go
-4
appview/middleware/middleware.go
···
223
223
)
224
224
if err != nil {
225
225
log.Println("failed to resolve repo", "err", err)
226
-
w.WriteHeader(http.StatusNotFound)
227
226
mw.pages.ErrorKnot404(w)
228
227
return
229
228
}
···
241
240
f, err := mw.repoResolver.Resolve(r)
242
241
if err != nil {
243
242
log.Println("failed to fully resolve repo", err)
244
-
w.WriteHeader(http.StatusNotFound)
245
243
mw.pages.ErrorKnot404(w)
246
244
return
247
245
}
···
290
288
f, err := mw.repoResolver.Resolve(r)
291
289
if err != nil {
292
290
log.Println("failed to fully resolve repo", err)
293
-
w.WriteHeader(http.StatusNotFound)
294
291
mw.pages.ErrorKnot404(w)
295
292
return
296
293
}
···
327
324
f, err := mw.repoResolver.Resolve(r)
328
325
if err != nil {
329
326
log.Println("failed to fully resolve repo", err)
330
-
w.WriteHeader(http.StatusNotFound)
331
327
mw.pages.ErrorKnot404(w)
332
328
return
333
329
}
+10
appview/models/pipeline.go
+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
+
}
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
···
83
83
Repo *Repo
84
84
}
85
85
86
-
// NOTE: This method does not include patch blob in returned atproto record
87
86
func (p Pull) AsRecord() tangled.RepoPull {
88
87
var source *tangled.RepoPull_Source
89
88
if p.PullSource != nil {
···
114
113
Repo: p.RepoAt.String(),
115
114
Branch: p.TargetBranch,
116
115
},
116
+
Patch: p.LatestPatch(),
117
117
Source: source,
118
118
}
119
119
return record
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
17
17
{{ else }}
18
18
{{ range $idx, $hunk := $diff }}
19
19
{{ with $hunk }}
20
-
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
20
+
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
21
<summary class="list-none cursor-pointer sticky top-0">
22
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
6
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
13
+
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
14
14
{{- range .LeftLines -}}
15
15
{{- if .IsEmpty -}}
16
-
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
-
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
-
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
-
</span>
16
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
+
</div>
21
21
{{- else if eq .Op.String "-" -}}
22
-
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
-
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
-
</span>
22
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
+
<div class="px-2">{{ .Content }}</div>
26
+
</div>
27
27
{{- else if eq .Op.String " " -}}
28
-
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
-
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
-
</span>
28
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
+
<div class="px-2">{{ .Content }}</div>
32
+
</div>
33
33
{{- end -}}
34
34
{{- end -}}
35
-
{{- end -}}</div></div></div>
35
+
{{- end -}}</div></div></pre>
36
36
37
-
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
37
+
<pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
38
38
{{- range .RightLines -}}
39
39
{{- if .IsEmpty -}}
40
-
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
-
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
-
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
-
</span>
40
+
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
+
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
+
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
+
</div>
45
45
{{- else if eq .Op.String "+" -}}
46
-
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
-
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
-
</span>
46
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
+
<div class="px-2" >{{ .Content }}</div>
50
+
</div>
51
51
{{- else if eq .Op.String " " -}}
52
-
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
-
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
-
</span>
52
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
+
<div class="px-2">{{ .Content }}</div>
56
+
</div>
57
57
{{- end -}}
58
58
{{- end -}}
59
-
{{- end -}}</div></div></div>
59
+
{{- end -}}</div></div></pre>
60
60
</div>
61
61
{{ end }}
+22
-21
appview/pages/templates/repo/fragments/unifiedDiff.html
+22
-21
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
1
{{ define "repo/fragments/unifiedDiff" }}
2
2
{{ $name := .Id }}
3
-
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
3
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</div>
4
4
{{- $oldStart := .OldPosition -}}
5
5
{{- $newStart := .NewPosition -}}
6
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
8
{{- $lineNrSepStyle1 := "" -}}
9
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
10
+
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
15
{{- range .Lines -}}
16
16
{{- if eq .Op.String "+" -}}
17
-
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
-
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
-
</span>
17
+
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
+
<div class="px-2">{{ .Line }}</div>
22
+
</div>
23
23
{{- $newStart = add64 $newStart 1 -}}
24
24
{{- end -}}
25
25
{{- if eq .Op.String "-" -}}
26
-
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
-
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
-
</span>
26
+
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
+
<div class="px-2">{{ .Line }}</div>
31
+
</div>
32
32
{{- $oldStart = add64 $oldStart 1 -}}
33
33
{{- end -}}
34
34
{{- if eq .Op.String " " -}}
35
-
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
-
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
-
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
-
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
-
</span>
35
+
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
+
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
+
<div class="px-2">{{ .Line }}</div>
40
+
</div>
41
41
{{- $newStart = add64 $newStart 1 -}}
42
42
{{- $oldStart = add64 $oldStart 1 -}}
43
43
{{- end -}}
44
44
{{- end -}}
45
-
{{- end -}}</div></div></div>
45
+
{{- end -}}</div></div></pre>
46
46
{{ end }}
47
+
+14
appview/pages/templates/repo/pipelines/workflow.html
+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>
+86
-1
appview/pipelines/pipelines.go
+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
+36
-48
appview/pulls/pulls.go
+36
-48
appview/pulls/pulls.go
···
1241
1241
return
1242
1242
}
1243
1243
1244
-
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1245
-
if err != nil {
1246
-
log.Println("failed to upload patch", err)
1247
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1248
-
return
1249
-
}
1250
-
1251
1244
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1252
1245
Collection: tangled.RepoPullNSID,
1253
1246
Repo: user.Did,
···
1259
1252
Repo: string(repo.RepoAt()),
1260
1253
Branch: targetBranch,
1261
1254
},
1262
-
PatchBlob: blob.Blob,
1255
+
Patch: patch,
1263
1256
Source: recordPullSource,
1264
1257
CreatedAt: time.Now().Format(time.RFC3339),
1265
1258
},
···
1335
1328
// apply all record creations at once
1336
1329
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1337
1330
for _, p := range stack {
1338
-
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1339
-
if err != nil {
1340
-
log.Println("failed to upload patch blob", err)
1341
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1342
-
return
1343
-
}
1344
-
1345
1331
record := p.AsRecord()
1346
-
record.PatchBlob = blob.Blob
1347
-
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1332
+
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1348
1333
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1349
1334
Collection: tangled.RepoPullNSID,
1350
1335
Rkey: &p.Rkey,
···
1352
1337
Val: &record,
1353
1338
},
1354
1339
},
1355
-
})
1340
+
}
1341
+
writes = append(writes, &write)
1356
1342
}
1357
1343
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1358
1344
Repo: user.Did,
···
1885
1871
return
1886
1872
}
1887
1873
1888
-
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1889
-
if err != nil {
1890
-
log.Println("failed to upload patch blob", err)
1891
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1892
-
return
1874
+
var recordPullSource *tangled.RepoPull_Source
1875
+
if pull.IsBranchBased() {
1876
+
recordPullSource = &tangled.RepoPull_Source{
1877
+
Branch: pull.PullSource.Branch,
1878
+
Sha: sourceRev,
1879
+
}
1880
+
}
1881
+
if pull.IsForkBased() {
1882
+
repoAt := pull.PullSource.RepoAt.String()
1883
+
recordPullSource = &tangled.RepoPull_Source{
1884
+
Branch: pull.PullSource.Branch,
1885
+
Repo: &repoAt,
1886
+
Sha: sourceRev,
1887
+
}
1893
1888
}
1894
-
record := pull.AsRecord()
1895
-
record.PatchBlob = blob.Blob
1896
-
record.CreatedAt = time.Now().Format(time.RFC3339)
1897
1889
1898
1890
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1899
1891
Collection: tangled.RepoPullNSID,
···
1901
1893
Rkey: pull.Rkey,
1902
1894
SwapRecord: ex.Cid,
1903
1895
Record: &lexutil.LexiconTypeDecoder{
1904
-
Val: &record,
1896
+
Val: &tangled.RepoPull{
1897
+
Title: pull.Title,
1898
+
Target: &tangled.RepoPull_Target{
1899
+
Repo: string(repo.RepoAt()),
1900
+
Branch: pull.TargetBranch,
1901
+
},
1902
+
Patch: patch, // new patch
1903
+
Source: recordPullSource,
1904
+
CreatedAt: time.Now().Format(time.RFC3339),
1905
+
},
1905
1906
},
1906
1907
})
1907
1908
if err != nil {
···
1987
1988
}
1988
1989
defer tx.Rollback()
1989
1990
1990
-
client, err := s.oauth.AuthorizedClient(r)
1991
-
if err != nil {
1992
-
log.Println("failed to authorize client")
1993
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1994
-
return
1995
-
}
1996
-
1997
1991
// pds updates to make
1998
1992
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1999
1993
···
2027
2021
return
2028
2022
}
2029
2023
2030
-
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2031
-
if err != nil {
2032
-
log.Println("failed to upload patch blob", err)
2033
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2034
-
return
2035
-
}
2036
2024
record := p.AsRecord()
2037
-
record.PatchBlob = blob.Blob
2038
2025
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2039
2026
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2040
2027
Collection: tangled.RepoPullNSID,
···
2069
2056
return
2070
2057
}
2071
2058
2072
-
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2073
-
if err != nil {
2074
-
log.Println("failed to upload patch blob", err)
2075
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2076
-
return
2077
-
}
2078
2059
record := np.AsRecord()
2079
-
record.PatchBlob = blob.Blob
2060
+
2080
2061
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2081
2062
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2082
2063
Collection: tangled.RepoPullNSID,
···
2110
2091
if err != nil {
2111
2092
log.Println("failed to resubmit pull", err)
2112
2093
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2094
+
return
2095
+
}
2096
+
2097
+
client, err := s.oauth.AuthorizedClient(r)
2098
+
if err != nil {
2099
+
log.Println("failed to authorize client")
2100
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2113
2101
return
2114
2102
}
2115
2103
-1
appview/repo/archive.go
-1
appview/repo/archive.go
···
18
18
l := rp.logger.With("handler", "DownloadArchive")
19
19
ref := chi.URLParam(r, "ref")
20
20
ref, _ = url.PathUnescape(ref)
21
-
ref = strings.TrimSuffix(ref, ".tar.gz")
22
21
f, err := rp.repoResolver.Resolve(r)
23
22
if err != nil {
24
23
l.Error("failed to get repo and knot", "err", err)
+1
-26
appview/reporesolver/resolver.go
+1
-26
appview/reporesolver/resolver.go
···
63
63
}
64
64
65
65
// get dir/ref
66
-
currentDir := extractCurrentDir(r.URL.EscapedPath())
66
+
currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath()))
67
67
ref := chi.URLParam(r, "ref")
68
68
69
69
repoAt := repo.RepoAt()
···
130
130
}
131
131
132
132
return repoInfo
133
-
}
134
-
135
-
// extractCurrentDir gets the current directory for markdown link resolution.
136
-
// for blob paths, returns the parent dir. for tree paths, returns the path itself.
137
-
//
138
-
// /@user/repo/blob/main/docs/README.md => docs
139
-
// /@user/repo/tree/main/docs => docs
140
-
func extractCurrentDir(fullPath string) string {
141
-
fullPath = strings.TrimPrefix(fullPath, "/")
142
-
143
-
blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`)
144
-
if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 {
145
-
return path.Dir(matches[1])
146
-
}
147
-
148
-
treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`)
149
-
if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 {
150
-
dir := strings.TrimSuffix(matches[1], "/")
151
-
if dir == "" {
152
-
return "."
153
-
}
154
-
return dir
155
-
}
156
-
157
-
return "."
158
133
}
159
134
160
135
// extractPathAfterRef gets the actual repository path
-22
appview/reporesolver/resolver_test.go
-22
appview/reporesolver/resolver_test.go
···
1
-
package reporesolver
2
-
3
-
import "testing"
4
-
5
-
func TestExtractCurrentDir(t *testing.T) {
6
-
tests := []struct {
7
-
path string
8
-
want string
9
-
}{
10
-
{"/@user/repo/blob/main/docs/README.md", "docs"},
11
-
{"/@user/repo/blob/main/README.md", "."},
12
-
{"/@user/repo/tree/main/docs", "docs"},
13
-
{"/@user/repo/tree/main/docs/", "docs"},
14
-
{"/@user/repo/tree/main", "."},
15
-
}
16
-
17
-
for _, tt := range tests {
18
-
if got := extractCurrentDir(tt.path); got != tt.want {
19
-
t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want)
20
-
}
21
-
}
22
-
}
+5
appview/spindles/spindles.go
+5
appview/spindles/spindles.go
···
653
653
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
654
return
655
655
}
656
+
if memberId.Handle.IsInvalidHandle() {
657
+
l.Error("failed to resolve member identity to handle")
658
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
659
+
return
660
+
}
656
661
657
662
tx, err := s.Db.Begin()
658
663
if err != nil {
-86
appview/state/knotstream.go
-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
-6
appview/state/profile.go
+4
-6
appview/state/profile.go
···
163
163
}
164
164
165
165
// populate commit counts in the timeline, using the punchcard
166
-
now := time.Now()
166
+
currentMonth := time.Now().Month()
167
167
for _, p := range profile.Punchcard.Punches {
168
-
years := now.Year() - p.Date.Year()
169
-
months := int(now.Month() - p.Date.Month())
170
-
monthsAgo := years*12 + months
171
-
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
-
timeline.ByMonth[monthsAgo].Commits += p.Count
168
+
idx := currentMonth - p.Date.Month()
169
+
if int(idx) < len(timeline.ByMonth) {
170
+
timeline.ByMonth[idx].Commits += p.Count
173
171
}
174
172
}
175
173
+3
-5
appview/state/router.go
+3
-5
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
···
109
109
})
110
110
111
111
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
112
-
w.WriteHeader(http.StatusNotFound)
113
112
s.pages.Error404(w)
114
113
})
115
114
···
183
182
r.Get("/brand", s.Brand)
184
183
185
184
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
186
-
w.WriteHeader(http.StatusNotFound)
187
185
s.pages.Error404(w)
188
186
})
189
187
return r
···
315
313
return repo.Router(mw)
316
314
}
317
315
318
-
func (s *State) PipelinesRouter() http.Handler {
316
+
func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
319
317
pipes := pipelines.New(
320
318
s.oauth,
321
319
s.repoResolver,
···
327
325
s.enforcer,
328
326
log.SubLogger(s.logger, "pipelines"),
329
327
)
330
-
return pipes.Router()
328
+
return pipes.Router(mw)
331
329
}
332
330
333
331
func (s *State) LabelsRouter() http.Handler {
+89
appview/state/spindlestream.go
+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 {
+25
-23
docs/DOCS.md
+25
-23
docs/DOCS.md
···
2
2
title: Tangled docs
3
3
author: The Tangled Contributors
4
4
date: 21 Sun, Dec 2025
5
-
abstract: |
6
-
Tangled is a decentralized code hosting and collaboration
7
-
platform. Every component of Tangled is open-source and
8
-
self-hostable. [tangled.org](https://tangled.org) also
9
-
provides hosting and CI services that are free to use.
5
+
---
10
6
11
-
There are several models for decentralized code
12
-
collaboration platforms, ranging from ActivityPubโs
13
-
(Forgejo) federated model, to Radicleโs entirely P2P model.
14
-
Our approach attempts to be the best of both worlds by
15
-
adopting the AT Protocolโa protocol for building decentralized
16
-
social applications with a central identity
7
+
# Introduction
17
8
18
-
Our approach to this is the idea of โknotsโ. Knots are
19
-
lightweight, headless servers that enable users to host Git
20
-
repositories with ease. Knots are designed for either single
21
-
or multi-tenant use which is perfect for self-hosting on a
22
-
Raspberry Pi at home, or larger โcommunityโ servers. By
23
-
default, Tangled provides managed knots where you can host
24
-
your repositories for free.
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.
25
13
26
-
The appview at tangled.org acts as a consolidated "view"
27
-
into the whole network, allowing users to access, clone and
28
-
contribute to repositories hosted across different knots
29
-
seamlessly.
30
-
---
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.
31
33
32
34
# Quick start guide
33
35
-3
docs/mode.html
-3
docs/mode.html
-7
docs/search.html
-7
docs/search.html
···
1
-
<form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full">
2
-
<input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]">
3
-
<label>
4
-
<span style="display:none;">Search</span>
5
-
<input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal">
6
-
</label>
7
-
</form>
+35
-74
docs/template.html
+35
-74
docs/template.html
···
37
37
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
38
38
39
39
</head>
40
-
<body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh">
40
+
<body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen">
41
41
$for(include-before)$
42
42
$include-before$
43
43
$endfor$
44
44
45
45
$if(toc)$
46
-
<!-- mobile TOC trigger -->
47
-
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
-
<button
49
-
type="button"
50
-
popovertarget="mobile-toc-popover"
51
-
popovertargetaction="toggle"
52
-
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
-
>
54
-
${ menu.svg() }
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">
55
49
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
-
</button>
57
-
</div>
58
-
59
-
<div
60
-
id="mobile-toc-popover"
61
-
popover
62
-
class="mobile-toc-popover
63
-
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
64
-
h-full overflow-y-auto shadow-sm
65
-
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
66
-
>
67
-
<div class="flex flex-col min-h-full">
68
-
<div class="flex-1 space-y-4">
69
-
<button
70
-
type="button"
71
-
popovertarget="mobile-toc-popover"
72
-
popovertargetaction="toggle"
73
-
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
74
-
${ x.svg() }
75
-
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
76
-
</button>
77
-
${ search.html() }
78
-
${ table-of-contents:toc.html() }
79
-
</div>
80
-
${ single-page:mode.html() }
81
-
</div>
82
-
</div>
83
-
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>
84
55
<!-- desktop sidebar toc -->
85
-
<nav
86
-
id="$idprefix$TOC"
87
-
role="doc-toc"
88
-
class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen
89
-
bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700
90
-
p-4 z-50 overflow-y-auto">
91
-
${ search.html() }
92
-
<div class="flex-1">
93
-
$if(toc-title)$
94
-
<h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2>
95
-
$endif$
96
-
${ table-of-contents:toc.html() }
97
-
</div>
98
-
${ single-page:mode.html() }
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() }
99
61
</nav>
100
62
$endif$
101
63
102
64
<div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col">
103
65
<main class="max-w-4xl w-full mx-auto p-6 flex-1">
104
66
$if(top)$
105
-
$-- only print title block if this is NOT the top page
67
+
$-- only print title block if this is NOT the top page
106
68
$else$
107
69
$if(title)$
108
-
<header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
109
-
<h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1>
110
-
$if(subtitle)$
111
-
<p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p>
112
-
$endif$
113
-
$for(author)$
114
-
<p class="text-sm text-gray-500 dark:text-gray-400">$author$</p>
115
-
$endfor$
116
-
$if(date)$
117
-
<p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p>
118
-
$endif$
119
-
$endif$
120
-
</header>
121
-
$endif$
122
-
123
-
$if(abstract)$
124
-
<article class="prose dark:prose-invert max-w-none">
125
-
$abstract$
126
-
</article>
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>
127
89
$endif$
128
-
129
90
<article class="prose dark:prose-invert max-w-none">
130
91
$body$
131
92
</article>
132
93
</main>
133
-
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
94
+
<nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ">
134
95
<div class="max-w-4xl mx-auto px-8 py-4">
135
96
<div class="flex justify-between gap-4">
136
97
<span class="flex-1">
+32
-3
flake.nix
+32
-3
flake.nix
···
76
76
};
77
77
buildGoApplication =
78
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
80
80
}).buildGoApplication;
81
81
modules = ./nix/gomod2nix.toml;
82
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
···
94
94
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
95
95
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
96
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 {};
97
101
});
98
102
in {
99
103
overlays.default = final: prev: {
100
-
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs;
104
+
inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs did-method-plc bluesky-jetstream bluesky-relay tap;
101
105
};
102
106
103
107
packages = forAllSystems (system: let
···
106
110
staticPackages = mkPackageSet pkgs.pkgsStatic;
107
111
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
108
112
in {
109
-
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs;
113
+
inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs did-method-plc bluesky-jetstream bluesky-relay tap;
110
114
111
115
pkgsStatic-appview = staticPackages.appview;
112
116
pkgsStatic-knot = staticPackages.knot;
···
305
309
imports = [./nix/modules/spindle.nix];
306
310
307
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;
308
337
};
309
338
};
310
339
}
+1
go.mod
+1
go.mod
···
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
+2
go.sum
+2
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=
-1
input.css
-1
input.css
-136
knotserver/ingester.go
-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
+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/server.go
-1
knotserver/server.go
···
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
+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
+
}
+2
-10
lexicons/pulls/pull.json
+2
-10
lexicons/pulls/pull.json
···
12
12
"required": [
13
13
"target",
14
14
"title",
15
-
"patchBlob",
15
+
"patch",
16
16
"createdAt"
17
17
],
18
18
"properties": {
···
27
27
"type": "string"
28
28
},
29
29
"patch": {
30
-
"type": "string",
31
-
"description": "(deprecated) use patchBlob instead"
32
-
},
33
-
"patchBlob": {
34
-
"type": "blob",
35
-
"accept": [
36
-
"text/x-patch"
37
-
],
38
-
"description": "patch content"
30
+
"type": "string"
39
31
},
40
32
"source": {
41
33
"type": "ref",
+3
nix/gomod2nix.toml
+3
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="
+64
nix/modules/bluesky-jetstream.nix
+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
+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
+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
+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
+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
+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
+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
+
})
+1
-13
nix/pkgs/docs.nix
+1
-13
nix/pkgs/docs.nix
···
18
18
# icons
19
19
cp -rf ${lucide-src}/*.svg working/
20
20
21
-
# content - chunked
21
+
# content
22
22
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
23
23
-o $out/ \
24
24
-t chunkedhtml \
25
25
--variable toc \
26
-
--variable-json single-page=false \
27
26
--toc-depth=2 \
28
27
--css=stylesheet.css \
29
28
--chunk-template="%i.html" \
30
-
--highlight-style=working/highlight.theme \
31
-
--template=working/template.html
32
-
33
-
# content - single page
34
-
${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
35
-
-o $out/single-page.html \
36
-
--toc \
37
-
--variable toc \
38
-
--variable single-page \
39
-
--toc-depth=2 \
40
-
--css=stylesheet.css \
41
29
--highlight-style=working/highlight.theme \
42
30
--template=working/template.html
43
31
+20
nix/pkgs/tap.nix
+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
+
}
+8
-2
nix/vm.nix
+8
-2
nix/vm.nix
···
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
+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
+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
+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
+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
+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
+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
+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}
+20
-11
spindle/config/config.go
+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
+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
+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
-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
-
}
+119
-11
spindle/db/repos.go
+119
-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
17
+
}
18
+
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
7
32
}
8
33
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)
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
···
34
63
return knots, nil
35
64
}
36
65
37
-
func (d *DB) GetRepo(knot, owner, name string) (*Repo, error) {
66
+
func (d *DB) GetRepo(repoAt syntax.ATURI) (*Repo, error) {
38
67
var repo Repo
39
-
40
-
query := "select knot, owner, name from repos where knot = ? and owner = ? and name = ?"
41
-
err := d.DB.QueryRow(query, knot, owner, name).
42
-
Scan(&repo.Knot, &repo.Owner, &repo.Name)
43
-
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
+
)
44
82
if err != nil {
45
83
return nil, err
46
84
}
85
+
return &repo, nil
86
+
}
47
87
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
+
}
48
108
return &repo, nil
49
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
+
}
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
+
)
153
+
if err != nil {
154
+
return nil, err
155
+
}
156
+
return &collaborator, nil
157
+
}
+24
-13
spindle/engines/nixery/engine.go
+24
-13
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
···
394
399
}
395
400
396
401
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
397
-
e.cleanupMu.Lock()
398
-
key := wid.String()
399
-
400
-
fns := e.cleanup[key]
401
-
delete(e.cleanup, key)
402
-
e.cleanupMu.Unlock()
402
+
fns := e.drainCleanups(wid)
403
403
404
404
for _, fn := range fns {
405
405
if err := fn(ctx); err != nil {
···
415
415
416
416
key := wid.String()
417
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
418
429
}
419
430
420
431
func networkName(wid models.WorkflowId) string {
+73
spindle/git/git.go
+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
-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
-
}
+1
-1
spindle/models/pipeline_env.go
+1
-1
spindle/models/pipeline_env.go
+234
-179
spindle/server.go
+234
-179
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"
11
-
"sync"
12
+
"path/filepath"
12
13
14
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
15
"github.com/go-chi/chi/v5"
16
+
"github.com/go-git/go-git/v5/plumbing/object"
17
+
"github.com/hashicorp/go-version"
14
18
"tangled.org/core/api/tangled"
15
19
"tangled.org/core/eventconsumer"
16
20
"tangled.org/core/eventconsumer/cursor"
17
21
"tangled.org/core/idresolver"
18
-
"tangled.org/core/jetstream"
22
+
kgit "tangled.org/core/knotserver/git"
19
23
"tangled.org/core/log"
20
24
"tangled.org/core/notifier"
21
-
"tangled.org/core/rbac"
25
+
"tangled.org/core/rbac2"
22
26
"tangled.org/core/spindle/config"
23
27
"tangled.org/core/spindle/db"
24
28
"tangled.org/core/spindle/engine"
25
29
"tangled.org/core/spindle/engines/nixery"
30
+
"tangled.org/core/spindle/git"
26
31
"tangled.org/core/spindle/models"
27
32
"tangled.org/core/spindle/queue"
28
33
"tangled.org/core/spindle/secrets"
29
34
"tangled.org/core/spindle/xrpc"
35
+
"tangled.org/core/tap"
36
+
"tangled.org/core/tid"
37
+
"tangled.org/core/workflow"
30
38
"tangled.org/core/xrpc/serviceauth"
31
39
)
32
40
33
41
//go:embed motd
34
-
var defaultMotd []byte
35
-
36
-
const (
37
-
rbacDomain = "thisserver"
38
-
)
42
+
var motd []byte
39
43
40
44
type Spindle struct {
41
-
jc *jetstream.JetstreamClient
42
-
db *db.DB
43
-
e *rbac.Enforcer
44
-
l *slog.Logger
45
-
n *notifier.Notifier
46
-
engs map[string]models.Engine
47
-
jq *queue.Queue
48
-
cfg *config.Config
49
-
ks *eventconsumer.Consumer
50
-
res *idresolver.Resolver
51
-
vault secrets.Manager
52
-
motd []byte
53
-
motdMu sync.RWMutex
45
+
tap *tap.Client
46
+
db *db.DB
47
+
e *rbac2.Enforcer
48
+
l *slog.Logger
49
+
n *notifier.Notifier
50
+
engs map[string]models.Engine
51
+
jq *queue.Queue
52
+
cfg *config.Config
53
+
ks *eventconsumer.Consumer
54
+
res *idresolver.Resolver
55
+
vault secrets.Manager
54
56
}
55
57
56
58
// New creates a new Spindle server with the provided configuration and engines.
57
59
func New(ctx context.Context, cfg *config.Config, engines map[string]models.Engine) (*Spindle, error) {
58
60
logger := log.FromContext(ctx)
59
61
60
-
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())
61
67
if err != nil {
62
68
return nil, fmt.Errorf("failed to setup db: %w", err)
63
69
}
64
70
65
-
e, err := rbac.NewEnforcer(cfg.Server.DBPath)
71
+
e, err := rbac2.NewEnforcer(cfg.Server.DBPath())
66
72
if err != nil {
67
73
return nil, fmt.Errorf("failed to setup rbac enforcer: %w", err)
68
74
}
69
-
e.E.EnableAutoSave(true)
70
75
71
76
n := notifier.New()
72
77
···
86
91
}
87
92
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
88
93
case "sqlite", "":
89
-
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
94
+
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath(), secrets.WithTableName("secrets"))
90
95
if err != nil {
91
96
return nil, fmt.Errorf("failed to setup sqlite secrets provider: %w", err)
92
97
}
93
-
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath)
98
+
logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath())
94
99
default:
95
100
return nil, fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
96
101
}
···
98
103
jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount)
99
104
logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount)
100
105
101
-
collections := []string{
102
-
tangled.SpindleMemberNSID,
103
-
tangled.RepoNSID,
104
-
tangled.RepoCollaboratorNSID,
105
-
}
106
-
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, log.SubLogger(logger, "jetstream"), d, true, true)
107
-
if err != nil {
108
-
return nil, fmt.Errorf("failed to setup jetstream client: %w", err)
109
-
}
110
-
jc.AddDid(cfg.Server.Owner)
111
-
112
-
// Check if the spindle knows about any Dids;
113
-
dids, err := d.GetAllDids()
114
-
if err != nil {
115
-
return nil, fmt.Errorf("failed to get all dids: %w", err)
116
-
}
117
-
for _, d := range dids {
118
-
jc.AddDid(d)
119
-
}
106
+
tap := tap.NewClient(cfg.Server.TapUrl, "")
120
107
121
108
resolver := idresolver.DefaultResolver(cfg.Server.PlcUrl)
122
109
123
110
spindle := &Spindle{
124
-
jc: jc,
111
+
tap: &tap,
125
112
e: e,
126
113
db: d,
127
114
l: logger,
···
131
118
cfg: cfg,
132
119
res: resolver,
133
120
vault: vault,
134
-
motd: defaultMotd,
135
121
}
136
122
137
-
err = e.AddSpindle(rbacDomain)
138
-
if err != nil {
139
-
return nil, fmt.Errorf("failed to set rbac domain: %w", err)
140
-
}
141
-
err = spindle.configureOwner()
123
+
err = e.SetSpindleOwner(spindle.cfg.Server.Owner, spindle.cfg.Server.Did())
142
124
if err != nil {
143
125
return nil, err
144
126
}
145
127
logger.Info("owner set", "did", cfg.Server.Owner)
146
128
147
-
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath)
129
+
cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath())
148
130
if err != nil {
149
131
return nil, fmt.Errorf("failed to setup sqlite3 cursor store: %w", err)
150
132
}
151
133
152
-
err = jc.StartJetstream(ctx, spindle.ingest())
153
-
if err != nil {
154
-
return nil, fmt.Errorf("failed to start jetstream consumer: %w", err)
155
-
}
156
-
157
-
// for each incoming sh.tangled.pipeline, we execute
158
-
// spindle.processPipeline, which in turn enqueues the pipeline
159
-
// 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
160
137
ccfg := eventconsumer.NewConsumerConfig()
161
138
ccfg.Logger = log.SubLogger(logger, "eventconsumer")
162
139
ccfg.Dev = cfg.Server.Dev
163
-
ccfg.ProcessFunc = spindle.processPipeline
140
+
ccfg.ProcessFunc = spindle.processKnotStream
164
141
ccfg.CursorStore = cursorStore
165
142
knownKnots, err := d.Knots()
166
143
if err != nil {
···
201
178
}
202
179
203
180
// Enforcer returns the RBAC enforcer instance.
204
-
func (s *Spindle) Enforcer() *rbac.Enforcer {
181
+
func (s *Spindle) Enforcer() *rbac2.Enforcer {
205
182
return s.e
206
183
}
207
184
208
-
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
-
func (s *Spindle) SetMotdContent(content []byte) {
210
-
s.motdMu.Lock()
211
-
defer s.motdMu.Unlock()
212
-
s.motd = content
213
-
}
214
-
215
-
// GetMotdContent returns the current MOTD content.
216
-
func (s *Spindle) GetMotdContent() []byte {
217
-
s.motdMu.RLock()
218
-
defer s.motdMu.RUnlock()
219
-
return s.motd
220
-
}
221
-
222
185
// Start starts the Spindle server (blocking).
223
186
func (s *Spindle) Start(ctx context.Context) error {
224
187
// starts a job queue runner in the background
···
233
196
go func() {
234
197
s.l.Info("starting knot event consumer")
235
198
s.ks.Start(ctx)
199
+
}()
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
+
})
236
211
}()
237
212
238
213
s.l.Info("starting spindle server", "address", s.cfg.Server.ListenAddr)
···
264
239
mux := chi.NewRouter()
265
240
266
241
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
267
-
w.Write(s.GetMotdContent())
242
+
w.Write(motd)
268
243
})
269
244
mux.HandleFunc("/events", s.Events)
270
245
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
···
286
261
Config: s.cfg,
287
262
Resolver: s.res,
288
263
Vault: s.vault,
264
+
Notifier: s.Notifier(),
289
265
ServiceAuth: serviceAuth,
290
266
}
291
267
292
268
return x.Router()
293
269
}
294
270
295
-
func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error {
296
-
if msg.Nsid == tangled.PipelineNSID {
297
-
tpl := tangled.Pipeline{}
298
-
err := json.Unmarshal(msg.EventJson, &tpl)
299
-
if err != nil {
300
-
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)
301
278
return err
302
279
}
280
+
l = l.With("repoDid", event.RepoDid, "repoName", event.RepoName)
303
281
304
-
if tpl.TriggerMetadata == nil {
305
-
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)
306
287
}
307
288
308
-
if tpl.TriggerMetadata.Repo == nil {
309
-
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)
310
294
}
295
+
l.Info("synced git repo")
311
296
312
-
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
313
-
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
+
},
314
311
}
315
312
316
-
// filter by repos
317
-
_, err = s.db.GetRepo(
318
-
tpl.TriggerMetadata.Repo.Knot,
319
-
tpl.TriggerMetadata.Repo.Did,
320
-
tpl.TriggerMetadata.Repo.Repo,
321
-
)
313
+
// load workflow definitions from rev (without spindle context)
314
+
rawPipeline, err := s.loadPipeline(ctx, repoCloneUri, repoPath, event.NewSha)
322
315
if err != nil {
323
-
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())
324
329
}
325
330
326
331
pipelineId := models.PipelineId{
327
-
Knot: src.Key(),
328
-
Rkey: msg.Rkey,
332
+
Knot: tpl.TriggerMetadata.Repo.Knot,
333
+
Rkey: tid.TID(),
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
329
342
}
343
+
}
330
344
331
-
workflows := make(map[models.Engine][]models.Workflow)
345
+
return nil
346
+
}
332
347
333
-
// Build pipeline environment variables once for all workflows
334
-
pipelineEnv := models.PipelineEnvVars(tpl.TriggerMetadata, pipelineId, s.cfg.Server.Dev)
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
+
}
335
356
336
-
for _, w := range tpl.Workflows {
337
-
if w != nil {
338
-
if _, ok := s.engs[w.Engine]; !ok {
339
-
err = s.db.StatusFailed(models.WorkflowId{
340
-
PipelineId: pipelineId,
341
-
Name: w.Name,
342
-
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
343
-
if err != nil {
344
-
return err
345
-
}
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
+
}
346
364
347
-
continue
348
-
}
365
+
var rawPipeline workflow.RawPipeline
366
+
for _, e := range workflowDir {
367
+
if !e.IsFile() {
368
+
continue
369
+
}
349
370
350
-
eng := s.engs[w.Engine]
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
+
}
351
376
352
-
if _, ok := workflows[eng]; !ok {
353
-
workflows[eng] = []models.Workflow{}
354
-
}
377
+
rawPipeline = append(rawPipeline, workflow.RawWorkflow{
378
+
Name: e.Name,
379
+
Contents: contents,
380
+
})
381
+
}
355
382
356
-
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
357
-
if err != nil {
358
-
return err
359
-
}
383
+
return rawPipeline, nil
384
+
}
360
385
361
-
// inject TANGLED_* env vars after InitWorkflow
362
-
// This prevents user-defined env vars from overriding them
363
-
if ewf.Environment == nil {
364
-
ewf.Environment = make(map[string]string)
365
-
}
366
-
maps.Copy(ewf.Environment, pipelineEnv)
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)
367
389
368
-
workflows[eng] = append(workflows[eng], *ewf)
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
+
}
369
404
370
-
err = s.db.StatusPending(models.WorkflowId{
371
-
PipelineId: pipelineId,
372
-
Name: w.Name,
373
-
}, s.n)
374
-
if err != nil {
375
-
return err
376
-
}
377
-
}
405
+
continue
378
406
}
379
407
380
-
ok := s.jq.Enqueue(queue.Job{
381
-
Run: func() error {
382
-
engine.StartWorkflows(log.SubLogger(s.l, "engine"), s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{
383
-
RepoOwner: tpl.TriggerMetadata.Repo.Did,
384
-
RepoName: tpl.TriggerMetadata.Repo.Repo,
385
-
Workflows: workflows,
386
-
}, pipelineId)
387
-
return nil
388
-
},
389
-
OnFail: func(jobError error) {
390
-
s.l.Error("pipeline run failed", "error", jobError)
391
-
},
392
-
})
393
-
if ok {
394
-
s.l.Info("pipeline enqueued successfully", "id", msg.Rkey)
395
-
} else {
396
-
s.l.Error("failed to enqueue pipeline: queue is full")
408
+
eng := s.engs[w.Engine]
409
+
410
+
if _, ok := workflows[eng]; !ok {
411
+
workflows[eng] = []models.Workflow{}
397
412
}
413
+
414
+
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
415
+
if err != nil {
416
+
return fmt.Errorf("init workflow: %w", err)
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)
398
427
}
399
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
+
}
400
460
return nil
401
461
}
402
462
403
-
func (s *Spindle) configureOwner() error {
404
-
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
+
}
405
468
406
-
existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain)
407
-
if err != nil {
408
-
return err
469
+
func (s *Spindle) newRepoCloneUrl(knot, did, name string) string {
470
+
scheme := "https://"
471
+
if s.cfg.Server.Dev {
472
+
scheme = "http://"
409
473
}
474
+
return fmt.Sprintf("%s%s/%s/%s", scheme, knot, did, name)
475
+
}
410
476
411
-
switch len(existing) {
412
-
case 0:
413
-
// no owner configured, continue
414
-
case 1:
415
-
// find existing owner
416
-
existingOwner := existing[0]
477
+
const RequiredVersion = "2.49.0"
417
478
418
-
// no ownership change, this is okay
419
-
if existingOwner == s.cfg.Server.Owner {
420
-
break
421
-
}
422
-
423
-
// remove existing owner
424
-
err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner)
425
-
if err != nil {
426
-
return nil
427
-
}
428
-
default:
429
-
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)
430
486
}
431
-
432
-
return s.e.AddSpindleOwner(rbacDomain, cfgOwner)
487
+
return nil
433
488
}
+391
spindle/tap.go
+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
+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
+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
+1
-1
spindle/xrpc/owner.go
+72
spindle/xrpc/pipeline_cancelPipeline.go
+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
+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
+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)
+24
tap/simpleIndexer.go
+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
+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
+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
+
)
-3
types/diff.go
-3
types/diff.go
-112
types/diff_test.go
-112
types/diff_test.go
···
1
-
package types
2
-
3
-
import "testing"
4
-
5
-
func TestDiffId(t *testing.T) {
6
-
tests := []struct {
7
-
name string
8
-
diff Diff
9
-
expected string
10
-
}{
11
-
{
12
-
name: "regular file uses new name",
13
-
diff: Diff{
14
-
Name: struct {
15
-
Old string `json:"old"`
16
-
New string `json:"new"`
17
-
}{Old: "", New: "src/main.go"},
18
-
},
19
-
expected: "src/main.go",
20
-
},
21
-
{
22
-
name: "new file uses new name",
23
-
diff: Diff{
24
-
Name: struct {
25
-
Old string `json:"old"`
26
-
New string `json:"new"`
27
-
}{Old: "", New: "src/new.go"},
28
-
IsNew: true,
29
-
},
30
-
expected: "src/new.go",
31
-
},
32
-
{
33
-
name: "deleted file uses old name",
34
-
diff: Diff{
35
-
Name: struct {
36
-
Old string `json:"old"`
37
-
New string `json:"new"`
38
-
}{Old: "src/deleted.go", New: ""},
39
-
IsDelete: true,
40
-
},
41
-
expected: "src/deleted.go",
42
-
},
43
-
{
44
-
name: "renamed file uses new name",
45
-
diff: Diff{
46
-
Name: struct {
47
-
Old string `json:"old"`
48
-
New string `json:"new"`
49
-
}{Old: "src/old.go", New: "src/renamed.go"},
50
-
IsRename: true,
51
-
},
52
-
expected: "src/renamed.go",
53
-
},
54
-
}
55
-
56
-
for _, tt := range tests {
57
-
t.Run(tt.name, func(t *testing.T) {
58
-
if got := tt.diff.Id(); got != tt.expected {
59
-
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
-
}
61
-
})
62
-
}
63
-
}
64
-
65
-
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
-
// ChangedFiles() must return values matching each Diff's Id()
67
-
// so that sidebar links point to the correct anchors.
68
-
// Tests existing, deleted, new, and renamed files.
69
-
nd := NiceDiff{
70
-
Diff: []Diff{
71
-
{
72
-
Name: struct {
73
-
Old string `json:"old"`
74
-
New string `json:"new"`
75
-
}{Old: "", New: "src/modified.go"},
76
-
},
77
-
{
78
-
Name: struct {
79
-
Old string `json:"old"`
80
-
New string `json:"new"`
81
-
}{Old: "src/deleted.go", New: ""},
82
-
IsDelete: true,
83
-
},
84
-
{
85
-
Name: struct {
86
-
Old string `json:"old"`
87
-
New string `json:"new"`
88
-
}{Old: "", New: "src/new.go"},
89
-
IsNew: true,
90
-
},
91
-
{
92
-
Name: struct {
93
-
Old string `json:"old"`
94
-
New string `json:"new"`
95
-
}{Old: "src/old.go", New: "src/renamed.go"},
96
-
IsRename: true,
97
-
},
98
-
},
99
-
}
100
-
101
-
changedFiles := nd.ChangedFiles()
102
-
103
-
if len(changedFiles) != len(nd.Diff) {
104
-
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
-
}
106
-
107
-
for i, diff := range nd.Diff {
108
-
if changedFiles[i] != diff.Id() {
109
-
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
-
}
111
-
}
112
-
}