+242
-2
api/tangled/cbor_gen.go
+242
-2
api/tangled/cbor_gen.go
···
3923
3923
}
3924
3924
3925
3925
cw := cbg.NewCborWriter(w)
3926
-
fieldCount := 3
3926
+
fieldCount := 4
3927
3927
3928
3928
if t.Environment == nil {
3929
+
fieldCount--
3930
+
}
3931
+
3932
+
if t.Oidcs_tokens == nil {
3929
3933
fieldCount--
3930
3934
}
3931
3935
···
4007
4011
4008
4012
}
4009
4013
}
4014
+
4015
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
4016
+
if t.Oidcs_tokens != nil {
4017
+
4018
+
if len("oidcs_tokens") > 1000000 {
4019
+
return xerrors.Errorf("Value in field \"oidcs_tokens\" was too long")
4020
+
}
4021
+
4022
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oidcs_tokens"))); err != nil {
4023
+
return err
4024
+
}
4025
+
if _, err := cw.WriteString(string("oidcs_tokens")); err != nil {
4026
+
return err
4027
+
}
4028
+
4029
+
if len(t.Oidcs_tokens) > 8192 {
4030
+
return xerrors.Errorf("Slice value in field t.Oidcs_tokens was too long")
4031
+
}
4032
+
4033
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Oidcs_tokens))); err != nil {
4034
+
return err
4035
+
}
4036
+
for _, v := range t.Oidcs_tokens {
4037
+
if err := v.MarshalCBOR(cw); err != nil {
4038
+
return err
4039
+
}
4040
+
4041
+
}
4042
+
}
4010
4043
return nil
4011
4044
}
4012
4045
···
4035
4068
4036
4069
n := extra
4037
4070
4038
-
nameBuf := make([]byte, 11)
4071
+
nameBuf := make([]byte, 12)
4039
4072
for i := uint64(0); i < n; i++ {
4040
4073
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4041
4074
if err != nil {
···
4121
4154
}
4122
4155
4123
4156
}
4157
+
}
4158
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
4159
+
case "oidcs_tokens":
4160
+
4161
+
maj, extra, err = cr.ReadHeader()
4162
+
if err != nil {
4163
+
return err
4164
+
}
4165
+
4166
+
if extra > 8192 {
4167
+
return fmt.Errorf("t.Oidcs_tokens: array too large (%d)", extra)
4168
+
}
4169
+
4170
+
if maj != cbg.MajArray {
4171
+
return fmt.Errorf("expected cbor array")
4172
+
}
4173
+
4174
+
if extra > 0 {
4175
+
t.Oidcs_tokens = make([]*Pipeline_Step_Oidcs_tokens_Elem, extra)
4176
+
}
4177
+
4178
+
for i := 0; i < int(extra); i++ {
4179
+
{
4180
+
var maj byte
4181
+
var extra uint64
4182
+
var err error
4183
+
_ = maj
4184
+
_ = extra
4185
+
_ = err
4186
+
4187
+
{
4188
+
4189
+
b, err := cr.ReadByte()
4190
+
if err != nil {
4191
+
return err
4192
+
}
4193
+
if b != cbg.CborNull[0] {
4194
+
if err := cr.UnreadByte(); err != nil {
4195
+
return err
4196
+
}
4197
+
t.Oidcs_tokens[i] = new(Pipeline_Step_Oidcs_tokens_Elem)
4198
+
if err := t.Oidcs_tokens[i].UnmarshalCBOR(cr); err != nil {
4199
+
return xerrors.Errorf("unmarshaling t.Oidcs_tokens[i] pointer: %w", err)
4200
+
}
4201
+
}
4202
+
4203
+
}
4204
+
4205
+
}
4206
+
}
4207
+
4208
+
default:
4209
+
// Field doesn't exist on this type, so ignore it
4210
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
4211
+
return err
4212
+
}
4213
+
}
4214
+
}
4215
+
4216
+
return nil
4217
+
}
4218
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) MarshalCBOR(w io.Writer) error {
4219
+
if t == nil {
4220
+
_, err := w.Write(cbg.CborNull)
4221
+
return err
4222
+
}
4223
+
4224
+
cw := cbg.NewCborWriter(w)
4225
+
fieldCount := 2
4226
+
4227
+
if t.Aud == nil {
4228
+
fieldCount--
4229
+
}
4230
+
4231
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
4232
+
return err
4233
+
}
4234
+
4235
+
// t.Aud (string) (string)
4236
+
if t.Aud != nil {
4237
+
4238
+
if len("aud") > 1000000 {
4239
+
return xerrors.Errorf("Value in field \"aud\" was too long")
4240
+
}
4241
+
4242
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aud"))); err != nil {
4243
+
return err
4244
+
}
4245
+
if _, err := cw.WriteString(string("aud")); err != nil {
4246
+
return err
4247
+
}
4248
+
4249
+
if t.Aud == nil {
4250
+
if _, err := cw.Write(cbg.CborNull); err != nil {
4251
+
return err
4252
+
}
4253
+
} else {
4254
+
if len(*t.Aud) > 1000000 {
4255
+
return xerrors.Errorf("Value in field t.Aud was too long")
4256
+
}
4257
+
4258
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Aud))); err != nil {
4259
+
return err
4260
+
}
4261
+
if _, err := cw.WriteString(string(*t.Aud)); err != nil {
4262
+
return err
4263
+
}
4264
+
}
4265
+
}
4266
+
4267
+
// t.Name (string) (string)
4268
+
if len("name") > 1000000 {
4269
+
return xerrors.Errorf("Value in field \"name\" was too long")
4270
+
}
4271
+
4272
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
4273
+
return err
4274
+
}
4275
+
if _, err := cw.WriteString(string("name")); err != nil {
4276
+
return err
4277
+
}
4278
+
4279
+
if len(t.Name) > 1000000 {
4280
+
return xerrors.Errorf("Value in field t.Name was too long")
4281
+
}
4282
+
4283
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
4284
+
return err
4285
+
}
4286
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
4287
+
return err
4288
+
}
4289
+
return nil
4290
+
}
4291
+
4292
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) UnmarshalCBOR(r io.Reader) (err error) {
4293
+
*t = Pipeline_Step_Oidcs_tokens_Elem{}
4294
+
4295
+
cr := cbg.NewCborReader(r)
4296
+
4297
+
maj, extra, err := cr.ReadHeader()
4298
+
if err != nil {
4299
+
return err
4300
+
}
4301
+
defer func() {
4302
+
if err == io.EOF {
4303
+
err = io.ErrUnexpectedEOF
4304
+
}
4305
+
}()
4306
+
4307
+
if maj != cbg.MajMap {
4308
+
return fmt.Errorf("cbor input should be of type map")
4309
+
}
4310
+
4311
+
if extra > cbg.MaxLength {
4312
+
return fmt.Errorf("Pipeline_Step_Oidcs_tokens_Elem: map struct too large (%d)", extra)
4313
+
}
4314
+
4315
+
n := extra
4316
+
4317
+
nameBuf := make([]byte, 4)
4318
+
for i := uint64(0); i < n; i++ {
4319
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
4320
+
if err != nil {
4321
+
return err
4322
+
}
4323
+
4324
+
if !ok {
4325
+
// Field doesn't exist on this type, so ignore it
4326
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
4327
+
return err
4328
+
}
4329
+
continue
4330
+
}
4331
+
4332
+
switch string(nameBuf[:nameLen]) {
4333
+
// t.Aud (string) (string)
4334
+
case "aud":
4335
+
4336
+
{
4337
+
b, err := cr.ReadByte()
4338
+
if err != nil {
4339
+
return err
4340
+
}
4341
+
if b != cbg.CborNull[0] {
4342
+
if err := cr.UnreadByte(); err != nil {
4343
+
return err
4344
+
}
4345
+
4346
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4347
+
if err != nil {
4348
+
return err
4349
+
}
4350
+
4351
+
t.Aud = (*string)(&sval)
4352
+
}
4353
+
}
4354
+
// t.Name (string) (string)
4355
+
case "name":
4356
+
4357
+
{
4358
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
4359
+
if err != nil {
4360
+
return err
4361
+
}
4362
+
4363
+
t.Name = string(sval)
4124
4364
}
4125
4365
4126
4366
default:
+9
-3
api/tangled/tangledpipeline.go
+9
-3
api/tangled/tangledpipeline.go
···
63
63
64
64
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
65
65
type Pipeline_Step struct {
66
-
Command string `json:"command" cborgen:"command"`
67
-
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
-
Name string `json:"name" cborgen:"name"`
66
+
Command string `json:"command" cborgen:"command"`
67
+
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
68
+
Name string `json:"name" cborgen:"name"`
69
+
Oidcs_tokens []*Pipeline_Step_Oidcs_tokens_Elem `json:"oidcs_tokens,omitempty" cborgen:"oidcs_tokens,omitempty"`
70
+
}
71
+
72
+
type Pipeline_Step_Oidcs_tokens_Elem struct {
73
+
Aud *string `json:"aud,omitempty" cborgen:"aud,omitempty"`
74
+
Name string `json:"name" cborgen:"name"`
69
75
}
70
76
71
77
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
+2
-2
appview/db/db.go
+2
-2
appview/db/db.go
···
728
728
kind := rv.Kind()
729
729
730
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
731
-
if kind == reflect.Slice || kind == reflect.Array {
731
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
732
732
if rv.Len() == 0 {
733
733
// always false
734
734
return "1 = 0"
···
748
748
func (f filter) Arg() []any {
749
749
rv := reflect.ValueOf(f.arg)
750
750
kind := rv.Kind()
751
-
if kind == reflect.Slice || kind == reflect.Array {
751
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
752
752
if rv.Len() == 0 {
753
753
return nil
754
754
}
+2
-2
appview/db/repos.go
+2
-2
appview/db/repos.go
···
391
391
var description, spindle sql.NullString
392
392
393
393
row := e.QueryRow(`
394
-
select did, name, knot, created, at_uri, description, spindle
394
+
select did, name, knot, created, at_uri, description, spindle
395
395
from repos
396
396
where did = ? and name = ?
397
397
`,
···
556
556
return err
557
557
}
558
558
559
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
559
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
560
560
_, err := e.Exec(
561
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
562
562
return err
+4
-2
appview/pages/templates/repo/empty.html
+4
-2
appview/pages/templates/repo/empty.html
···
32
32
<div class="py-6 w-fit flex flex-col gap-4">
33
33
<p>This is an empty repository. To get started:</p>
34
34
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
35
+
<p><span class="{{$bullet}}">1</span>Add an SSH public key to your account from the <a href="/settings" class="underline">settings</a> page.
36
+
If you don't have one, you can generate a new SSH key pair using the following command: <code>ssh-keygen -t ed25519 -C "you@example.com"</code>
37
+
</p>
38
+
<p><span class="{{$bullet}}">2</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
37
39
<p><span class="{{$bullet}}">3</span>Push!</p>
38
40
</div>
39
41
</div>
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+9
-4
appview/pages/templates/repo/settings/pipelines.html
+9
-4
appview/pages/templates/repo/settings/pipelines.html
···
34
34
{{ else }}
35
35
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
36
<select
37
-
id="spindle"
37
+
id="spindle"
38
38
name="spindle"
39
-
required
39
+
required
40
40
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
-
<option value="" disabled>
41
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
42
44
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
43
48
</option>
44
49
{{ range $.Spindles }}
45
50
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
···
82
87
{{ end }}
83
88
84
89
{{ define "addSecretButton" }}
85
-
<button
90
+
<button
86
91
class="btn flex items-center gap-2"
87
92
popovertarget="add-secret-modal"
88
93
popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+3
-2
appview/pages/templates/repo/tree.html
+3
-2
appview/pages/templates/repo/tree.html
···
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "size-4" }}
64
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}{{ .Name }}
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
71
</a>
71
72
</div>
+5
-4
appview/pages/templates/user/fragments/repoCard.html
+5
-4
appview/pages/templates/user/fragments/repoCard.html
···
28
28
{{ define "repoStats" }}
29
29
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
30
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full"
33
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
34
+
<span>{{ . }}</span>
35
+
</div>
35
36
{{ end }}
36
37
{{ with .StarCount }}
37
38
<div class="flex gap-1 items-center text-sm">
+25
-15
appview/repo/repo.go
+25
-15
appview/repo/repo.go
···
657
657
}
658
658
659
659
newSpindle := r.FormValue("spindle")
660
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
660
661
client, err := rp.oauth.AuthorizedClient(r)
661
662
if err != nil {
662
663
fail("Failed to authorize. Try again later.", err)
663
664
return
664
665
}
665
666
666
-
// ensure that this is a valid spindle for this user
667
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
668
-
if err != nil {
669
-
fail("Failed to find spindles. Try again later.", err)
670
-
return
667
+
if !removingSpindle {
668
+
// ensure that this is a valid spindle for this user
669
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
670
+
if err != nil {
671
+
fail("Failed to find spindles. Try again later.", err)
672
+
return
673
+
}
674
+
675
+
if !slices.Contains(validSpindles, newSpindle) {
676
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
677
+
return
678
+
}
671
679
}
672
680
673
-
if !slices.Contains(validSpindles, newSpindle) {
674
-
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
675
-
return
681
+
spindlePtr := &newSpindle
682
+
if removingSpindle {
683
+
spindlePtr = nil
676
684
}
677
685
678
686
// optimistic update
679
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
687
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
680
688
if err != nil {
681
689
fail("Failed to update spindle. Try again later.", err)
682
690
return
···
699
707
Owner: user.Did,
700
708
CreatedAt: f.CreatedAt,
701
709
Description: &f.Description,
702
-
Spindle: &newSpindle,
710
+
Spindle: spindlePtr,
703
711
},
704
712
},
705
713
})
···
709
717
return
710
718
}
711
719
712
-
// add this spindle to spindle stream
713
-
rp.spindlestream.AddSource(
714
-
context.Background(),
715
-
eventconsumer.NewSpindleSource(newSpindle),
716
-
)
720
+
if !removingSpindle {
721
+
// add this spindle to spindle stream
722
+
rp.spindlestream.AddSource(
723
+
context.Background(),
724
+
eventconsumer.NewSpindleSource(newSpindle),
725
+
)
726
+
}
717
727
718
728
rp.pages.HxRefresh(w)
719
729
}
+5
appview/strings/strings.go
+5
appview/strings/strings.go
···
99
99
w.WriteHeader(http.StatusInternalServerError)
100
100
return
101
101
}
102
+
if len(strings) < 1 {
103
+
l.Error("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
102
107
if len(strings) != 1 {
103
108
l.Error("incorrect number of records returned", "len(strings)", len(strings))
104
109
w.WriteHeader(http.StatusInternalServerError)
+1
cmd/gen.go
+1
cmd/gen.go
+9
-10
docs/hacking.md
+9
-10
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
60
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
61
+
don't lose it.
62
62
63
63
You can now start a lightweight NixOS VM using
64
64
`nixos-shell` like so:
···
91
91
92
92
## running a spindle
93
93
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
94
+
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
95
+
The above VM should already be running a spindle on `localhost:6555`.
96
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
97
+
and register a spindle with hostname `localhost:6555`. It should instantly
98
+
be verified. You can then configure each repository to use this spindle
99
+
and run CI jobs.
101
100
102
101
Of interest when debugging spindles:
103
102
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
···
114
114
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
115
116
116
# Generate secret ID
117
-
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
117
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
118
119
119
echo "Role ID: $ROLE_ID"
120
120
echo "Secret ID: $SECRET_ID"
+67
-20
flake.nix
+67
-20
flake.nix
···
75
75
};
76
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
77
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
78
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
79
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
80
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
81
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
82
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
83
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
93
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
94
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
95
96
in {
96
-
appview = packages.appview;
97
-
lexgen = packages.lexgen;
98
-
knot = packages.knot;
99
-
knot-unwrapped = packages.knot-unwrapped;
100
-
spindle = packages.spindle;
101
-
genjwks = packages.genjwks;
102
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
103
98
104
99
pkgsStatic-appview = staticPackages.appview;
105
100
pkgsStatic-knot = staticPackages.knot;
···
121
116
stdenv = pkgs.pkgsStatic.stdenv;
122
117
};
123
118
in {
124
-
default = staticShell {
119
+
default = pkgs.mkShell {
125
120
nativeBuildInputs = [
126
121
pkgs.go
127
122
pkgs.air
···
132
127
pkgs.tailwindcss
133
128
pkgs.nixos-shell
134
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
135
131
packages'.lexgen
136
132
];
137
133
shellHook = ''
138
-
mkdir -p appview/pages/static/{fonts,icons}
139
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
140
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
141
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
142
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
143
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
144
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
145
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
146
138
'';
147
139
env.CGO_ENABLED = 1;
···
149
141
});
150
142
apps = forAllSystems (system: let
151
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
152
145
air-watcher = name: arg:
153
146
pkgs.writeShellScriptBin "run"
154
147
''
···
167
160
in {
168
161
watch-appview = {
169
162
type = "app";
170
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
171
168
};
172
169
watch-knot = {
173
170
type = "app";
···
177
174
type = "app";
178
175
program = ''${tailwind-watcher}/bin/run'';
179
176
};
180
-
vm = {
177
+
vm = let
178
+
system =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64"
181
+
else "x86_64";
182
+
183
+
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
+
patches =
185
+
(old.patches or [])
186
+
++ [
187
+
# https://github.com/Mic92/nixos-shell/pull/94
188
+
(pkgs.fetchpatch {
189
+
name = "fix-foreign-vm.patch";
190
+
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
+
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
+
})
193
+
];
194
+
});
195
+
in {
181
196
type = "app";
182
197
program = toString (pkgs.writeShellScript "vm" ''
183
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
198
+
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
184
199
'');
185
200
};
186
201
gomod2nix = {
···
189
204
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
190
205
'');
191
206
};
207
+
lexgen = {
208
+
type = "app";
209
+
program =
210
+
(pkgs.writeShellApplication {
211
+
name = "lexgen";
212
+
text = ''
213
+
if ! command -v lexgen > /dev/null; then
214
+
echo "error: must be executed from devshell"
215
+
exit 1
216
+
fi
217
+
218
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
+
cd "$rootDir"
220
+
221
+
rm api/tangled/*
222
+
lexgen --build-file lexicon-build-config.json lexicons
223
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
225
+
go run cmd/gen.go
226
+
lexgen --build-file lexicon-build-config.json lexicons
227
+
rm api/tangled/*.bak
228
+
'';
229
+
})
230
+
+ /bin/lexgen;
231
+
};
192
232
});
193
233
194
234
nixosModules.appview = {
···
218
258
219
259
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
220
260
};
221
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
261
+
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
+
inherit self nixpkgs;
263
+
system = "x86_64-linux";
264
+
};
265
+
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
+
inherit self nixpkgs;
267
+
system = "aarch64-linux";
268
+
};
222
269
};
223
270
}
+4
input.css
+4
input.css
+19
-12
knotserver/git/post_receive.go
+19
-12
knotserver/git/post_receive.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"strings"
···
57
58
ByEmail map[string]int
58
59
}
59
60
60
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
61
64
commitCount, err := g.newCommitCount(line)
62
-
if err != nil {
63
-
// TODO: log this
64
-
}
65
+
errors.Join(errs, err)
65
66
66
67
isDefaultRef, err := g.isDefaultBranch(line)
67
-
if err != nil {
68
-
// TODO: log this
69
-
}
68
+
errors.Join(errs, err)
70
69
71
70
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
71
defer cancel()
73
72
breakdown, err := g.AnalyzeLanguages(ctx)
74
-
if err != nil {
75
-
// TODO: log this
76
-
}
73
+
errors.Join(errs, err)
77
74
78
75
return RefUpdateMeta{
79
76
CommitCount: commitCount,
80
77
IsDefaultRef: isDefaultRef,
81
78
LangBreakdown: breakdown,
82
-
}
79
+
}, errs
83
80
}
84
81
85
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
95
92
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
93
97
94
if line.OldSha.IsZero() {
98
-
// just git rev-list <newsha>
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
99
96
args = append(args, line.NewSha.String())
97
+
98
+
branches, _ := g.Branches()
99
+
for _, b := range branches {
100
+
if !strings.Contains(line.Ref, b.Name) {
101
+
args = append(args, fmt.Sprintf("^%s", b.Name))
102
+
}
103
+
}
104
+
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
100
107
} else {
101
108
// git rev-list <oldsha>..<newsha>
102
109
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5
-2
knotserver/internal.go
+5
-2
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
145
146
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
147
}
147
148
148
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
149
152
150
153
metaRecord := meta.AsRecord()
151
154
···
169
172
EventJson: string(eventJson),
170
173
}
171
174
172
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
173
176
}
174
177
175
178
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+17
lexicons/pipeline/pipeline.json
+17
lexicons/pipeline/pipeline.json
···
241
241
"type": "ref",
242
242
"ref": "#pair"
243
243
}
244
+
},
245
+
"oidcs_tokens": {
246
+
"type": "array",
247
+
"items": {
248
+
"type": "object",
249
+
"required": [
250
+
"name"
251
+
],
252
+
"properties": {
253
+
"name": {
254
+
"type": "string"
255
+
},
256
+
"aud": {
257
+
"type": "string"
258
+
}
259
+
}
260
+
}
244
261
}
245
262
}
246
263
},
+23
nix/pkgs/appview-static-files.nix
+23
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {} ''
13
+
mkdir -p $out/{fonts,icons} && cd $out
14
+
cp -f ${htmx-src} htmx.min.js
15
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
16
+
cp -rf ${lucide-src}/*.svg icons/
17
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
18
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
19
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
20
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
21
+
# for whatever reason (produces broken css), so we are doing this instead
22
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
23
+
''
+3
-14
nix/pkgs/appview.nix
+3
-14
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
6
src,
12
7
}:
···
17
12
18
13
postUnpack = ''
19
14
pushd source
20
-
mkdir -p appview/pages/static/{fonts,icons}
21
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
22
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
23
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
24
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
25
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
26
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
27
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
28
17
popd
29
18
'';
30
19
+80
-65
nix/vm.nix
+80
-65
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
3
4
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
5
+
}: let
6
+
envVar = name: let
7
+
var = builtins.getEnv name;
8
+
in
9
+
if var == ""
10
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
else var;
12
+
in
13
+
nixpkgs.lib.nixosSystem {
14
+
inherit system;
15
+
modules = [
16
+
self.nixosModules.knot
17
+
self.nixosModules.spindle
18
+
({
19
+
config,
20
+
pkgs,
21
+
...
22
+
}: {
23
+
nixos-shell = {
24
+
inheritPath = false;
25
+
mounts = {
26
+
mountHome = false;
27
+
mountNixProfile = false;
28
+
};
29
+
};
30
+
virtualisation = {
31
+
memorySize = 2048;
32
+
diskSize = 10 * 1024;
33
+
cores = 2;
34
+
forwardPorts = [
35
+
# ssh
36
+
{
37
+
from = "host";
38
+
host.port = 2222;
39
+
guest.port = 22;
40
+
}
41
+
# knot
42
+
{
43
+
from = "host";
44
+
host.port = 6000;
45
+
guest.port = 6000;
46
+
}
47
+
# spindle
48
+
{
49
+
from = "host";
50
+
host.port = 6555;
51
+
guest.port = 6555;
52
+
}
53
+
];
54
+
};
55
+
services.getty.autologinUser = "root";
56
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
+
systemd.tmpfiles.rules = let
58
+
u = config.services.tangled-knot.gitUser;
59
+
g = config.services.tangled-knot.gitUser;
60
+
in [
61
+
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
38
63
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
motd = "Welcome to the development knot!\n";
52
-
server = {
53
-
secretFile = "/var/lib/knot/secret";
54
-
hostname = "localhost:6000";
55
-
listenAddr = "0.0.0.0:6000";
64
+
services.tangled-knot = {
65
+
enable = true;
66
+
motd = "Welcome to the development knot!\n";
67
+
server = {
68
+
secretFile = "/var/lib/knot/secret";
69
+
hostname = "localhost:6000";
70
+
listenAddr = "0.0.0.0:6000";
71
+
};
56
72
};
57
-
};
58
-
services.tangled-spindle = {
59
-
enable = true;
60
-
server = {
61
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
62
-
hostname = "localhost:6555";
63
-
listenAddr = "0.0.0.0:6555";
64
-
dev = true;
65
-
secrets = {
66
-
provider = "sqlite";
73
+
services.tangled-spindle = {
74
+
enable = true;
75
+
server = {
76
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
77
+
hostname = "localhost:6555";
78
+
listenAddr = "0.0.0.0:6555";
79
+
dev = true;
80
+
secrets = {
81
+
provider = "sqlite";
82
+
};
67
83
};
68
84
};
69
-
};
70
-
})
71
-
];
72
-
}
85
+
})
86
+
];
87
+
}
+15
-3
spindle/engine/engine.go
+15
-3
spindle/engine/engine.go
···
25
25
"tangled.sh/tangled.sh/core/spindle/config"
26
26
"tangled.sh/tangled.sh/core/spindle/db"
27
27
"tangled.sh/tangled.sh/core/spindle/models"
28
+
"tangled.sh/tangled.sh/core/spindle/oidc"
28
29
"tangled.sh/tangled.sh/core/spindle/secrets"
29
30
)
30
31
···
41
42
n *notifier.Notifier
42
43
cfg *config.Config
43
44
vault secrets.Manager
45
+
oidc oidc.OidcTokenGenerator
44
46
45
47
cleanupMu sync.Mutex
46
48
cleanup map[string][]cleanupFunc
47
49
}
48
50
49
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
51
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager, oidc *oidc.OidcTokenGenerator) (*Engine, error) {
50
52
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
51
53
if err != nil {
52
54
return nil, err
···
61
63
n: n,
62
64
cfg: cfg,
63
65
vault: vault,
66
+
oidc: *oidc,
64
67
}
65
68
66
69
e.cleanup = make(map[string][]cleanupFunc)
···
124
127
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
125
128
defer cancel()
126
129
127
-
err = e.StartSteps(ctx, wid, w, allSecrets)
130
+
err = e.StartSteps(ctx, wid, w, allSecrets, pipeline, pipelineId)
128
131
if err != nil {
129
132
if errors.Is(err, ErrTimedOut) {
130
133
dbErr := e.db.StatusTimeout(wid, e.n)
···
202
205
// ONLY marks pipeline as failed if container's exit code is non-zero.
203
206
// All other errors are bubbled up.
204
207
// Fixed version of the step execution logic
205
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
208
+
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret, pipeline *models.Pipeline, pipelineId models.PipelineId) error {
206
209
workflowEnvs := ConstructEnvs(w.Environment)
207
210
for _, s := range secrets {
208
211
workflowEnvs.AddEnv(s.Key, s.Value)
···
221
224
}
222
225
envs.AddEnv("HOME", workspaceDir)
223
226
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
227
+
228
+
for _, t := range step.OidcTokens {
229
+
token, err := e.oidc.CreateToken(t, pipelineId, pipeline.RepoOwner, pipeline.RepoName)
230
+
if err != nil {
231
+
e.l.Error("failed to get OIDC token", "error", err, "token", t.Name)
232
+
return fmt.Errorf("getting OIDC token: %w", err)
233
+
}
234
+
envs.AddEnv(t.Name, token)
235
+
}
224
236
225
237
hostConfig := hostConfig(wid)
226
238
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
+14
spindle/models/pipeline.go
+14
spindle/models/pipeline.go
···
18
18
Name string
19
19
Environment map[string]string
20
20
Kind StepKind
21
+
OidcTokens []OidcToken
21
22
}
22
23
23
24
type StepKind int
···
28
29
// steps defined by the user in the original pipeline
29
30
StepKindUser
30
31
)
32
+
33
+
type OidcToken struct {
34
+
Name string
35
+
Aud *string
36
+
}
31
37
32
38
type Workflow struct {
33
39
Steps []Step
···
60
66
sstep.Name = tstep.Name
61
67
sstep.Kind = StepKindUser
62
68
swf.Steps = append(swf.Steps, sstep)
69
+
70
+
sstep.OidcTokens = make([]OidcToken, 0, len(tstep.Oidcs_tokens))
71
+
for _, ttoken := range tstep.Oidcs_tokens {
72
+
sstep.OidcTokens = append(sstep.OidcTokens, OidcToken{
73
+
Name: ttoken.Name,
74
+
Aud: ttoken.Aud,
75
+
})
76
+
}
63
77
}
64
78
swf.Name = twf.Name
65
79
swf.Environment = workflowEnvToMap(twf.Environment)
+320
spindle/oidc/oidc.go
+320
spindle/oidc/oidc.go
···
1
+
package oidc
2
+
3
+
import (
4
+
"crypto/ecdsa"
5
+
"crypto/elliptic"
6
+
"crypto/rand"
7
+
"encoding/json"
8
+
"fmt"
9
+
"log/slog"
10
+
"net/http"
11
+
"reflect"
12
+
"time"
13
+
14
+
"github.com/lestrrat-go/jwx/v2/jwa"
15
+
"github.com/lestrrat-go/jwx/v2/jwk"
16
+
"github.com/lestrrat-go/jwx/v2/jwt"
17
+
"tangled.sh/tangled.sh/core/spindle/models"
18
+
)
19
+
20
+
const JWKSPath = "/.well-known/jwks.json"
21
+
const WebFingerPath = "/.well-known/webfinger"
22
+
23
+
// OidcKeyPair represents an OIDC key pair with both private and public keys
24
+
type OidcKeyPair struct {
25
+
privateKey *ecdsa.PrivateKey
26
+
publicKey *ecdsa.PublicKey
27
+
keyID string
28
+
jwkKey jwk.Key
29
+
}
30
+
31
+
// OidcTokenGenerator handles OIDC token generation and key management with rotation
32
+
type OidcTokenGenerator struct {
33
+
currentKeyPair OidcKeyPair
34
+
nextKeyPair *OidcKeyPair
35
+
l *slog.Logger
36
+
issuer string
37
+
claimsSupported []string
38
+
}
39
+
40
+
// NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management
41
+
func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) {
42
+
// Create new keys
43
+
currentKeyPair, err := NewOidcKeyPair()
44
+
if err != nil {
45
+
return nil, fmt.Errorf("failed to generate initial current key pair: %w", err)
46
+
}
47
+
48
+
// Use reflection to get claim field names from OidcClaims
49
+
var claimsSupported []string
50
+
claimsType := reflect.TypeOf(OidcClaims{})
51
+
for i := 0; i < claimsType.NumField(); i++ {
52
+
tag := claimsType.Field(i).Tag.Get("json")
53
+
if tag != "" {
54
+
claimsSupported = append(claimsSupported, tag)
55
+
}
56
+
}
57
+
58
+
return &OidcTokenGenerator{
59
+
issuer: issuer,
60
+
currentKeyPair: *currentKeyPair,
61
+
claimsSupported: claimsSupported,
62
+
}, nil
63
+
}
64
+
65
+
// NewOidcKeyPair generates a new ECDSA key pair for OIDC token signing
66
+
func NewOidcKeyPair() (*OidcKeyPair, error) {
67
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
68
+
if err != nil {
69
+
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
70
+
}
71
+
72
+
keyID := fmt.Sprintf("spindle-%d", time.Now().Unix())
73
+
74
+
// Create JWK from the private key
75
+
jwkKey, err := jwk.FromRaw(privKey)
76
+
if err != nil {
77
+
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
78
+
}
79
+
80
+
// Set the key ID
81
+
if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil {
82
+
return nil, fmt.Errorf("failed to set key ID: %w", err)
83
+
}
84
+
85
+
// Set algorithm
86
+
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
87
+
return nil, fmt.Errorf("failed to set algorithm: %w", err)
88
+
}
89
+
90
+
// Set usage
91
+
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
92
+
return nil, fmt.Errorf("failed to set key usage: %w", err)
93
+
}
94
+
95
+
return &OidcKeyPair{
96
+
privateKey: privKey,
97
+
publicKey: &privKey.PublicKey,
98
+
keyID: keyID,
99
+
jwkKey: jwkKey,
100
+
}, nil
101
+
}
102
+
103
+
func (k *OidcKeyPair) GetKeyID() string {
104
+
return k.keyID
105
+
}
106
+
107
+
// RotateKeys performs key rotation: generates new next key, moves next to current
108
+
func (g *OidcTokenGenerator) RotateKeys() error {
109
+
// Generate a new key pair for the next key
110
+
newNextKeyPair, err := NewOidcKeyPair()
111
+
if err != nil {
112
+
return fmt.Errorf("failed to generate new next key pair: %w", err)
113
+
}
114
+
115
+
// Perform rotation: next becomes current, new key becomes next
116
+
g.currentKeyPair = *g.nextKeyPair
117
+
g.nextKeyPair = newNextKeyPair
118
+
119
+
return nil
120
+
}
121
+
122
+
// OidcClaims represents the claims in an OIDC token
123
+
type OidcClaims struct {
124
+
// Standard JWT claims
125
+
Issuer string `json:"iss"`
126
+
Subject string `json:"sub"`
127
+
Audience string `json:"aud"`
128
+
ExpiresAt int64 `json:"exp"`
129
+
NotBefore int64 `json:"nbf"`
130
+
IssuedAt int64 `json:"iat"`
131
+
JWTID string `json:"jti"`
132
+
}
133
+
134
+
// CreateToken creates a signed JWT token for the given OidcToken and pipeline context
135
+
func (g *OidcTokenGenerator) CreateToken(
136
+
oidcToken models.OidcToken,
137
+
pipelineId models.PipelineId,
138
+
repoOwner, repoName string,
139
+
) (string, error) {
140
+
now := time.Now()
141
+
exp := now.Add(5 * time.Minute)
142
+
143
+
// Determine audience - use the provided audience or default to issuer
144
+
audience := g.issuer
145
+
if oidcToken.Aud != nil && *oidcToken.Aud != "" {
146
+
audience = *oidcToken.Aud
147
+
}
148
+
149
+
pipelineUri := pipelineId.AtUri()
150
+
151
+
// Create claims
152
+
claims := OidcClaims{
153
+
Issuer: g.issuer,
154
+
// Hardcode the did as did:web of the issuer. At some point knots will have their own DIDs which will be used here
155
+
Subject: pipelineUri.String(),
156
+
Audience: audience,
157
+
ExpiresAt: exp.Unix(),
158
+
NotBefore: now.Unix(),
159
+
IssuedAt: now.Unix(),
160
+
// Repo owner, name, and id should be global unique but we add timestamp to ensure uniqueness
161
+
JWTID: fmt.Sprintf("%s/%s-%s-%d", repoOwner, repoName, pipelineUri.RecordKey(), now.Unix()),
162
+
}
163
+
164
+
// Create JWT token
165
+
token := jwt.New()
166
+
167
+
// Set all claims
168
+
if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil {
169
+
return "", fmt.Errorf("failed to set issuer: %w", err)
170
+
}
171
+
if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil {
172
+
return "", fmt.Errorf("failed to set subject: %w", err)
173
+
}
174
+
if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil {
175
+
return "", fmt.Errorf("failed to set audience: %w", err)
176
+
}
177
+
if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil {
178
+
return "", fmt.Errorf("failed to set expiration: %w", err)
179
+
}
180
+
if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil {
181
+
return "", fmt.Errorf("failed to set not before: %w", err)
182
+
}
183
+
if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil {
184
+
return "", fmt.Errorf("failed to set issued at: %w", err)
185
+
}
186
+
if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil {
187
+
return "", fmt.Errorf("failed to set JWT ID: %w", err)
188
+
}
189
+
190
+
// Sign the token with the current key
191
+
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey))
192
+
if err != nil {
193
+
return "", fmt.Errorf("failed to sign token: %w", err)
194
+
}
195
+
196
+
return string(signedToken), nil
197
+
}
198
+
199
+
// JWKSHandler serves the JWKS endpoint (const JWKSPath)
200
+
func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) {
201
+
pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey)
202
+
if err != nil {
203
+
http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError)
204
+
return
205
+
}
206
+
var keys []jwk.Key
207
+
keys = append(keys, pubJWK)
208
+
209
+
// Add next key if available
210
+
if g.nextKeyPair != nil {
211
+
pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey)
212
+
if err != nil {
213
+
http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError)
214
+
return
215
+
}
216
+
keys = append(keys, pubJWK)
217
+
}
218
+
219
+
if len(keys) == 0 {
220
+
http.Error(w, "no keys available for JWKS", http.StatusInternalServerError)
221
+
return
222
+
}
223
+
224
+
jwks := map[string]interface{}{
225
+
"keys": keys,
226
+
}
227
+
228
+
w.Header().Set("Content-Type", "application/json")
229
+
if err := json.NewEncoder(w).Encode(jwks); err != nil {
230
+
http.Error(w, fmt.Sprintf("failed to encode JWKS: %v", err), http.StatusInternalServerError)
231
+
}
232
+
}
233
+
234
+
// DiscoveryHandler serves the OIDC discovery endpoint (/.well-known/openid-configuration)
235
+
func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
236
+
237
+
responseTypesSupported := []string{
238
+
"id_token",
239
+
}
240
+
241
+
subjectTypesSupported := []string{
242
+
"public",
243
+
}
244
+
245
+
idTokenSigningAlgValuesSupported := []string{
246
+
jwa.RS256.String(),
247
+
}
248
+
249
+
scopesSupported := []string{
250
+
"openid",
251
+
}
252
+
253
+
discovery := map[string]interface{}{
254
+
"issuer": g.issuer,
255
+
"jwks_uri": fmt.Sprintf("%s%s", g.issuer, JWKSPath),
256
+
"claims_supported": g.claimsSupported,
257
+
"response_types_supported": responseTypesSupported,
258
+
"subject_types_supported": subjectTypesSupported,
259
+
"id_token_signing_alg_values_supported": idTokenSigningAlgValuesSupported,
260
+
"scopes_supported": scopesSupported,
261
+
}
262
+
w.Header().Set("Content-Type", "application/json")
263
+
if err := json.NewEncoder(w).Encode(discovery); err != nil {
264
+
http.Error(w, fmt.Sprintf("failed to encode discovery document: %v", err), http.StatusInternalServerError)
265
+
}
266
+
}
267
+
268
+
// WebFingerResponse represents the WebFinger response format
269
+
type WebFingerResponse struct {
270
+
Subject string `json:"subject"`
271
+
Links []WebFingerLink `json:"links"`
272
+
}
273
+
274
+
// WebFingerLink represents a link in the WebFinger response
275
+
type WebFingerLink struct {
276
+
Rel string `json:"rel"`
277
+
Href string `json:"href"`
278
+
}
279
+
280
+
// WebFingerHandler serves the WebFinger endpoint for issuer discovery (/.well-known/webfinger)
281
+
// This implements OpenID Connect Discovery 1.0 Section 2 - OpenID Provider Issuer Discovery
282
+
func (g *OidcTokenGenerator) WebFingerHandler(w http.ResponseWriter, r *http.Request) {
283
+
// Parse query parameters
284
+
resource := r.URL.Query().Get("resource")
285
+
rel := r.URL.Query().Get("rel")
286
+
287
+
// Check if this is an OpenID Connect issuer discovery request
288
+
expectedRel := "http://openid.net/specs/connect/1.0/issuer"
289
+
if rel != "" && rel != expectedRel {
290
+
http.Error(w, "unsupported rel parameter", http.StatusBadRequest)
291
+
return
292
+
}
293
+
294
+
if resource == "" {
295
+
http.Error(w, "resource parameter is required", http.StatusBadRequest)
296
+
return
297
+
}
298
+
299
+
// Check if the resource matches the issuer
300
+
if resource != g.issuer {
301
+
http.Error(w, "issuer not found", http.StatusNotFound)
302
+
return
303
+
}
304
+
305
+
// Create the WebFinger response
306
+
response := WebFingerResponse{
307
+
Subject: resource,
308
+
Links: []WebFingerLink{
309
+
{
310
+
Rel: expectedRel,
311
+
Href: g.issuer,
312
+
},
313
+
},
314
+
}
315
+
316
+
w.Header().Set("Content-Type", "application/jrd+json")
317
+
if err := json.NewEncoder(w).Encode(response); err != nil {
318
+
http.Error(w, fmt.Sprintf("failed to encode WebFinger response: %v", err), http.StatusInternalServerError)
319
+
}
320
+
}
+16
-3
spindle/server.go
+16
-3
spindle/server.go
···
21
21
"tangled.sh/tangled.sh/core/spindle/db"
22
22
"tangled.sh/tangled.sh/core/spindle/engine"
23
23
"tangled.sh/tangled.sh/core/spindle/models"
24
+
"tangled.sh/tangled.sh/core/spindle/oidc"
24
25
"tangled.sh/tangled.sh/core/spindle/queue"
25
26
"tangled.sh/tangled.sh/core/spindle/secrets"
26
27
"tangled.sh/tangled.sh/core/spindle/xrpc"
···
93
94
return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider)
94
95
}
95
96
96
-
eng, err := engine.New(ctx, cfg, d, &n, vault)
97
+
oidc, err := oidc.NewOidcTokenGenerator(fmt.Sprintf("https://%s", cfg.Server.Hostname))
98
+
if err != nil {
99
+
return fmt.Errorf("failed to create OIDC token generator: %w", err)
100
+
}
101
+
102
+
eng, err := engine.New(ctx, cfg, d, &n, vault, oidc)
97
103
if err != nil {
98
104
return err
99
105
}
···
188
194
}()
189
195
190
196
logger.Info("starting spindle server", "address", cfg.Server.ListenAddr)
191
-
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router()))
197
+
logger.Error("server error", "error", http.ListenAndServe(cfg.Server.ListenAddr, spindle.Router(oidc)))
192
198
193
199
return nil
194
200
}
195
201
196
-
func (s *Spindle) Router() http.Handler {
202
+
func (s *Spindle) Router(oidcg *oidc.OidcTokenGenerator) http.Handler {
197
203
mux := chi.NewRouter()
198
204
199
205
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
···
204
210
w.Write([]byte(s.cfg.Server.Owner))
205
211
})
206
212
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
213
+
mux.HandleFunc(oidc.JWKSPath, oidcg.JWKSHandler)
214
+
mux.HandleFunc("/.well-known/oidc-configuration", oidcg.DiscoveryHandler)
215
+
mux.HandleFunc(oidc.WebFingerPath, oidcg.WebFingerHandler)
207
216
208
217
mux.Mount("/xrpc", s.XrpcRouter())
209
218
return mux
···
240
249
241
250
if tpl.TriggerMetadata.Repo == nil {
242
251
return fmt.Errorf("no repo data found")
252
+
}
253
+
254
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
255
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
243
256
}
244
257
245
258
// filter by repos