mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-04-15 01:41:56 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
145
docs/docs/20-usage/72-extensions/50-registry-extension.md
Normal file
145
docs/docs/20-usage/72-extensions/50-registry-extension.md
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
79
server/services/registry/http.go
Normal file
79
server/services/registry/http.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
136
server/services/registry/with_extension.go
Normal file
136
server/services/registry/with_extension.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user