Support exclusive extensions (#5978)

Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
qwerty287
2026-01-26 16:13:00 +01:00
committed by GitHub
parent e332da9f3b
commit ff6895308a
14 changed files with 83 additions and 113 deletions

View File

@@ -276,7 +276,12 @@ var flags = append([]cli.Flag{
&cli.StringFlag{
Sources: cli.EnvVars("WOODPECKER_CONFIG_SERVICE_ENDPOINT"),
Name: "config-service-endpoint",
Usage: "url used for calling configuration service endpoint",
Usage: "url used for calling global configuration service endpoint",
},
&cli.BoolFlag{
Sources: cli.EnvVars("WOODPECKER_CONFIG_SERVICE_EXCLUSIVE"),
Name: "config-service-exclusive",
Usage: "whether global configuration service endpoint should be exclusive (skip forge)",
},
&cli.StringFlag{
Sources: cli.EnvVars("WOODPECKER_EXTENSIONS_ALLOWED_HOSTS"),

View File

@@ -5223,6 +5223,9 @@ const docTemplate = `{
"config_extension_endpoint": {
"type": "string"
},
"config_extension_exclusive": {
"type": "boolean"
},
"config_file": {
"type": "string"
},
@@ -5316,6 +5319,9 @@ const docTemplate = `{
"config_extension_endpoint": {
"type": "string"
},
"config_extension_exclusive": {
"type": "boolean"
},
"config_file": {
"type": "string"
},
@@ -5400,6 +5406,9 @@ const docTemplate = `{
"config_extension_endpoint": {
"type": "string"
},
"config_extension_exclusive": {
"type": "boolean"
},
"config_file": {
"type": "string"
},

View File

@@ -23,7 +23,7 @@ As Woodpecker will pass private information like tokens and will execute the ret
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 configuration extension.
The global configuration will be called before the repository specific configuration extension if both are configured.
The global configuration will be called before the repository specific configuration extension if both are configured and the repository has not enabled the exclusive setting.
```ini title="Server"
WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig
@@ -33,6 +33,8 @@ WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig
When a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used.
You can enable the exclusive setting (both globally and on a per-repo level). Then Woodpecker will only call your extension, but nothing else. This allows you to completely skip the forge. Requests sent to the extension will not have the configuration files added.
### Request
The extension receives an HTTP POST request with the following JSON payload:
@@ -42,6 +44,11 @@ class Request {
repo: Repo;
pipeline: Pipeline;
netrc: Netrc;
configuration: {
// list of configurations. Not send if there was none.
name: string; // filename of the configuration file
data: string; // content of the configuration file
}[];
}
```
@@ -52,15 +59,12 @@ Checkout the following models for more information:
- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)
:::tip
The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to fetch files or other information (like changed files, issues) from the repository using the forge api or even clone the repository.
The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.
:::
Example request:
```json
// Please check the latest structure in the models mentioned above.
// This example is likely outdated.
{
"repo": {
"id": 100,
@@ -122,12 +126,12 @@ Example request:
"updated_at": 0,
"verified": false
},
"netrc": {
"machine": "myforge.com",
"login": "myUser",
"password": "myPassword",
"type": "forge"
}
"configuration": [
{
"name": ".woodpecker.yaml",
"data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from Repo (.woodpecker.yaml)\"\n"
}
]
}
```

View File

@@ -402,97 +402,6 @@ woodpecker_waiting_steps 0
woodpecker_worker_count 4
```
## External Configuration API
To provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service.
Before the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration.
Every request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/rfc9421) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`.
A simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service)
:::warning
You need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks.
:::
### Configuration
```ini title="Server"
WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig
```
#### Example request made by Woodpecker
```json
{
"repo": {
"id": 100,
"uid": "",
"user_id": 0,
"namespace": "",
"name": "woodpecker-test-pipe",
"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-file-name.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
},
"netrc": {
"machine": "https://example.com",
"login": "user",
"password": "password"
}
}
```
#### Example response structure
```json
@@ -1067,7 +976,20 @@ Supported variables:
- Name: `WOODPECKER_CONFIG_SERVICE_ENDPOINT`
- Default: none
Specify a configuration service endpoint, see [Configuration Extension](#external-configuration-api)
Specify a configuration service endpoint, see [Configuration Extension](../../20-usage/72-extensions/40-configuration-extension.md)
---
### CONFIG_SERVICE_EXCLUSIVE
- Name: `CONFIG_SERVICE_EXCLUSIVE`
- Default: false
Whether the forge request should be skipped for the global configuration endpoint.
:::warning
If you enable this, all repos will exclusively use the global config service endpoint. There is no possibility to directly define pipelines in the forge, except the extension handles this case itself as well.
:::
---

View File

@@ -8,7 +8,7 @@
## Addons and extensions
If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an
[addon](../30-administration/10-configuration/100-addons.md), [extension](../30-administration/10-configuration/10-server.md#external-configuration-api) or an
[addon](../30-administration/10-configuration/100-addons.md), [extension](../20-usage/72-extensions/40-configuration-extension.md) or an
[external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points:
- Is your change very specific to your setup and unlikely to be used by anyone else?

View File

@@ -187,7 +187,6 @@ The Webhook tokens have been changed for enhanced security and therefore existin
Image pull secrets must now be set explicitly via env var `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` ([#4005](https://github.com/woodpecker-ci/woodpecker/pull/4005))
- Webhook signatures now use the `rfc9421` protocol
- Replaced `configs` object by `netrc` in external configuration APIs
- Git is now the only officially supported SCM.
No others were supported previously, but the existence of the env var `CI_REPO_SCM` indicated that others might be.

View File

@@ -287,6 +287,9 @@ func PatchRepo(c *gin.Context) {
if in.ConfigExtensionEndpoint != nil {
repo.ConfigExtensionEndpoint = *in.ConfigExtensionEndpoint
}
if in.ConfigExtensionExclusive != nil {
repo.ConfigExtensionExclusive = *in.ConfigExtensionExclusive
}
err := _store.UpdateRepo(repo)
if err != nil {

View File

@@ -73,6 +73,7 @@ type Repo struct {
CancelPreviousPipelineEvents []WebhookEvent `json:"cancel_previous_pipeline_events" xorm:"json 'cancel_previous_pipeline_events'"`
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'"`
} // @name Repo
// TableName return database table name for xorm.
@@ -144,6 +145,7 @@ type RepoPatch struct {
NetrcTrusted *[]string `json:"netrc_trusted"`
Trusted *TrustedConfigurationPatch `json:"trusted"`
ConfigExtensionEndpoint *string `json:"config_extension_endpoint,omitempty"`
ConfigExtensionExclusive *bool `json:"config_extension_exclusive"`
} // @name RepoPatch
type ForgeRemoteID string

View File

@@ -37,9 +37,10 @@ type configData struct {
}
type requestStructure struct {
Repo *model.Repo `json:"repo"`
Pipeline *model.Pipeline `json:"pipeline"`
Netrc *model.Netrc `json:"netrc"`
Repo *model.Repo `json:"repo"`
Pipeline *model.Pipeline `json:"pipeline"`
Netrc *model.Netrc `json:"netrc"`
Configuration []*configData `json:"configuration,omitempty"`
}
type responseStructure struct {
@@ -56,11 +57,17 @@ func (h *http) Fetch(ctx context.Context, forge forge.Forge, user *model.User, r
return nil, fmt.Errorf("could not get Netrc data from forge: %w", err)
}
configuration := make([]*configData, len(oldConfigData))
for i, oldConfig := range oldConfigData {
configuration[i] = &configData{Name: oldConfig.Name, Data: string(oldConfig.Data)}
}
response := new(responseStructure)
body := requestStructure{
Repo: repo,
Pipeline: pipeline,
Netrc: netrc,
Repo: repo,
Pipeline: pipeline,
Netrc: netrc,
Configuration: configuration,
}
status, err := h.client.Send(ctx, net_http.MethodPost, h.endpoint, body, response)

View File

@@ -119,6 +119,9 @@ func (m *manager) RegistryService() registry.Service {
func (m *manager) ConfigServiceFromRepo(repo *model.Repo) config.Service {
if repo.ConfigExtensionEndpoint != "" {
if repo.ConfigExtensionExclusive {
return config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, "/"), m.client)
}
return config.NewCombined(m.config, config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, "/"), m.client))
}

View File

@@ -68,6 +68,9 @@ func setupConfigService(c *cli.Command, client *utils.Client) (config.Service, e
if endpoint := c.String("config-service-endpoint"); endpoint != "" {
httpFetcher := config.NewHTTP(endpoint, client)
if c.Bool("config-service-exclusive") {
return httpFetcher, nil
}
return config.NewCombined(configFetcher, httpFetcher), nil
}

View File

@@ -525,6 +525,8 @@
"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",
"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",
"extensions_signatures_public_key_description": "This public key should be used by your extensions to verify webhook calls from Woodpecker.",
"extensions_configuration_saved": "Extensions configuration saved",

View File

@@ -82,6 +82,8 @@ export interface Repo {
// Endpoint for config extensions
config_extension_endpoint: string;
config_extension_exclusive: boolean;
}
/* eslint-disable no-unused-vars */
@@ -113,7 +115,7 @@ export type RepoSettings = Pick<
| 'netrc_trusted'
>;
export type ExtensionSettings = Pick<Repo, 'config_extension_endpoint'>;
export type ExtensionSettings = Pick<Repo, 'config_extension_endpoint' | 'config_extension_exclusive'>;
export interface RepoPermissions {
pull: boolean;

View File

@@ -9,6 +9,13 @@
</InputField>
<InputField :label="$t('config_extension_endpoint')" docs-url="docs/usage/extensions/configuration-extension">
<TextField v-model="extensions.config_extension_endpoint" :placeholder="$t('extension_endpoint_placeholder')" />
<Checkbox
v-model="extensions.config_extension_exclusive"
class="pt-3"
:label="$t('config_extension_exclusive')"
:description="$t('config_extension_exclusive_desc')"
/>
</InputField>
<Button :is-loading="isSaving" color="green" type="submit" :text="$t('save')" />
@@ -22,6 +29,7 @@ import type { Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
import Checkbox from '~/components/form/Checkbox.vue';
import InputField from '~/components/form/InputField.vue';
import TextField from '~/components/form/TextField.vue';
import Settings from '~/components/layout/Settings.vue';
@@ -48,6 +56,7 @@ onMounted(async () => {
const extensions = ref<ExtensionSettings>({
config_extension_endpoint: repo.value.config_extension_endpoint,
config_extension_exclusive: repo.value.config_extension_exclusive,
});
const { doSubmit: saveExtensions, isLoading: isSaving } = useAsyncAction(async () => {