1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2025-01-11 17:18:09 +02:00

Merge branch 'main' into service-use

This commit is contained in:
qwerty287 2024-11-15 08:27:39 +02:00
commit 54e5a12297
No known key found for this signature in database
49 changed files with 1111 additions and 214 deletions

View File

@ -74,8 +74,9 @@ steps:
deploy-prepare:
image: *alpine_image
secrets:
- BOT_PRIVATE_KEY
environment:
BOT_PRIVATE_KEY:
from_secret: BOT_PRIVATE_KEY
commands:
- apk add openssh-client git
- mkdir -p $HOME/.ssh
@ -127,8 +128,9 @@ steps:
deploy:
image: *alpine_image
secrets:
- BOT_PRIVATE_KEY
environment:
BOT_PRIVATE_KEY:
from_secret: BOT_PRIVATE_KEY
commands:
- apk add openssh-client rsync git
- mkdir -p $HOME/.ssh

View File

@ -35,11 +35,8 @@ steps:
services:
server:
image: *trivy_plugin
# settings:
# service: true
# db-repository: docker.io/aquasec/trivy-db:2
environment:
PLUGIN_SERVICE: 'true'
PLUGIN_DB_REPOSITORY: 'docker.io/aquasec/trivy-db:2'
settings:
service: true
db-repository: docker.io/aquasec/trivy-db:2
ports:
- 10000

View File

@ -5,7 +5,7 @@ when:
steps:
- name: lint-editorconfig
image: docker.io/mstruebing/editorconfig-checker:v3.0.3
image: docker.io/woodpeckerci/plugin-editorconfig-checker:0.2.0
depends_on: []
when:
- event: pull_request

View File

@ -40,6 +40,7 @@ steps:
- go run go.woodpecker-ci.org/woodpecker/v2/cmd/cli lint
environment:
WOODPECKER_DISABLE_UPDATE_CHECK: true
WOODPECKER_LINT_STRICT: true
WOODPECKER_PLUGINS_PRIVILEGED: 'docker.io/woodpeckerci/plugin-docker-buildx:5.0.0'
when:
- event: pull_request

View File

@ -220,7 +220,7 @@ func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, ax
Workflow: conf,
}})
if err != nil {
str, err := lint.FormatLintError(file, err)
str, err := lint.FormatLintError(file, err, false)
fmt.Print(str)
if err != nil {
return err

View File

@ -48,6 +48,11 @@ var Command = &cli.Command{
Usage: "Plugins which are trusted to handle the netrc info in clone steps",
Value: constant.TrustedClonePlugins,
},
&cli.BoolFlag{
Sources: cli.EnvVars("WOODPECKER_LINT_STRICT"),
Name: "strict",
Usage: "treat warnings as errors",
},
},
}
@ -119,7 +124,7 @@ func lintFile(_ context.Context, c *cli.Command, file string) error {
linter.WithTrustedClonePlugins(c.StringSlice("plugins-trusted-clone")),
).Lint([]*linter.WorkflowConfig{config})
if err != nil {
str, err := FormatLintError(config.File, err)
str, err := FormatLintError(config.File, err, c.Bool("strict"))
if str != "" {
fmt.Print(str)

View File

@ -10,7 +10,7 @@ import (
pipeline_errors "go.woodpecker-ci.org/woodpecker/v2/pipeline/errors"
)
func FormatLintError(file string, err error) (string, error) {
func FormatLintError(file string, err error, strict bool) (string, error) {
if err == nil {
return "", nil
}
@ -24,7 +24,7 @@ func FormatLintError(file string, err error) (string, error) {
for _, err := range linterErrors {
line := " "
if err.IsWarning {
if !strict && err.IsWarning {
line = fmt.Sprintf("%s ⚠️ ", line)
amountWarnings++
} else {

View File

@ -173,6 +173,11 @@ var flags = append([]cli.Flag{
Usage: "The maximum time in minutes you can set in the repo settings before a pipeline gets killed",
Value: 120,
},
&cli.StringSliceFlag{
Sources: cli.EnvVars("WOODPECKER_DEFAULT_WORKFLOW_LABELS"),
Name: "default-workflow-labels",
Usage: "The default label filter to set for workflows that has no label filter set. By default workflows will be allowed to run on any agent, if not specified in the workflow.",
},
&cli.DurationFlag{
Sources: cli.EnvVars("WOODPECKER_SESSION_EXPIRES"),
Name: "session-expires",
@ -213,6 +218,11 @@ var flags = append([]cli.Flag{
Name: "agent-secret",
Usage: "server-agent shared password",
},
&cli.BoolFlag{
Sources: cli.EnvVars("WOODPECKER_DISABLE_USER_AGENT_REGISTRATION"),
Name: "disable-user-agent-registration",
Usage: "Disable user registered agents",
},
&cli.DurationFlag{
Sources: cli.EnvVars("WOODPECKER_KEEPALIVE_MIN_TIME"),
Name: "keepalive-min-time",

View File

@ -167,6 +167,9 @@ func setupEvilGlobals(ctx context.Context, c *cli.Command, s store.Store) (err e
return fmt.Errorf("could not setup log store: %w", err)
}
// agents
server.Config.Agent.DisableUserRegisteredAgentRegistration = c.Bool("disable-user-agent-registration")
// authentication
server.Config.Pipeline.AuthenticatePublicRepos = c.Bool("authenticate-public-repos")
@ -185,6 +188,17 @@ func setupEvilGlobals(ctx context.Context, c *cli.Command, s store.Store) (err e
server.Config.Pipeline.DefaultTimeout = c.Int("default-pipeline-timeout")
server.Config.Pipeline.MaxTimeout = c.Int("max-pipeline-timeout")
_labels := c.StringSlice("default-workflow-labels")
labels := make(map[string]string, len(_labels))
for _, v := range _labels {
name, value, ok := strings.Cut(v, "=")
if !ok {
return fmt.Errorf("invalid label filter: %s", v)
}
labels[name] = value
}
server.Config.Pipeline.DefaultWorkflowLabels = labels
// backend options for pipeline compiler
server.Config.Pipeline.Proxy.No = c.String("backend-no-proxy")
server.Config.Pipeline.Proxy.HTTP = c.String("backend-http-proxy")

View File

@ -40,9 +40,16 @@ Only server admins can set this option. If you are not a server admin this optio
:::
## Only inject netrc credentials into trusted containers
## Only inject netrc credentials into trusted clone plugins
Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.
The clone step may require git credentials (e.g. for private repos) which are injected via `netrc`.
By default, they are only injected into trusted clone plugins listed in the env var `WOODPECKER_PLUGINS_TRUSTED_CLONE`.
If this option is disabled, the git credentials are injected into every clone plugin, regardless of whether it is trusted or not.
:::note
This option has no effect on steps other than the clone step.
:::
## Project visibility

View File

@ -7,3 +7,44 @@ The chart contains two sub-charts, `server` and `agent` which are automatically
The chart started off with two independent charts but was merged into one to simplify the deployment at start of 2023.
A couple of backend-specific config env vars exists which are described in the [kubernetes backend docs](../22-backends/40-kubernetes.md).
## Metrics
Please see [Prometheus](../40-advanced/90-prometheus.md) for general information on configuration and usage.
For Kubernetes, you must set the following values when deploying via Helm chart to enable in-cluster metrics gathering:
```yaml
metrics:
enabled: true
port: 9001
```
This activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at [Prometheus](../40-advanced/90-prometheus.md) if you want to enable authenticated external access to metrics.
To enable Prometheus pod monitoring discovery, you must also make the following settings:
<!-- cspell:disable -->
```yaml
prometheus:
podmonitor:
enabled: true
interval: 60s
labels: {}
```
<!-- cspell:enable -->
### Troubleshooting Metrics
If you are not receiving metrics despite the steps above, ensure that in your Prometheus configuration either your namespace is explicitly configured in `podMonitorNamespaceSelector` or the selectors are disabled.
```yaml
# Search all available namespaces
podMonitorNamespaceSelector:
matchLabels: {}
# Enable all available pod monitors
podMonitorSelector:
matchLabels: {}
```

View File

@ -54,6 +54,20 @@ Use the `WOODPECKER_REPO_OWNERS` variable to filter which GitHub user's repos sh
WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user
```
## Disallow normal users to create agents
By default, users can create new agents for their repos they have admin access to.
If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements.
:::note
You should set this option if you have, for example,
global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction.
:::
```ini
WOODPECKER_DISABLE_USER_AGENT_REGISTRATION=true
```
## Global registry setting
If you want to make available a specific private registry to all pipelines, use the `WOODPECKER_DOCKER_CONFIG` server configuration.
@ -343,6 +357,14 @@ The default docker image to be used when cloning the repo.
It is also added to the trusted clone plugin list.
### `WOODPECKER_DEFAULT_WORKFLOW_LABELS`
> By default run workflows on any agent if no label conditions are set in workflow definition.
You can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set.
Example: `platform=linux/amd64,backend=docker`
### `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT`
> 60 (minutes)
@ -422,6 +444,12 @@ A shared secret used by server and agents to authenticate communication. A secre
Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath
### `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION`
> Default: false
[Read about "Disallow normal users to create agents"](./10-server-config.md#disallow-normal-users-to-create-agents)
### `WOODPECKER_KEEPALIVE_MIN_TIME`
> Default: empty

View File

@ -18,10 +18,6 @@ FROM woodpeckerci/woodpecker-server:latest-alpine
RUN apk add -U --no-cache docker-credential-ecr-login
```
## Podman support
While the agent was developed with Docker/Moby, Podman can also be used by setting the environment variable `DOCKER_HOST` to point to the Podman socket. In order to work without workarounds, Podman 4.0 (or above) is required.
## Image cleanup
The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner.
@ -44,6 +40,12 @@ docker image rm $(docker images --filter "dangling=true" -q --no-trunc)
docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q)
```
## Tips and tricks
### Podman
There is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog).
## Configuration
### `WOODPECKER_BACKEND_DOCKER_NETWORK`

View File

@ -50,6 +50,21 @@ See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/container
`serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally.
See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts.
```yaml
steps:
- name: 'My kubernetes step'
image: alpine
commands:
- echo "Hello world"
backend_options:
kubernetes:
# Use the service account `default` in the current namespace.
# This usually the same as wherever woodpecker is deployed.
serviceAccountName: default
```
To give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)
### Node selector
`nodeSelector` specifies the labels which are used to select the node on which the job will be executed.
@ -119,7 +134,19 @@ steps:
### Volumes
To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option.
Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a step:
Persistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference.
_If your PVC is not highly available or NFS-based, you may also need to integrate affinity settings to ensure that your steps are executed on the correct node._
NOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver:
```yaml
accessModes:
- ReadWriteMany
```
Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step:
```yaml
steps:
@ -133,6 +160,19 @@ steps:
[...]
```
Or as follows when using a normal image:
```yaml
steps:
- name: "Edit cache"
image: alpine:latest
volumes:
- woodpecker-cache:/woodpecker/src/cache
commands:
- echo "Hello World" > /woodpecker/src/cache/output.txt
[...]
```
### Security context
Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step:

View File

@ -32,7 +32,7 @@
},
{
"name": "Prettier",
"docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-prettier/main/docs.md",
"docs": "https://codeberg.org/woodpecker-plugins/prettier/raw/branch/main/docs.md",
"verified": true
},
{
@ -219,6 +219,11 @@
"name": "Telegram",
"docs": "https://raw.githubusercontent.com/appleboy/drone-telegram/refs/heads/master/DOCS.md",
"verified": false
},
{
"name": "EditorConfig Checker",
"docs": "https://codeberg.org/woodpecker-plugins/editorconfig-checker/raw/branch/main/docs.md",
"verified": true
}
]
}

View File

@ -33,9 +33,16 @@ Only server admins can set this option. If you are not a server admin this optio
:::
### Only inject netrc credentials into trusted containers
### Only inject netrc credentials into trusted clone plugins
Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.
The clone step may require git credentials (e.g. for private repos) which are injected via `netrc`.
By default, they are only injected into trusted clone plugins listed in the env var `WOODPECKER_PLUGINS_TRUSTED_CLONE`.
If this option is disabled, the git credentials are injected into every clone plugin, regardless of whether it is trusted or not.
:::note
This option has no effect on steps other than the clone step.
:::
## Project visibility

View File

@ -40,9 +40,16 @@ Only server admins can set this option. If you are not a server admin this optio
:::
## Only inject netrc credentials into trusted containers
## Only inject netrc credentials into trusted clone plugins
Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.
The clone step may require git credentials (e.g. for private repos) which are injected via `netrc`.
By default, they are only injected into trusted clone plugins listed in the env var `WOODPECKER_PLUGINS_TRUSTED_CLONE`.
If this option is disabled, the git credentials are injected into every clone plugin, regardless of whether it is trusted or not.
:::note
This option has no effect on steps other than the clone step.
:::
## Project visibility

View File

@ -40,9 +40,16 @@ Only server admins can set this option. If you are not a server admin this optio
:::
## Only inject netrc credentials into trusted containers
## Only inject netrc credentials into trusted clone plugins
Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.
The clone step may require git credentials (e.g. for private repos) which are injected via `netrc`.
By default, they are only injected into trusted clone plugins listed in the env var `WOODPECKER_PLUGINS_TRUSTED_CLONE`.
If this option is disabled, the git credentials are injected into every clone plugin, regardless of whether it is trusted or not.
:::note
This option has no effect on steps other than the clone step.
:::
## Project visibility

View File

@ -40,9 +40,16 @@ Only server admins can set this option. If you are not a server admin this optio
:::
## Only inject netrc credentials into trusted containers
## Only inject netrc credentials into trusted clone plugins
Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step.
The clone step may require git credentials (e.g. for private repos) which are injected via `netrc`.
By default, they are only injected into trusted clone plugins listed in the env var `WOODPECKER_PLUGINS_TRUSTED_CLONE`.
If this option is disabled, the git credentials are injected into every clone plugin, regardless of whether it is trusted or not.
:::note
This option has no effect on steps other than the clone step.
:::
## Project visibility

View File

@ -252,10 +252,10 @@ func (e *kube) WaitStep(ctx context.Context, step *types.Step, taskUUID string)
finished := make(chan bool)
podUpdated := func(_, new any) {
pod, ok := new.(*v1.Pod)
podUpdated := func(_, newPod any) {
pod, ok := newPod.(*v1.Pod)
if !ok {
log.Error().Msgf("could not parse pod: %v", new)
log.Error().Msgf("could not parse pod: %v", newPod)
return
}
@ -328,10 +328,10 @@ func (e *kube) TailStep(ctx context.Context, step *types.Step, taskUUID string)
up := make(chan bool)
podUpdated := func(_, new any) {
pod, ok := new.(*v1.Pod)
podUpdated := func(_, newPod any) {
pod, ok := newPod.(*v1.Pod)
if !ok {
log.Error().Msgf("could not parse pod: %v", new)
log.Error().Msgf("could not parse pod: %v", newPod)
return
}

View File

@ -78,7 +78,7 @@ func (l *Linter) lintFile(config *WorkflowConfig) error {
var linterErr error
if len(config.Workflow.Steps.ContainerList) == 0 {
linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing steps section", config.File, "steps", false))
linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing `steps` section", config.File, "steps", false))
}
if err := l.lintCloneSteps(config); err != nil {
@ -123,7 +123,7 @@ func (l *Linter) lintCloneSteps(config *WorkflowConfig) error {
if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) {
linterErr = multierr.Append(linterErr,
newLinterError(
"Specified clone image does not match allow list, netrc will not be injected",
"Specified clone image does not match allow list, netrc is not injected",
config.File, fmt.Sprintf("clone.%s", container.Name), true),
)
}
@ -173,7 +173,7 @@ func (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area stri
func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error {
// lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918
if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") {
msg := fmt.Sprintf("The formerly privileged plugin '%s' is no longer privileged by default, if required, add it to WOODPECKER_PLUGINS_PRIVILEGED", c.Image)
msg := fmt.Sprintf("The formerly privileged plugin `%s` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`", c.Image)
// check first if user did not add them back
if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) {
return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false)
@ -191,16 +191,16 @@ func (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field
return nil
}
if len(c.Commands) != 0 {
return newLinterError("Cannot configure both commands and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
return newLinterError("Cannot configure both `commands` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
}
if len(c.Entrypoint) != 0 {
return newLinterError("Cannot configure both entrypoint and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
return newLinterError("Cannot configure both `entrypoint` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false)
}
if len(c.Environment) != 0 {
return newLinterError("Should not configure both environment and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
return newLinterError("Should not configure both `environment` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
}
if len(c.Secrets) != 0 {
return newLinterError("Should not configure both secrets and settings", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
return newLinterError("Should not configure both `secrets` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true)
}
return nil
}
@ -210,32 +210,32 @@ func (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area st
errors := []string{}
if !l.trusted.Security {
if c.Privileged {
errors = append(errors, "Insufficient privileges to use privileged mode")
errors = append(errors, "Insufficient trust level to use `privileged` mode")
}
}
if !l.trusted.Network {
if len(c.DNS) != 0 {
errors = append(errors, "Insufficient privileges to use custom dns")
errors = append(errors, "Insufficient trust level to use custom `dns`")
}
if len(c.DNSSearch) != 0 {
errors = append(errors, "Insufficient privileges to use dns_search")
errors = append(errors, "Insufficient trust level to use `dns_search`")
}
if len(c.ExtraHosts) != 0 {
errors = append(errors, "Insufficient privileges to use extra_hosts")
errors = append(errors, "Insufficient trust level to use `extra_hosts`")
}
if len(c.NetworkMode) != 0 {
errors = append(errors, "Insufficient privileges to use network_mode")
errors = append(errors, "Insufficient trust level to use `network_mode`")
}
}
if !l.trusted.Volumes {
if len(c.Devices) != 0 {
errors = append(errors, "Insufficient privileges to use devices")
errors = append(errors, "Insufficient trust level to use `devices`")
}
if len(c.Volumes.Volumes) != 0 {
errors = append(errors, "Insufficient privileges to use volumes")
errors = append(errors, "Insufficient trust level to use `volumes`")
}
if len(c.Tmpfs) != 0 {
errors = append(errors, "Insufficient privileges to use tmpfs")
errors = append(errors, "Insufficient trust level to use `tmpfs`")
}
}
@ -294,7 +294,7 @@ func (l *Linter) lintDeprecations(config *WorkflowConfig) (err error) {
if len(container.Secrets) > 0 {
err = multierr.Append(err, &errorTypes.PipelineError{
Type: errorTypes.PipelineErrorTypeDeprecation,
Message: "Secrets are deprecated, use environment with from_secret",
Message: "Usage of `secrets` is deprecated, use `environment` with `from_secret`",
Data: errors.DeprecationErrorData{
File: config.File,
Field: fmt.Sprintf("steps.%s.secrets", container.Name),
@ -343,7 +343,7 @@ func (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) {
if field != "" {
err = multierr.Append(err, &errorTypes.PipelineError{
Type: errorTypes.PipelineErrorTypeBadHabit,
Message: "Please set an event filter for all steps or the whole workflow on all items of the when block",
Message: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
Data: errors.BadHabitErrorData{
File: config.File,
Field: field,

View File

@ -114,7 +114,7 @@ func TestLintErrors(t *testing.T) {
}{
{
from: "",
want: "Invalid or missing steps section",
want: "Invalid or missing `steps` section",
},
{
from: "steps: { build: { image: '' } }",
@ -122,48 +122,48 @@ func TestLintErrors(t *testing.T) {
},
{
from: "steps: { build: { image: golang, privileged: true } }",
want: "Insufficient privileges to use privileged mode",
want: "Insufficient trust level to use `privileged` mode",
},
{
from: "steps: { build: { image: golang, dns: [ 8.8.8.8 ] } }",
want: "Insufficient privileges to use custom dns",
want: "Insufficient trust level to use custom `dns`",
},
{
from: "steps: { build: { image: golang, dns_search: [ example.com ] } }",
want: "Insufficient privileges to use dns_search",
want: "Insufficient trust level to use `dns_search`",
},
{
from: "steps: { build: { image: golang, devices: [ '/dev/tty0:/dev/tty0' ] } }",
want: "Insufficient privileges to use devices",
want: "Insufficient trust level to use `devices`",
},
{
from: "steps: { build: { image: golang, extra_hosts: [ 'somehost:162.242.195.82' ] } }",
want: "Insufficient privileges to use extra_hosts",
want: "Insufficient trust level to use `extra_hosts`",
},
{
from: "steps: { build: { image: golang, network_mode: host } }",
want: "Insufficient privileges to use network_mode",
want: "Insufficient trust level to use `network_mode`",
},
{
from: "steps: { build: { image: golang, volumes: [ '/opt/data:/var/lib/mysql' ] } }",
want: "Insufficient privileges to use volumes",
want: "Insufficient trust level to use `volumes`",
},
{
from: "steps: { build: { image: golang, network_mode: 'container:name' } }",
want: "Insufficient privileges to use network_mode",
want: "Insufficient trust level to use `network_mode`",
},
{
from: "steps: { build: { image: golang, settings: { test: 'true' }, commands: [ 'echo ja', 'echo nein' ] } }",
want: "Cannot configure both commands and settings",
want: "Cannot configure both `commands` and `settings`",
},
{
from: "steps: { build: { image: golang, settings: { test: 'true' }, entrypoint: [ '/bin/fish' ] } }",
want: "Cannot configure both entrypoint and settings",
want: "Cannot configure both `entrypoint` and `settings`",
},
{
from: "steps: { build: { image: golang, settings: { test: 'true' }, environment: { 'TEST': 'true' } } }",
want: "Should not configure both environment and settings",
want: "Should not configure both `environment` and `settings`",
},
{
from: "{pipeline: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push } }",
@ -171,11 +171,11 @@ func TestLintErrors(t *testing.T) {
},
{
from: "{steps: { build: { image: plugins/docker, settings: { test: 'true' } } }, when: { branch: main, event: push } } }",
want: "The formerly privileged plugin 'plugins/docker' is no longer privileged by default, if required, add it to WOODPECKER_PLUGINS_PRIVILEGED",
want: "The formerly privileged plugin `plugins/docker` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`",
},
{
from: "{steps: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push }, clone: { git: { image: some-other/plugin-git:v1.1.0 } } }",
want: "Specified clone image does not match allow list, netrc will not be injected",
want: "Specified clone image does not match allow list, netrc is not injected",
},
}
@ -209,11 +209,11 @@ func TestBadHabits(t *testing.T) {
}{
{
from: "steps: { build: { image: golang } }",
want: "Please set an event filter for all steps or the whole workflow on all items of the when block",
want: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
},
{
from: "when: [{branch: xyz}, {event: push}]\nsteps: { build: { image: golang } }",
want: "Please set an event filter for all steps or the whole workflow on all items of the when block",
want: "Set an event filter for all steps or the entire workflow on all items of the `when` block",
},
}

View File

@ -38,15 +38,6 @@ steps:
commands:
- go test
secrets:
image: docker
commands:
- echo $DOCKER_USERNAME
- echo $DOCKER_PASSWORD
secrets:
- docker_username
- docker_prod_password
detached:
image: redis
detach: true

View File

@ -286,9 +286,6 @@
"directory": {
"$ref": "#/definitions/step_directory"
},
"secrets": {
"$ref": "#/definitions/step_secrets"
},
"when": {
"$ref": "#/definitions/step_when"
},
@ -616,14 +613,6 @@
"type": ["boolean", "string", "number", "array", "object"]
}
},
"step_secrets": {
"description": "Pass secrets to a pipeline step at runtime. Read more: https://woodpecker-ci.org/docs/usage/secrets",
"type": "array",
"items": {
"type": "string"
},
"minLength": 1
},
"step_settings": {
"description": "Change the settings of your plugin. Read more: https://woodpecker-ci.org/docs/usage/plugins/overview",
"type": "object",
@ -845,9 +834,6 @@
"directory": {
"$ref": "#/definitions/step_directory"
},
"secrets": {
"$ref": "#/definitions/step_secrets"
},
"settings": {
"$ref": "#/definitions/step_settings"
},

View File

@ -0,0 +1,59 @@
// Copyright 2024 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.
// TODO: delete file after v3.0.0 release
package base
import (
"fmt"
)
type EnvironmentMap map[string]any
// UnmarshalYAML implements the Unmarshaler interface.
func (s *EnvironmentMap) UnmarshalYAML(unmarshal func(any) error) error {
var mapType map[string]any
err := unmarshal(&mapType)
if err == nil {
*s = mapType
return nil
}
var sliceType []any
if err := unmarshal(&sliceType); err == nil {
return fmt.Errorf("list syntax for 'environment' has been removed, use map syntax instead (https://woodpecker-ci.org/docs/usage/environment)")
}
return err
}
type SecretsSlice []string
// UnmarshalYAML implements the Unmarshaler interface.
func (s *SecretsSlice) UnmarshalYAML(unmarshal func(any) error) error {
var stringSlice []string
err := unmarshal(&stringSlice)
if err == nil {
*s = stringSlice
return nil
}
var objectSlice []any
if err := unmarshal(&objectSlice); err == nil {
return fmt.Errorf("'secrets' property has been removed, use 'from_secret' instead (https://woodpecker-ci.org/docs/usage/secrets)")
}
return err
}

View File

@ -0,0 +1,79 @@
// Copyright 2024 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.
// TODO: delete file after v3.0.0 release
package base
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
type StructMap struct {
Foos EnvironmentMap `yaml:"foos,omitempty"`
}
type StructSecret struct {
Foos SecretsSlice `yaml:"foos,omitempty"`
}
func TestEnvironmentMapYaml(t *testing.T) {
str := `{foos: [bar=baz, far=faz]}`
s := StructMap{}
err := yaml.Unmarshal([]byte(str), &s)
if assert.Error(t, err) {
assert.EqualValues(t, "list syntax for 'environment' has been removed, use map syntax instead (https://woodpecker-ci.org/docs/usage/environment)", err.Error())
}
s.Foos = EnvironmentMap{"bar": "baz", "far": "faz"}
d, err := yaml.Marshal(&s)
assert.NoError(t, err)
str = `foos:
bar: baz
far: faz
`
assert.EqualValues(t, str, string(d))
s2 := StructMap{}
assert.NoError(t, yaml.Unmarshal(d, &s2))
assert.Equal(t, EnvironmentMap{"bar": "baz", "far": "faz"}, s2.Foos)
}
func TestSecretsSlice(t *testing.T) {
str := `{foos: [ { source: mysql_username, target: mysql_username } ]}`
s := StructSecret{}
err := yaml.Unmarshal([]byte(str), &s)
if assert.Error(t, err) {
assert.EqualValues(t, "'secrets' property has been removed, use 'from_secret' instead (https://woodpecker-ci.org/docs/usage/secrets)", err.Error())
}
s.Foos = SecretsSlice{"bar", "baz", "faz"}
d, err := yaml.Marshal(&s)
assert.NoError(t, err)
str = `foos:
- bar
- baz
- faz
`
assert.EqualValues(t, str, string(d))
s2 := StructSecret{}
assert.NoError(t, yaml.Unmarshal(d, &s2))
assert.Equal(t, SecretsSlice{"bar", "baz", "faz"}, s2.Foos)
}

View File

@ -46,13 +46,14 @@ type (
Ports []string `yaml:"ports,omitempty"`
DependsOn base.StringOrSlice `yaml:"depends_on,omitempty"`
Needs base.StringOrSlice `yaml:"needs,omitempty"`
Environment map[string]any `yaml:"environment,omitempty"`
// TODO deprecated remove in next major
Detached bool `yaml:"detach,omitempty"`
// TODO: remove base.EnvironmentMap and use map[string]any after v3.0.0 release
Environment base.EnvironmentMap `yaml:"environment,omitempty"`
// Deprecated
Secrets []string `yaml:"secrets,omitempty"`
Secrets base.SecretsSlice `yaml:"secrets,omitempty"`
// Docker and Kubernetes Specific
Privileged bool `yaml:"privileged,omitempty"`

275
server/api/agent_test.go Normal file
View File

@ -0,0 +1,275 @@
// Copyright 2024 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 api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
"go.woodpecker-ci.org/woodpecker/v2/server/queue"
queue_mocks "go.woodpecker-ci.org/woodpecker/v2/server/queue/mocks"
mocks_manager "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks"
store_mocks "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/store/types"
)
var fakeAgent = &model.Agent{
ID: 1,
Name: "test-agent",
OwnerID: 1,
NoSchedule: false,
}
func TestGetAgents(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should get agents", func(t *testing.T) {
agents := []*model.Agent{fakeAgent}
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentList", mock.Anything).Return(agents, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
GetAgents(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentList", mock.Anything)
assert.Equal(t, http.StatusOK, w.Code)
var response []*model.Agent
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, agents, response)
})
}
func TestGetAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should get agent", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "agent_id", Value: "1"}}
GetAgent(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentFind", int64(1))
assert.Equal(t, http.StatusOK, w.Code)
var response model.Agent
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, fakeAgent, &response)
})
t.Run("should return bad request for invalid agent id", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "agent_id", Value: "invalid"}}
GetAgent(c)
c.Writer.WriteHeaderNow()
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("should return not found for non-existent agent", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentFind", int64(2)).Return((*model.Agent)(nil), types.RecordNotExist)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "agent_id", Value: "2"}}
GetAgent(c)
c.Writer.WriteHeaderNow()
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
func TestPatchAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should update agent", func(t *testing.T) {
updatedAgent := *fakeAgent
updatedAgent.Name = "updated-agent"
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil)
mockStore.On("AgentUpdate", mock.AnythingOfType("*model.Agent")).Return(nil)
mockManager := mocks_manager.NewManager(t)
server.Config.Services.Manager = mockManager
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "agent_id", Value: "1"}}
c.Request, _ = http.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"name":"updated-agent"}`))
c.Request.Header.Set("Content-Type", "application/json")
PatchAgent(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentFind", int64(1))
mockStore.AssertCalled(t, "AgentUpdate", mock.AnythingOfType("*model.Agent"))
assert.Equal(t, http.StatusOK, w.Code)
var response model.Agent
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "updated-agent", response.Name)
})
}
func TestPostAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should create agent", func(t *testing.T) {
newAgent := &model.Agent{
Name: "new-agent",
NoSchedule: false,
}
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Set("user", &model.User{ID: 1})
c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`))
c.Request.Header.Set("Content-Type", "application/json")
PostAgent(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent"))
assert.Equal(t, http.StatusOK, w.Code)
var response model.Agent
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, newAgent.Name, response.Name)
assert.NotEmpty(t, response.Token)
})
}
func TestDeleteAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should delete agent", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil)
mockStore.On("AgentDelete", mock.AnythingOfType("*model.Agent")).Return(nil)
mockManager := mocks_manager.NewManager(t)
server.Config.Services.Manager = mockManager
mockQueue := queue_mocks.NewQueue(t)
mockQueue.On("Info", mock.Anything).Return(queue.InfoT{})
mockQueue.On("KickAgentWorkers", int64(1)).Return()
server.Config.Services.Queue = mockQueue
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "agent_id", Value: "1"}}
DeleteAgent(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentFind", int64(1))
mockStore.AssertCalled(t, "AgentDelete", mock.AnythingOfType("*model.Agent"))
mockQueue.AssertCalled(t, "KickAgentWorkers", int64(1))
assert.Equal(t, http.StatusNoContent, w.Code)
})
t.Run("should not delete agent with running tasks", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil)
mockManager := mocks_manager.NewManager(t)
server.Config.Services.Manager = mockManager
mockQueue := queue_mocks.NewQueue(t)
mockQueue.On("Info", mock.Anything).Return(queue.InfoT{
Running: []*model.Task{{AgentID: 1}},
})
server.Config.Services.Queue = mockQueue
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "agent_id", Value: "1"}}
DeleteAgent(c)
c.Writer.WriteHeaderNow()
mockStore.AssertCalled(t, "AgentFind", int64(1))
mockStore.AssertNotCalled(t, "AgentDelete", mock.Anything)
assert.Equal(t, http.StatusConflict, w.Code)
})
}
func TestPostOrgAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("create org agent should succeed", func(t *testing.T) {
mockStore := store_mocks.NewStore(t)
mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", mockStore)
// Set up a non-admin user
c.Set("user", &model.User{
ID: 1,
Admin: false,
})
c.Params = gin.Params{{Key: "org_id", Value: "1"}}
c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`))
c.Request.Header.Set("Content-Type", "application/json")
PostOrgAgent(c)
c.Writer.WriteHeaderNow()
assert.Equal(t, http.StatusOK, w.Code)
// Ensure an agent was created
mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent"))
})
}

View File

@ -54,6 +54,9 @@ var Config = struct {
CustomCSSFile string
CustomJsFile string
}
Agent struct {
DisableUserRegisteredAgentRegistration bool
}
WebUI struct {
EnableSwagger bool
SkipVersionCheck bool
@ -64,6 +67,7 @@ var Config = struct {
Pipeline struct {
AuthenticatePublicRepos bool
DefaultCancelPreviousPipelineEvents []model.WebhookEvent
DefaultWorkflowLabels map[string]string
DefaultClonePlugin string
TrustedClonePlugins []string
Volumes []string

View File

@ -72,16 +72,17 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model.
}
b := stepbuilder.StepBuilder{
Repo: repo,
Curr: currentPipeline,
Prev: prev,
Netrc: netrc,
Secs: secs,
Regs: regs,
Envs: envs,
Host: server.Config.Server.Host,
Yamls: yamls,
Forge: forge,
Repo: repo,
Curr: currentPipeline,
Prev: prev,
Netrc: netrc,
Secs: secs,
Regs: regs,
Envs: envs,
Host: server.Config.Server.Host,
Yamls: yamls,
Forge: forge,
DefaultLabels: server.Config.Pipeline.DefaultWorkflowLabels,
ProxyOpts: compiler.ProxyOptions{
NoProxy: server.Config.Pipeline.Proxy.No,
HTTPProxy: server.Config.Pipeline.Proxy.HTTP,

View File

@ -17,6 +17,7 @@ package stepbuilder
import (
"fmt"
"maps"
"path/filepath"
"strings"
@ -40,17 +41,18 @@ import (
// StepBuilder Takes the hook data and the yaml and returns in internal data model.
type StepBuilder struct {
Repo *model.Repo
Curr *model.Pipeline
Prev *model.Pipeline
Netrc *model.Netrc
Secs []*model.Secret
Regs []*model.Registry
Host string
Yamls []*forge_types.FileMeta
Envs map[string]string
Forge metadata.ServerForge
ProxyOpts compiler.ProxyOptions
Repo *model.Repo
Curr *model.Pipeline
Prev *model.Pipeline
Netrc *model.Netrc
Secs []*model.Secret
Regs []*model.Registry
Host string
Yamls []*forge_types.FileMeta
Envs map[string]string
Forge metadata.ServerForge
DefaultLabels map[string]string
ProxyOpts compiler.ProxyOptions
}
type Item struct {
@ -186,8 +188,10 @@ func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.A
DependsOn: parsed.DependsOn,
RunsOn: parsed.RunsOn,
}
if item.Labels == nil {
item.Labels = map[string]string{}
if len(item.Labels) == 0 {
item.Labels = make(map[string]string, len(b.DefaultLabels))
// Set default labels if no labels are defined in the pipeline
maps.Copy(item.Labels, b.DefaultLabels)
}
return item, errorsAndWarnings

259
server/queue/mocks/queue.go Normal file
View File

@ -0,0 +1,259 @@
// Code generated by mockery. DO NOT EDIT.
//go:build test
// +build test
package mocks
import (
context "context"
mock "github.com/stretchr/testify/mock"
model "go.woodpecker-ci.org/woodpecker/v2/server/model"
queue "go.woodpecker-ci.org/woodpecker/v2/server/queue"
)
// Queue is an autogenerated mock type for the Queue type
type Queue struct {
mock.Mock
}
// Done provides a mock function with given fields: c, id, exitStatus
func (_m *Queue) Done(c context.Context, id string, exitStatus model.StatusValue) error {
ret := _m.Called(c, id, exitStatus)
if len(ret) == 0 {
panic("no return value specified for Done")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, model.StatusValue) error); ok {
r0 = rf(c, id, exitStatus)
} else {
r0 = ret.Error(0)
}
return r0
}
// Error provides a mock function with given fields: c, id, err
func (_m *Queue) Error(c context.Context, id string, err error) error {
ret := _m.Called(c, id, err)
if len(ret) == 0 {
panic("no return value specified for Error")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, error) error); ok {
r0 = rf(c, id, err)
} else {
r0 = ret.Error(0)
}
return r0
}
// ErrorAtOnce provides a mock function with given fields: c, ids, err
func (_m *Queue) ErrorAtOnce(c context.Context, ids []string, err error) error {
ret := _m.Called(c, ids, err)
if len(ret) == 0 {
panic("no return value specified for ErrorAtOnce")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []string, error) error); ok {
r0 = rf(c, ids, err)
} else {
r0 = ret.Error(0)
}
return r0
}
// Evict provides a mock function with given fields: c, id
func (_m *Queue) Evict(c context.Context, id string) error {
ret := _m.Called(c, id)
if len(ret) == 0 {
panic("no return value specified for Evict")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(c, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// EvictAtOnce provides a mock function with given fields: c, ids
func (_m *Queue) EvictAtOnce(c context.Context, ids []string) error {
ret := _m.Called(c, ids)
if len(ret) == 0 {
panic("no return value specified for EvictAtOnce")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []string) error); ok {
r0 = rf(c, ids)
} else {
r0 = ret.Error(0)
}
return r0
}
// Extend provides a mock function with given fields: c, agentID, workflowID
func (_m *Queue) Extend(c context.Context, agentID int64, workflowID string) error {
ret := _m.Called(c, agentID, workflowID)
if len(ret) == 0 {
panic("no return value specified for Extend")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok {
r0 = rf(c, agentID, workflowID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Info provides a mock function with given fields: c
func (_m *Queue) Info(c context.Context) queue.InfoT {
ret := _m.Called(c)
if len(ret) == 0 {
panic("no return value specified for Info")
}
var r0 queue.InfoT
if rf, ok := ret.Get(0).(func(context.Context) queue.InfoT); ok {
r0 = rf(c)
} else {
r0 = ret.Get(0).(queue.InfoT)
}
return r0
}
// KickAgentWorkers provides a mock function with given fields: agentID
func (_m *Queue) KickAgentWorkers(agentID int64) {
_m.Called(agentID)
}
// Pause provides a mock function with given fields:
func (_m *Queue) Pause() {
_m.Called()
}
// Poll provides a mock function with given fields: c, agentID, f
func (_m *Queue) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) {
ret := _m.Called(c, agentID, f)
if len(ret) == 0 {
panic("no return value specified for Poll")
}
var r0 *model.Task
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) (*model.Task, error)); ok {
return rf(c, agentID, f)
}
if rf, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) *model.Task); ok {
r0 = rf(c, agentID, f)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Task)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64, queue.FilterFn) error); ok {
r1 = rf(c, agentID, f)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Push provides a mock function with given fields: c, task
func (_m *Queue) Push(c context.Context, task *model.Task) error {
ret := _m.Called(c, task)
if len(ret) == 0 {
panic("no return value specified for Push")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *model.Task) error); ok {
r0 = rf(c, task)
} else {
r0 = ret.Error(0)
}
return r0
}
// PushAtOnce provides a mock function with given fields: c, tasks
func (_m *Queue) PushAtOnce(c context.Context, tasks []*model.Task) error {
ret := _m.Called(c, tasks)
if len(ret) == 0 {
panic("no return value specified for PushAtOnce")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []*model.Task) error); ok {
r0 = rf(c, tasks)
} else {
r0 = ret.Error(0)
}
return r0
}
// Resume provides a mock function with given fields:
func (_m *Queue) Resume() {
_m.Called()
}
// Wait provides a mock function with given fields: c, id
func (_m *Queue) Wait(c context.Context, id string) error {
ret := _m.Called(c, id)
if len(ret) == 0 {
panic("no return value specified for Wait")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(c, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewQueue creates a new instance of Queue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewQueue(t interface {
mock.TestingT
Cleanup(func())
}) *Queue {
mock := &Queue{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -72,6 +72,8 @@ func (t *InfoT) String() string {
// The int return value represents the matching score (higher is better).
type FilterFn func(*model.Task) (bool, int)
//go:generate mockery --name Queue --output mocks --case underscore --note "+build test"
// Queue defines a task queue for scheduling tasks among
// a pool of workers.
type Queue interface {

View File

@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
"go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/api"
"go.woodpecker-ci.org/woodpecker/v2/server/api/debug"
"go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session"
@ -74,10 +75,12 @@ func apiRoutes(e *gin.RouterGroup) {
org.PATCH("/registries/:registry", api.PatchOrgRegistry)
org.DELETE("/registries/:registry", api.DeleteOrgRegistry)
org.GET("/agents", api.GetOrgAgents)
org.POST("/agents", api.PostOrgAgent)
org.PATCH("/agents/:agent_id", api.PatchOrgAgent)
org.DELETE("/agents/:agent_id", api.DeleteOrgAgent)
if !server.Config.Agent.DisableUserRegisteredAgentRegistration {
org.GET("/agents", api.GetOrgAgents)
org.POST("/agents", api.PostOrgAgent)
org.PATCH("/agents/:agent_id", api.PatchOrgAgent)
org.DELETE("/agents/:agent_id", api.DeleteOrgAgent)
}
}
}
}

View File

@ -23,14 +23,14 @@ import (
"xorm.io/xorm/schemas"
)
func renameTable(sess *xorm.Session, old, new string) error {
func renameTable(sess *xorm.Session, oldTable, newTable string) error {
dialect := sess.Engine().Dialect().URI().DBType
switch dialect {
case schemas.MYSQL:
_, err := sess.Exec(fmt.Sprintf("RENAME TABLE `%s` TO `%s`;", old, new))
_, err := sess.Exec(fmt.Sprintf("RENAME TABLE `%s` TO `%s`;", oldTable, newTable))
return err
case schemas.POSTGRES, schemas.SQLITE:
_, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`;", old, new))
_, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`;", oldTable, newTable))
return err
default:
return fmt.Errorf("dialect '%s' not supported", dialect)

View File

@ -57,14 +57,14 @@ func createSQLiteDB(t *testing.T) string {
return tmpF.Name()
}
func testDB(t *testing.T, new bool) (engine *xorm.Engine, closeDB func()) {
func testDB(t *testing.T, initNewDB bool) (engine *xorm.Engine, closeDB func()) {
driver := testDriver()
var err error
closeDB = func() {}
switch driver {
case "sqlite3":
config := ":memory:"
if !new {
if !initNewDB {
config = createSQLiteDB(t)
closeDB = func() {
_ = os.Remove(config)
@ -77,7 +77,7 @@ func testDB(t *testing.T, new bool) (engine *xorm.Engine, closeDB func()) {
return
case "mysql", "postgres":
config := os.Getenv("WOODPECKER_DATABASE_DATASOURCE")
if !new {
if !initNewDB {
t.Logf("do not have dump to test against")
t.SkipNow()
}

View File

@ -40,12 +40,13 @@ func Config(c *gin.Context) {
}
configData := map[string]any{
"user": user,
"csrf": csrf,
"version": version.String(),
"skip_version_check": server.Config.WebUI.SkipVersionCheck,
"root_path": server.Config.Server.RootPath,
"enable_swagger": server.Config.WebUI.EnableSwagger,
"user": user,
"csrf": csrf,
"version": version.String(),
"skip_version_check": server.Config.WebUI.SkipVersionCheck,
"root_path": server.Config.Server.RootPath,
"enable_swagger": server.Config.WebUI.EnableSwagger,
"user_registered_agents": !server.Config.Agent.DisableUserRegisteredAgentRegistration,
}
// default func map with json parser.
@ -79,4 +80,5 @@ window.WOODPECKER_VERSION = "{{ .version }}";
window.WOODPECKER_ROOT_PATH = "{{ .root_path }}";
window.WOODPECKER_ENABLE_SWAGGER = {{ .enable_swagger }};
window.WOODPECKER_SKIP_VERSION_CHECK = {{ .skip_version_check }}
window.WOODPECKER_USER_REGISTERED_AGENTS = {{ .user_registered_agents }}
`

View File

@ -24,9 +24,11 @@
"@vueuse/core": "^11.0.0",
"ansi_up": "^6.0.2",
"dayjs": "^1.11.12",
"dompurify": "^3.2.0",
"fuse.js": "^7.0.0",
"js-base64": "^3.7.7",
"lodash": "^4.17.21",
"marked": "^15.0.0",
"node-emoji": "^2.1.3",
"pinia": "^2.2.1",
"prismjs": "^1.29.0",

18
web/pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
dayjs:
specifier: ^1.11.12
version: 1.11.13
dompurify:
specifier: ^3.2.0
version: 3.2.0
fuse.js:
specifier: ^7.0.0
version: 7.0.0
@ -38,6 +41,9 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
marked:
specifier: ^15.0.0
version: 15.0.0
node-emoji:
specifier: ^2.1.3
version: 2.1.3
@ -1310,6 +1316,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.2.0:
resolution: {integrity: sha512-AMdOzK44oFWqHEi0wpOqix/fUNY707OmoeFDnbi3Q5I8uOpy21ufUA5cDJPr0bosxrflOVD/H2DMSvuGKJGfmQ==}
domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
@ -1921,6 +1930,11 @@ packages:
markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
marked@15.0.0:
resolution: {integrity: sha512-0mouKmBROJv/WSHJBPZZyYofUgawMChnD5je/g+aOBXsHDjb/IsnTQj7mnhQZu+qPJmRQ0ecX3mLGEUm3BgwYA==}
engines: {node: '>= 18'}
hasBin: true
mdast-util-find-and-replace@3.0.1:
resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==}
@ -4055,6 +4069,8 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.2.0: {}
domutils@3.1.0:
dependencies:
dom-serializer: 2.0.0
@ -4766,6 +4782,8 @@ snapshots:
markdown-table@3.0.3: {}
marked@15.0.0: {}
mdast-util-find-and-replace@3.0.1:
dependencies:
'@types/mdast': 4.0.4

View File

@ -93,8 +93,8 @@
"desc": "Every pipeline needs to be approved before being executed."
},
"netrc_only_trusted": {
"netrc_only_trusted": "Only inject netrc credentials into trusted containers",
"desc": "Only inject netrc credentials into trusted containers (recommended)."
"netrc_only_trusted": "Only inject netrc credentials into trusted clone plugins",
"desc": "If enabled, git netrc credentials are only available for trusted clone plugins set in `WOODPECKER_PLUGINS_TRUSTED_CLONE`. Otherwise, all clone plugins can use the netrc credentials. This option has no effect on non-clone steps."
},
"trusted": {
"trusted": "Trusted",

View File

@ -3,9 +3,9 @@
:href="`${docsUrl}`"
:title="$t('documentation_for', { topic })"
target="_blank"
class="text-wp-link-100 hover:text-wp-link-200 cursor-pointer mt-1"
class="text-wp-link-100 hover:text-wp-link-200 cursor-pointer"
>
<Icon name="question" class="!w-4 !h-4" />
<Icon name="question" class="!w-5 !h-5" />
</a>
</template>

View File

@ -0,0 +1,18 @@
<template>
<span v-html="contentHTML" />
</template>
<script setup lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { computed } from 'vue';
const props = defineProps<{
content: string;
}>();
const contentHTML = computed<string>(() => {
const dirtyHTML = marked.parse(props.content);
return DOMPurify.sanitize(dirtyHTML as string, { USE_PROFILES: { html: true } });
});
</script>

View File

@ -1,21 +1,21 @@
<template>
<IconButton :title="$t('pipeline_feed')" class="!p-1.5 relative text-current active-pipelines-toggle" @click="toggle">
<div v-if="activePipelines.length > 0" class="spinner m-1">
<div class="spinner-ring ring1" />
<div class="spinner-ring ring2" />
<div class="spinner-ring ring3" />
<div class="spinner-ring ring4" />
</div>
<IconButton
:title="pipelineCount > 0 ? `${$t('pipeline_feed')} (${pipelineCount})` : $t('pipeline_feed')"
class="!p-1.5 relative text-current active-pipelines-toggle"
@click="toggle"
>
<div v-if="pipelineCount > 0" class="spinner" />
<div
class="flex items-center justify-center h-full w-full font-bold bg-white bg-opacity-15 dark:bg-black dark:bg-opacity-10 rounded-md"
class="z-1 flex items-center justify-center h-full w-full font-bold bg-white bg-opacity-15 dark:bg-black dark:bg-opacity-10 rounded-md"
>
{{ activePipelines.length || 0 }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ pipelineCount > 9 ? '9+' : pipelineCount }}
</div>
</IconButton>
</template>
<script lang="ts" setup>
import { onMounted, toRef } from 'vue';
import { computed, onMounted, toRef } from 'vue';
import IconButton from '~/components/atomic/IconButton.vue';
import usePipelineFeed from '~/compositions/usePipelineFeed';
@ -23,6 +23,7 @@ import usePipelineFeed from '~/compositions/usePipelineFeed';
const pipelineFeed = usePipelineFeed();
const activePipelines = toRef(pipelineFeed, 'activePipelines');
const { toggle } = pipelineFeed;
const pipelineCount = computed(() => activePipelines.value.length || 0);
onMounted(async () => {
await pipelineFeed.load();
@ -30,29 +31,31 @@ onMounted(async () => {
</script>
<style scoped>
.spinner {
@apply absolute top-0 bottom-0 left-0 right-0;
}
.spinner .spinner-ring {
animation: spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
@apply border-3 rounded-full absolute top-1.5 bottom-1.5 left-1.5 right-1.5;
}
.spinner .ring1 {
animation-delay: -0.45s;
}
.spinner .ring2 {
animation-delay: -0.3s;
}
.spinner .ring3 {
animation-delay: -0.15s;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
@keyframes spinner-rotate {
100% {
transform: rotate(360deg);
transform: rotate(1turn);
}
}
.spinner {
@apply absolute z-0 inset-1.5 rounded-md;
overflow: hidden;
}
.spinner::before {
@apply absolute -z-2 bg-wp-primary-200 dark:bg-wp-primary-300;
content: '';
left: -50%;
top: -50%;
width: 200%;
height: 200%;
background-repeat: no-repeat;
background-size:
50% 50%,
50% 50%;
background-image: linear-gradient(#fff, transparent);
animation: spinner-rotate 1.5s linear infinite;
}
.spinner::after {
@apply absolute inset-0.5 rounded-md bg-blend-darken bg-wp-primary-200 dark:bg-wp-primary-300;
content: '';
}
</style>

View File

@ -14,9 +14,9 @@
</template>
<template #description>
<i18n-t keypath="repo.settings.general.pipeline_path.desc" tag="p" class="text-sm text-wp-text-alt-100">
<span class="code-box-inline px-1">{{ $t('repo.settings.general.pipeline_path.desc_path_example') }}</span>
<span class="code-box-inline">{{ $t('repo.settings.general.pipeline_path.desc_path_example') }}</span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="code-box-inline px-1">/</span>
<span class="code-box-inline">/</span>
</i18n-t>
</template>
</InputField>

View File

@ -8,6 +8,7 @@ declare global {
WOODPECKER_CSRF: string | undefined;
WOODPECKER_ROOT_PATH: string | undefined;
WOODPECKER_ENABLE_SWAGGER: boolean | undefined;
WOODPECKER_USER_REGISTERED_AGENTS: boolean | undefined;
}
}
@ -18,4 +19,5 @@ export default () => ({
csrf: window.WOODPECKER_CSRF ?? null,
rootPath: window.WOODPECKER_ROOT_PATH ?? '',
enableSwagger: window.WOODPECKER_ENABLE_SWAGGER === true || false,
userRegisteredAgents: window.WOODPECKER_USER_REGISTERED_AGENTS || false,
});

View File

@ -147,6 +147,7 @@ body,
white-space: pre-wrap;
}
.code-box-inline {
@apply bg-wp-code-200 rounded-md text-wp-code-text-100;
.code-box-inline,
code:not(pre > code) {
@apply bg-wp-code-200 rounded-md text-wp-code-text-100 px-1 py-px;
}

View File

@ -14,7 +14,7 @@
<Tab id="cli-and-api" :title="$t('user.settings.cli_and_api.cli_and_api')">
<UserCLIAndAPITab />
</Tab>
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
<Tab v-if="useConfig().userRegisteredAgents" id="agents" :title="$t('admin.settings.agents.agents')">
<UserAgentsTab />
</Tab>
</Scaffold>

View File

@ -19,7 +19,7 @@
<OrgRegistriesTab />
</Tab>
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
<Tab v-if="useConfig().userRegisteredAgents" id="agents" :title="$t('admin.settings.agents.agents')">
<OrgAgentsTab />
</Tab>
</Scaffold>
@ -35,6 +35,7 @@ import Tab from '~/components/layout/scaffold/Tab.vue';
import OrgAgentsTab from '~/components/org/settings/OrgAgentsTab.vue';
import OrgRegistriesTab from '~/components/org/settings/OrgRegistriesTab.vue';
import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue';
import useConfig from '~/compositions/useConfig';
import { inject } from '~/compositions/useInjectProvide';
import useNotifications from '~/compositions/useNotifications';
import { useRouteBack } from '~/compositions/useRouteBack';

View File

@ -1,37 +1,47 @@
<template>
<Panel>
<div class="grid justify-center gap-x-4 text-left grid-3-1">
<template v-for="(error, i) in pipeline!.errors" :key="i">
<Icon
name="attention"
class="flex-shrink-0 my-1"
:class="{
'text-wp-state-warn-100': error.is_warning,
'text-wp-state-error-100': !error.is_warning,
}"
/>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span>[{{ error.type }}]</span>
<span
v-if="isLinterError(error) || isDeprecationError(error) || isBadHabitError(error)"
class="whitespace-nowrap"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span v-if="error.data?.file" class="font-bold">{{ error.data?.file }}: </span>
<span>{{ error.data?.field }}</span>
</span>
<span v-else />
<a
v-if="isDeprecationError(error) || isBadHabitError(error)"
:href="error.data?.docs"
target="_blank"
class="underline col-span-full col-start-2 md:col-span-auto md:col-start-auto"
>
{{ error.message }}
</a>
<span v-else class="col-span-full col-start-2 md:col-span-auto md:col-start-auto">
{{ error.message }}
</span>
<div class="flex flex-col gap-y-4">
<template v-for="(error, _index) in pipeline!.errors" :key="_index">
<div>
<div class="grid grid-cols-[minmax(10rem,auto),3fr]">
<span class="flex items-center gap-x-2">
<Icon
name="attention"
class="flex-shrink-0 my-1"
:class="{
'text-wp-state-warn-100': error.is_warning,
'text-wp-state-error-100': !error.is_warning,
}"
/>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span>
<code>{{ error.type }}</code>
</span>
</span>
<span
v-if="isLinterError(error) || isDeprecationError(error) || isBadHabitError(error)"
class="flex items-center gap-x-2 whitespace-nowrap"
>
<span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span v-if="error.data?.file" class="font-bold">{{ error.data?.file }}: </span>
<span>{{ error.data?.field }}</span>
</span>
<DocsLink
v-if="isDeprecationError(error) || isBadHabitError(error)"
:topic="error.data?.field || ''"
:url="error.data?.docs || ''"
/>
</span>
<span v-else />
</div>
<div class="grid grid-cols-[minmax(10rem,auto),4fr] col-start-2">
<span />
<span>
<RenderMarkdown :content="error.message" />
</span>
</div>
</div>
</template>
</div>
</Panel>
@ -40,7 +50,9 @@
<script lang="ts" setup>
import { inject, type Ref } from 'vue';
import DocsLink from '~/components/atomic/DocsLink.vue';
import Icon from '~/components/atomic/Icon.vue';
import RenderMarkdown from '~/components/atomic/RenderMarkdown.vue';
import Panel from '~/components/layout/Panel.vue';
import type { Pipeline, PipelineError } from '~/lib/api/types';
@ -63,9 +75,3 @@ function isBadHabitError(error: PipelineError): error is PipelineError<{ file?:
return error.type === 'bad_habit';
}
</script>
<style scoped>
.grid-3-1 {
grid-template-columns: auto auto auto 1fr;
}
</style>