Add Container Registry credential extension (#5993)

Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com>
Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
qwerty287
2026-03-18 22:55:38 +01:00
committed by GitHub
parent 9fb5d4dcdc
commit 1643e77286
22 changed files with 457 additions and 36 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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"
}
]
}
```

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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<Repo, 'config_extension_endpoint' | 'config_extension_exclusive'>;
export type ExtensionSettings = Pick<
Repo,
'config_extension_endpoint' | 'config_extension_exclusive' | 'registry_extension_endpoint'
>;
export interface RepoPermissions {
pull: boolean;

View File

@@ -18,6 +18,13 @@
/>
</InputField>
<InputField :label="$t('registry_extension_endpoint')" docs-url="docs/usage/extensions/registry-extension">
<TextField
v-model="extensions.registry_extension_endpoint"
:placeholder="$t('extension_endpoint_placeholder')"
/>
</InputField>
<Button :is-loading="isSaving" color="green" type="submit" :text="$t('save')" />
</form>
</Settings>
@@ -57,6 +64,7 @@ onMounted(async () => {
const extensions = ref<ExtensionSettings>({
config_extension_endpoint: repo.value.config_extension_endpoint,
config_extension_exclusive: repo.value.config_extension_exclusive,
registry_extension_endpoint: repo.value.registry_extension_endpoint,
});
const { doSubmit: saveExtensions, isLoading: isSaving } = useAsyncAction(async () => {