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

Compare changes

Choose any two refs to compare.

Changed files
+704 -53
api
appview
db
pages
templates
repo
user
fragments
repo
cmd
knotserver
lexicons
pipeline
spindle
engine
models
oidc
+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
··· 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/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
··· 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>
+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">
+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
··· 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 }
+1
cmd/gen.go
··· 34 34 tangled.Pipeline_PushTriggerData{}, 35 35 tangled.PipelineStatus{}, 36 36 tangled.Pipeline_Step{}, 37 + tangled.Pipeline_Step_Oidcs_tokens_Elem{}, 37 38 tangled.Pipeline_TriggerMetadata{}, 38 39 tangled.Pipeline_TriggerRepo{}, 39 40 tangled.Pipeline_Workflow{},
+1 -1
flake.nix
··· 116 116 stdenv = pkgs.pkgsStatic.stdenv; 117 117 }; 118 118 in { 119 - default = staticShell { 119 + default = pkgs.mkShell { 120 120 nativeBuildInputs = [ 121 121 pkgs.go 122 122 pkgs.air
+4
input.css
··· 70 70 details summary::-webkit-details-marker { 71 71 display: none; 72 72 } 73 + 74 + code { 75 + @apply font-mono p-1 rounded bg-gray-100 dark:bg-gray-700; 76 + } 73 77 } 74 78 75 79 @layer components {
+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
··· 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
··· 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 },
+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
··· 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
··· 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 + }
+12 -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