From 1643e77286de0ec7df6bba93ebf5c79adf3e3679 Mon Sep 17 00:00:00 2001 From: qwerty287 <80460567+qwerty287@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:55:38 +0100 Subject: [PATCH] Add Container Registry credential extension (#5993) Co-authored-by: techknowlogick Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com> Co-authored-by: 6543 <6543@obermui.de> --- cmd/server/flags.go | 5 + cmd/server/openapi/docs.go | 9 ++ .../72-extensions/50-registry-extension.md | 145 ++++++++++++++++++ docs/docs/20-usage/72-extensions/index.md | 1 + server/api/hook_test.go | 2 +- server/api/pipeline_test.go | 4 +- server/api/repo.go | 3 + server/model/repo.go | 2 + server/pipeline/create.go | 2 +- server/pipeline/items.go | 6 +- server/pipeline/items_test.go | 4 +- server/services/manager.go | 7 +- server/services/registry/combined.go | 5 +- server/services/registry/db.go | 4 +- server/services/registry/http.go | 79 ++++++++++ .../services/registry/mocks/mock_Service.go | 40 +++-- server/services/registry/service.go | 8 +- server/services/registry/with_extension.go | 136 ++++++++++++++++ server/services/setup.go | 14 +- web/src/assets/locales/en.json | 1 + web/src/lib/api/types/repo.ts | 8 +- web/src/views/repo/settings/Extensions.vue | 8 + 22 files changed, 457 insertions(+), 36 deletions(-) create mode 100644 docs/docs/20-usage/72-extensions/50-registry-extension.md create mode 100644 server/services/registry/http.go create mode 100644 server/services/registry/with_extension.go diff --git a/cmd/server/flags.go b/cmd/server/flags.go index 6b136bec1..ccfb81334 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -283,6 +283,11 @@ var flags = append([]cli.Flag{ Name: "config-service-exclusive", Usage: "whether global configuration service endpoint should be exclusive (skip forge)", }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_REGISTRY_SERVICE_ENDPOINT"), + Name: "registry-service-endpoint", + Usage: "url used for calling registry service endpoint", + }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_EXTENSIONS_ALLOWED_HOSTS"), Name: "extensions-allowed-hosts", diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go index 68ce140b6..ce17c7175 100644 --- a/cmd/server/openapi/docs.go +++ b/cmd/server/openapi/docs.go @@ -5283,6 +5283,9 @@ const docTemplate = `{ "private": { "type": "boolean" }, + "registry_extension_endpoint": { + "type": "string" + }, "require_approval": { "$ref": "#/definitions/model.ApprovalMode" }, @@ -5382,6 +5385,9 @@ const docTemplate = `{ "private": { "type": "boolean" }, + "registry_extension_endpoint": { + "type": "string" + }, "require_approval": { "$ref": "#/definitions/model.ApprovalMode" }, @@ -5432,6 +5438,9 @@ const docTemplate = `{ "type": "string" } }, + "registry_extension_endpoint": { + "type": "string" + }, "require_approval": { "type": "string" }, diff --git a/docs/docs/20-usage/72-extensions/50-registry-extension.md b/docs/docs/20-usage/72-extensions/50-registry-extension.md new file mode 100644 index 000000000..30e78a0b4 --- /dev/null +++ b/docs/docs/20-usage/72-extensions/50-registry-extension.md @@ -0,0 +1,145 @@ +# Registry extension + +Woodpecker uses the registry extension to get registry credentials. You can configure an HTTP endpoint in the repository settings in the extensions tab. + +Using such an extension can be useful if you want to: + +- Centralize registry credential management +- Use an external storage for credentials +- Dynamically manage which credentials Woodpecker should use + +## Security + +:::warning +As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). +::: + +## Global configuration + +In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if +you share your Woodpecker server with others as they will also use your registry extension. + +If both the global and the repo-level extension return credentials for a registry, it will use the credentials from the repo extension. + +```ini title="Server" +WOODPECKER_REGISTRY_SERVICE_ENDPOINT=https://example.com/ciconfig +``` + +## How it works + +When a pipeline is triggered, Woodpecker will fetch the credentials from your service. As fallback, it uses the credentials configured directly in Woodpecker. + +### Request + +The extension receives an HTTP POST request with the following JSON payload: + +```ts +class Request { + repo: Repo; + pipeline: Pipeline; +} +``` + +Checkout the following models for more information: + +- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) +- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) + +Example request: + +```json +// Please check the latest structure in the models mentioned above. +// This example is likely outdated. + +{ + "repo": { + "id": 100, + "uid": "", + "user_id": 0, + "namespace": "", + "name": "woodpecker-test-pipeline", + "slug": "", + "scm": "git", + "git_http_url": "", + "git_ssh_url": "", + "link": "", + "default_branch": "", + "private": true, + "visibility": "private", + "active": true, + "config": "", + "trusted": false, + "protected": false, + "ignore_forks": false, + "ignore_pulls": false, + "cancel_pulls": false, + "timeout": 60, + "counter": 0, + "synced": 0, + "created": 0, + "updated": 0, + "version": 0 + }, + "pipeline": { + "author": "myUser", + "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", + "author_email": "my@email.com", + "branch": "main", + "changed_files": ["some-filename.txt"], + "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", + "created_at": 0, + "deploy_to": "", + "enqueued_at": 0, + "error": "", + "event": "push", + "finished_at": 0, + "id": 0, + "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", + "message": "test old config\n", + "number": 0, + "parent": 0, + "ref": "refs/heads/main", + "refspec": "", + "clone_url": "", + "reviewed_at": 0, + "reviewed_by": "", + "sender": "myUser", + "signed": false, + "started_at": 0, + "status": "", + "timestamp": 1645962783, + "title": "", + "updated_at": 0, + "verified": false + } +} +``` + +### Response + +The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. +If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. + +```ts +class Response { + registries: { + address: string; // the docker registry address + username: string; // registry username + password: string; // registry password + }[]; +} +``` + +Example response: + +```json +{ + "registries": [ + { + "address": "docker.io", + "username": "woodpecker-bot", + "password": "your-pass-word-123" + } + ] +} +``` diff --git a/docs/docs/20-usage/72-extensions/index.md b/docs/docs/20-usage/72-extensions/index.md index e30cb53f4..d0cd7134c 100644 --- a/docs/docs/20-usage/72-extensions/index.md +++ b/docs/docs/20-usage/72-extensions/index.md @@ -5,6 +5,7 @@ Woodpecker allows you to replace internal logic with external extensions by usin There is currently one type of extension available: - [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly. +- [Registry extension](./50-registry-extension.md) to get registry credentials from the extension. ## Security diff --git a/server/api/hook_test.go b/server/api/hook_test.go index f294e5042..c49194752 100644 --- a/server/api/hook_test.go +++ b/server/api/hook_test.go @@ -85,7 +85,7 @@ func TestHook(t *testing.T) { _manager.On("SecretServiceFromRepo", repo).Return(_secretService) _secretService.On("SecretListPipeline", repo, mock.Anything, mock.Anything).Return(nil, nil) _manager.On("RegistryServiceFromRepo", repo).Return(_registryService) - _registryService.On("RegistryListPipeline", repo, mock.Anything).Return(nil, nil) + _registryService.On("RegistryListPipeline", mock.Anything, repo, mock.Anything).Return(nil, nil) _manager.On("EnvironmentService").Return(nil) _store.On("DeletePipeline", mock.Anything).Return(nil) diff --git a/server/api/pipeline_test.go b/server/api/pipeline_test.go index 70b3d24f6..e01ef22fa 100644 --- a/server/api/pipeline_test.go +++ b/server/api/pipeline_test.go @@ -305,7 +305,7 @@ func TestCreatePipeline(t *testing.T) { mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe() mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe() - mockRegistryService.On("RegistryListPipeline", fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe() + mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe() mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) @@ -378,7 +378,7 @@ func TestCreatePipeline(t *testing.T) { mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe() mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe() - mockRegistryService.On("RegistryListPipeline", fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe() + mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe() mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) diff --git a/server/api/repo.go b/server/api/repo.go index 21224bc25..70d6d4396 100644 --- a/server/api/repo.go +++ b/server/api/repo.go @@ -292,6 +292,9 @@ func PatchRepo(c *gin.Context) { if in.ConfigExtensionExclusive != nil { repo.ConfigExtensionExclusive = *in.ConfigExtensionExclusive } + if in.RegistryExtensionEndpoint != nil { + repo.RegistryExtensionEndpoint = *in.RegistryExtensionEndpoint + } err := _store.UpdateRepo(repo) if err != nil { diff --git a/server/model/repo.go b/server/model/repo.go index e64367588..96891fb9d 100644 --- a/server/model/repo.go +++ b/server/model/repo.go @@ -74,6 +74,7 @@ type Repo struct { NetrcTrustedPlugins []string `json:"netrc_trusted" xorm:"json 'netrc_trusted'"` ConfigExtensionEndpoint string `json:"config_extension_endpoint" xorm:"varchar(500) 'config_extension_endpoint'"` ConfigExtensionExclusive bool `json:"config_extension_exclusive" xorm:"DEFAULT FALSE 'config_extension_exclusive'"` + RegistryExtensionEndpoint string `json:"registry_extension_endpoint" xorm:"varchar(500) 'registry_extension_endpoint'"` } // @name Repo // TableName return database table name for xorm. @@ -146,6 +147,7 @@ type RepoPatch struct { Trusted *TrustedConfigurationPatch `json:"trusted"` ConfigExtensionEndpoint *string `json:"config_extension_endpoint,omitempty"` ConfigExtensionExclusive *bool `json:"config_extension_exclusive"` + RegistryExtensionEndpoint *string `json:"registry_extension_endpoint,omitempty"` } // @name RepoPatch type ForgeRemoteID string diff --git a/server/pipeline/create.go b/server/pipeline/create.go index 4bfcedf4f..92b507ba7 100644 --- a/server/pipeline/create.go +++ b/server/pipeline/create.go @@ -96,7 +96,7 @@ func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline return nil, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, fmt.Errorf("could not load config from forge: %w", configFetchErr)) } - pipelineItems, parseErr := parsePipeline(_forge, _store, pipeline, repoUser, repo, forgeYamlConfigs, nil) + pipelineItems, parseErr := parsePipeline(ctx, _forge, _store, pipeline, repoUser, repo, forgeYamlConfigs, nil) if pipeline_errors.HasBlockingErrors(parseErr) { log.Debug().Str("repo", repo.FullName).Err(parseErr).Msg("failed to parse yaml") return pipeline, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, parseErr) diff --git a/server/pipeline/items.go b/server/pipeline/items.go index d0bf26b3b..5eb1cf189 100644 --- a/server/pipeline/items.go +++ b/server/pipeline/items.go @@ -33,7 +33,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/store" ) -func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string) ([]*stepbuilder.Item, error) { +func parsePipeline(ctx context.Context, forge forge.Forge, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string) ([]*stepbuilder.Item, error) { netrc, err := forge.Netrc(user, repo) if err != nil { log.Error().Err(err).Msg("failed to generate netrc file") @@ -67,7 +67,7 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model. } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) - regs, err := registryService.RegistryListPipeline(repo, currentPipeline) + regs, err := registryService.RegistryListPipeline(ctx, repo, currentPipeline) if err != nil { log.Error().Err(err).Msgf("error getting registry credentials for %s#%d", repo.FullName, currentPipeline.Number) } @@ -141,7 +141,7 @@ func createPipelineItems(c context.Context, forge forge.Forge, store store.Store currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string, ) (*model.Pipeline, []*stepbuilder.Item, error) { - pipelineItems, err := parsePipeline(forge, store, currentPipeline, user, repo, yamls, envs) + pipelineItems, err := parsePipeline(c, forge, store, currentPipeline, user, repo, yamls, envs) if pipeline_errors.HasBlockingErrors(err) { currentPipeline, uErr := UpdateToStatusError(store, *currentPipeline, err) if uErr != nil { diff --git a/server/pipeline/items_test.go b/server/pipeline/items_test.go index 291877b93..78b162a06 100644 --- a/server/pipeline/items_test.go +++ b/server/pipeline/items_test.go @@ -127,7 +127,7 @@ steps: mockManager.On("SecretServiceFromRepo", mock.Anything).Return(secretService, nil) registryService := registry_service_mocks.NewMockService(t) - registryService.On("RegistryListPipeline", mock.Anything, mock.Anything).Return([]*model.Registry{ + registryService.On("RegistryListPipeline", mock.Anything, mock.Anything, mock.Anything).Return([]*model.Registry{ { Address: "docker.io", Username: "user", @@ -138,7 +138,7 @@ steps: mockManager.On("EnvironmentService").Return(nil, nil) - pipelineItems, err := parsePipeline(forge, store, pipeline, user, repo, yamls, envs) + pipelineItems, err := parsePipeline(t.Context(), forge, store, pipeline, user, repo, yamls, envs) assert.NoError(t, err) assert.Len(t, pipelineItems, 1) diff --git a/server/services/manager.go b/server/services/manager.go index 7c6417ed6..778042b7c 100644 --- a/server/services/manager.go +++ b/server/services/manager.go @@ -88,7 +88,7 @@ func NewManager(c *cli.Command, store store.Store, setupForge SetupForge) (Manag signaturePublicKey: signaturePublicKey, store: store, secret: setupSecretService(store), - registry: setupRegistryService(store, c.String("docker-config")), + registry: setupRegistryService(store, c.String("docker-config"), c.String("registry-service-endpoint"), client), config: configService, environment: environment.Parse(c.StringSlice("environment")), forgeCache: ttlcache.New(ttlcache.WithDisableTouchOnHit[int64, forge.Forge]()), @@ -109,7 +109,10 @@ func (m *manager) SecretService() secret.Service { return m.secret } -func (m *manager) RegistryServiceFromRepo(_ *model.Repo) registry.Service { +func (m *manager) RegistryServiceFromRepo(repo *model.Repo) registry.Service { + if repo.RegistryExtensionEndpoint != "" { + return registry.NewWithExtension(m.registry, registry.NewHTTP(strings.TrimRight(repo.RegistryExtensionEndpoint, "/"), m.client)) + } return m.RegistryService() } diff --git a/server/services/registry/combined.go b/server/services/registry/combined.go index 0cb551156..f4d624ea1 100644 --- a/server/services/registry/combined.go +++ b/server/services/registry/combined.go @@ -15,6 +15,7 @@ package registry import ( + "context" "errors" "go.woodpecker-ci.org/woodpecker/v3/server/model" @@ -42,8 +43,8 @@ func (c *combined) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*mode return c.dbRegistry.RegistryList(repo, p) } -func (c *combined) RegistryListPipeline(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { - dbRegistries, err := c.dbRegistry.RegistryListPipeline(repo, pipeline) +func (c *combined) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { + dbRegistries, err := c.dbRegistry.RegistryListPipeline(ctx, repo, pipeline) if err != nil { return nil, err } diff --git a/server/services/registry/db.go b/server/services/registry/db.go index efdb2c8c4..f06842429 100644 --- a/server/services/registry/db.go +++ b/server/services/registry/db.go @@ -15,6 +15,8 @@ package registry import ( + "context" + "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) @@ -36,7 +38,7 @@ func (d *db) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Regi return d.store.RegistryList(repo, false, p) } -func (d *db) RegistryListPipeline(repo *model.Repo, _ *model.Pipeline) ([]*model.Registry, error) { +func (d *db) RegistryListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline) ([]*model.Registry, error) { r, err := d.store.RegistryList(repo, true, &model.ListOptions{All: true}) if err != nil { return nil, err diff --git a/server/services/registry/http.go b/server/services/registry/http.go new file mode 100644 index 000000000..81c836fac --- /dev/null +++ b/server/services/registry/http.go @@ -0,0 +1,79 @@ +// Copyright 2025 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "context" + "fmt" + net_http "net/http" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" + "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" +) + +type httpExtension struct { + endpoint string + client *utils.Client +} + +type requestStructure struct { + Repo *model.Repo `json:"repo"` + Pipeline *model.Pipeline `json:"pipeline"` +} + +type responseStructure struct { + Registries []*registryData `json:"registries"` +} + +type registryData struct { + Address string `json:"address"` + Username string `json:"username"` + Password string `json:"password"` +} + +// NewHTTP returns a new HTTP registry extension client. +func NewHTTP(endpoint string, client *utils.Client) *httpExtension { + return &httpExtension{endpoint, client} +} + +// RegistryListPipeline fetches registry credentials from an external HTTP extension. +func (h *httpExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { + response := new(responseStructure) + body := requestStructure{ + Repo: repo, + Pipeline: pipeline, + } + + status, err := h.client.Send(ctx, net_http.MethodPost, h.endpoint, body, response) + if err != nil && status != net_http.StatusNoContent { + return nil, fmt.Errorf("failed to fetch registries via http (%d) %w", status, err) + } + + if status != net_http.StatusOK { + // 204 No Content means no additional registries + return nil, nil + } + + registries := make([]*model.Registry, len(response.Registries)) + for i, reg := range response.Registries { + registries[i] = &model.Registry{ + Address: reg.Address, + Username: reg.Username, + Password: reg.Password, + } + } + + return registries, nil +} diff --git a/server/services/registry/mocks/mock_Service.go b/server/services/registry/mocks/mock_Service.go index 3fdbe7d11..109460c3a 100644 --- a/server/services/registry/mocks/mock_Service.go +++ b/server/services/registry/mocks/mock_Service.go @@ -5,6 +5,8 @@ package mocks import ( + "context" + mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) @@ -871,8 +873,8 @@ func (_c *MockService_RegistryList_Call) RunAndReturn(run func(repo *model.Repo, } // RegistryListPipeline provides a mock function for the type MockService -func (_mock *MockService) RegistryListPipeline(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { - ret := _mock.Called(repo, pipeline) +func (_mock *MockService) RegistryListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { + ret := _mock.Called(context1, repo, pipeline) if len(ret) == 0 { panic("no return value specified for RegistryListPipeline") @@ -880,18 +882,18 @@ func (_mock *MockService) RegistryListPipeline(repo *model.Repo, pipeline *model var r0 []*model.Registry var r1 error - if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Pipeline) ([]*model.Registry, error)); ok { - return returnFunc(repo, pipeline) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline) ([]*model.Registry, error)); ok { + return returnFunc(context1, repo, pipeline) } - if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Pipeline) []*model.Registry); ok { - r0 = returnFunc(repo, pipeline) + if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline) []*model.Registry); ok { + r0 = returnFunc(context1, repo, pipeline) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } - if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.Pipeline) error); ok { - r1 = returnFunc(repo, pipeline) + if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline) error); ok { + r1 = returnFunc(context1, repo, pipeline) } else { r1 = ret.Error(1) } @@ -904,25 +906,31 @@ type MockService_RegistryListPipeline_Call struct { } // RegistryListPipeline is a helper method to define mock.On call +// - context1 context.Context // - repo *model.Repo // - pipeline *model.Pipeline -func (_e *MockService_Expecter) RegistryListPipeline(repo interface{}, pipeline interface{}) *MockService_RegistryListPipeline_Call { - return &MockService_RegistryListPipeline_Call{Call: _e.mock.On("RegistryListPipeline", repo, pipeline)} +func (_e *MockService_Expecter) RegistryListPipeline(context1 interface{}, repo interface{}, pipeline interface{}) *MockService_RegistryListPipeline_Call { + return &MockService_RegistryListPipeline_Call{Call: _e.mock.On("RegistryListPipeline", context1, repo, pipeline)} } -func (_c *MockService_RegistryListPipeline_Call) Run(run func(repo *model.Repo, pipeline *model.Pipeline)) *MockService_RegistryListPipeline_Call { +func (_c *MockService_RegistryListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline)) *MockService_RegistryListPipeline_Call { _c.Call.Run(func(args mock.Arguments) { - var arg0 *model.Repo + var arg0 context.Context if args[0] != nil { - arg0 = args[0].(*model.Repo) + arg0 = args[0].(context.Context) } - var arg1 *model.Pipeline + var arg1 *model.Repo if args[1] != nil { - arg1 = args[1].(*model.Pipeline) + arg1 = args[1].(*model.Repo) + } + var arg2 *model.Pipeline + if args[2] != nil { + arg2 = args[2].(*model.Pipeline) } run( arg0, arg1, + arg2, ) }) return _c @@ -933,7 +941,7 @@ func (_c *MockService_RegistryListPipeline_Call) Return(registrys []*model.Regis return _c } -func (_c *MockService_RegistryListPipeline_Call) RunAndReturn(run func(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error)) *MockService_RegistryListPipeline_Call { +func (_c *MockService_RegistryListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error)) *MockService_RegistryListPipeline_Call { _c.Call.Return(run) return _c } diff --git a/server/services/registry/service.go b/server/services/registry/service.go index f42bbe488..5e45d272c 100644 --- a/server/services/registry/service.go +++ b/server/services/registry/service.go @@ -14,11 +14,15 @@ package registry -import "go.woodpecker-ci.org/woodpecker/v3/server/model" +import ( + "context" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) // Service defines a service for managing registries. type Service interface { - RegistryListPipeline(*model.Repo, *model.Pipeline) ([]*model.Registry, error) + RegistryListPipeline(context.Context, *model.Repo, *model.Pipeline) ([]*model.Registry, error) // Repository registries RegistryFind(*model.Repo, string) (*model.Registry, error) RegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error) diff --git a/server/services/registry/with_extension.go b/server/services/registry/with_extension.go new file mode 100644 index 000000000..0e233c4ac --- /dev/null +++ b/server/services/registry/with_extension.go @@ -0,0 +1,136 @@ +// Copyright 2025 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package registry + +import ( + "context" + + "github.com/rs/zerolog/log" + + "go.woodpecker-ci.org/woodpecker/v3/server/model" +) + +type withExtension struct { + base Service + extension *httpExtension +} + +// NewWithExtension returns a registry service that combines a base service with an HTTP extension. +// The extension is called during RegistryListPipeline to fetch additional registry credentials and +// the extension registries taking priority. +func NewWithExtension(base Service, extension *httpExtension) Service { + return &withExtension{base, extension} +} + +func (w *withExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline) ([]*model.Registry, error) { + // Get registries from base service + baseRegistries, err := w.base.RegistryListPipeline(ctx, repo, pipeline) + if err != nil { + return nil, err + } + + // Get registries from HTTP extension + extensionRegistries, err := w.extension.RegistryListPipeline(ctx, repo, pipeline) + if err != nil { + // Log the error but don't fail - use base registries only + log.Warn().Err(err).Msg("failed to fetch registries from extension") + return baseRegistries, nil + } + + if len(extensionRegistries) == 0 { + return baseRegistries, nil + } + + // Merge registries, with extension registries taking priority (no duplicates by address) + exists := make(map[string]struct{}, len(extensionRegistries)) + for _, reg := range extensionRegistries { + exists[reg.Address] = struct{}{} + } + + merged := make([]*model.Registry, 0, len(baseRegistries)+len(extensionRegistries)) + merged = append(merged, extensionRegistries...) + + for _, reg := range baseRegistries { + if _, ok := exists[reg.Address]; ok { + continue + } + exists[reg.Address] = struct{}{} + merged = append(merged, reg) + } + + return merged, nil +} + +// All other methods delegate to the base service. + +func (w *withExtension) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { + return w.base.RegistryFind(repo, addr) +} + +func (w *withExtension) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { + return w.base.RegistryList(repo, p) +} + +func (w *withExtension) RegistryCreate(repo *model.Repo, registry *model.Registry) error { + return w.base.RegistryCreate(repo, registry) +} + +func (w *withExtension) RegistryUpdate(repo *model.Repo, registry *model.Registry) error { + return w.base.RegistryUpdate(repo, registry) +} + +func (w *withExtension) RegistryDelete(repo *model.Repo, addr string) error { + return w.base.RegistryDelete(repo, addr) +} + +func (w *withExtension) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) { + return w.base.OrgRegistryFind(owner, addr) +} + +func (w *withExtension) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { + return w.base.OrgRegistryList(owner, p) +} + +func (w *withExtension) OrgRegistryCreate(owner int64, registry *model.Registry) error { + return w.base.OrgRegistryCreate(owner, registry) +} + +func (w *withExtension) OrgRegistryUpdate(owner int64, registry *model.Registry) error { + return w.base.OrgRegistryUpdate(owner, registry) +} + +func (w *withExtension) OrgRegistryDelete(owner int64, addr string) error { + return w.base.OrgRegistryDelete(owner, addr) +} + +func (w *withExtension) GlobalRegistryFind(addr string) (*model.Registry, error) { + return w.base.GlobalRegistryFind(addr) +} + +func (w *withExtension) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { + return w.base.GlobalRegistryList(p) +} + +func (w *withExtension) GlobalRegistryCreate(registry *model.Registry) error { + return w.base.GlobalRegistryCreate(registry) +} + +func (w *withExtension) GlobalRegistryUpdate(registry *model.Registry) error { + return w.base.GlobalRegistryUpdate(registry) +} + +func (w *withExtension) GlobalRegistryDelete(addr string) error { + return w.base.GlobalRegistryDelete(addr) +} diff --git a/server/services/setup.go b/server/services/setup.go index c6677aec2..dde7abdf5 100644 --- a/server/services/setup.go +++ b/server/services/setup.go @@ -35,15 +35,23 @@ import ( "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) -func setupRegistryService(store store.Store, dockerConfig string) registry.Service { +func setupRegistryService(store store.Store, dockerConfig, endpoint string, client *utils.Client) registry.Service { + var service registry.Service if dockerConfig != "" { - return registry.NewCombined( + service = registry.NewCombined( registry.NewDB(store), registry.NewFilesystem(dockerConfig), ) + } else { + service = registry.NewDB(store) } - return registry.NewDB(store) + // Wrap with global HTTP extension if configured + if endpoint != "" { + service = registry.NewWithExtension(service, registry.NewHTTP(endpoint, client)) + } + + return service } func setupSecretService(store store.Store) secret.Service { diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 2c857d926..3f1ed36b2 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -532,6 +532,7 @@ "extensions_description": "Extensions are HTTP services that can be called by Woodpecker instead of using the builtin ones.", "extension_endpoint_placeholder": "e.g. https://example.com/api", "config_extension_endpoint": "Config extension endpoint", + "registry_extension_endpoint": "Registry extension endpoint", "config_extension_exclusive": "Exclusive", "config_extension_exclusive_desc": "If enabled, will skip all other ways of configuration fetching, including the forge.", "extensions_signatures_public_key": "Public key for signatures", diff --git a/web/src/lib/api/types/repo.ts b/web/src/lib/api/types/repo.ts index bd5bf4c22..82624c44b 100644 --- a/web/src/lib/api/types/repo.ts +++ b/web/src/lib/api/types/repo.ts @@ -84,6 +84,9 @@ export interface Repo { config_extension_endpoint: string; config_extension_exclusive: boolean; + + // Endpoint for registry extensions + registry_extension_endpoint: string; } /* eslint-disable no-unused-vars */ @@ -115,7 +118,10 @@ export type RepoSettings = Pick< | 'netrc_trusted' >; -export type ExtensionSettings = Pick; +export type ExtensionSettings = Pick< + Repo, + 'config_extension_endpoint' | 'config_extension_exclusive' | 'registry_extension_endpoint' +>; export interface RepoPermissions { pull: boolean; diff --git a/web/src/views/repo/settings/Extensions.vue b/web/src/views/repo/settings/Extensions.vue index 1571f3287..998b8c90d 100644 --- a/web/src/views/repo/settings/Extensions.vue +++ b/web/src/views/repo/settings/Extensions.vue @@ -18,6 +18,13 @@ /> + + + +