mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-01-03 14:18:42 +00:00
Implement registries for Kubernetes backend (#4092)
According to [the documentation](https://woodpecker-ci.org/docs/administration/backends/kubernetes#images-from-private-registries), per-organization and per-pipeline registries are currently unsupported for the Kubernetes backend. This patch implements this missing functionality by creating and deleting a matching secret for each pod with a matched registry, using the same name, labels, and annotations as the pod, and appending it to its `imagePullSecrets` list. This patch adds tests for the new functionality, and has been manually end-to-end-tested in KinD by using a private image hosted in the matching gitea instance. This will require updating the matching helm charts to add the create/delete permissions to the agent role, which **is already done**. close #2987
This commit is contained in:
parent
ecb59ce1c4
commit
b52b021acb
8 changed files with 215 additions and 13 deletions
|
@ -35,10 +35,6 @@ Example registry hostname matching logic:
|
||||||
- Hostname `docker.io` matches `bradrydzewski/golang`
|
- Hostname `docker.io` matches `bradrydzewski/golang`
|
||||||
- Hostname `docker.io` matches `bradrydzewski/golang:latest`
|
- Hostname `docker.io` matches `bradrydzewski/golang:latest`
|
||||||
|
|
||||||
:::note
|
|
||||||
The flow above doesn't work in Kubernetes. There is [workaround](../30-administration/22-backends/40-kubernetes.md#images-from-private-registries).
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Global registry support
|
## Global registry support
|
||||||
|
|
||||||
To make a private registry globally available, check the [server configuration docs](../30-administration/10-server-config.md#global-registry-setting).
|
To make a private registry globally available, check the [server configuration docs](../30-administration/10-server-config.md#global-registry-setting).
|
||||||
|
|
|
@ -8,9 +8,9 @@ The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is
|
||||||
|
|
||||||
## Images from private registries
|
## Images from private registries
|
||||||
|
|
||||||
In order to pull private container images defined in your pipeline YAML you must provide [registry credentials in Kubernetes Secret](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/).
|
In addition to [registries specified in the UI](../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML.
|
||||||
As the Secret is Agent-wide, it has to be placed in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE`.
|
|
||||||
Besides, you need to provide the Secret name to Agent via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.
|
Place these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`.
|
||||||
|
|
||||||
## Job specific configuration
|
## Job specific configuration
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
std_errs "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
|
@ -225,6 +226,13 @@ func (e *kube) StartStep(ctx context.Context, step *types.Step, taskUUID string)
|
||||||
log.Error().Err(err).Msg("could not parse backend options")
|
log.Error().Err(err).Msg("could not parse backend options")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if needsRegistrySecret(step) {
|
||||||
|
err = startRegistrySecret(ctx, e, step)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Trace().Str("taskUUID", taskUUID).Msgf("starting step: %s", step.Name)
|
log.Trace().Str("taskUUID", taskUUID).Msgf("starting step: %s", step.Name)
|
||||||
_, err = startPod(ctx, e, step, options)
|
_, err = startPod(ctx, e, step, options)
|
||||||
return err
|
return err
|
||||||
|
@ -382,9 +390,20 @@ func (e *kube) TailStep(ctx context.Context, step *types.Step, taskUUID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *kube) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error {
|
func (e *kube) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error {
|
||||||
|
var errs []error
|
||||||
log.Trace().Str("taskUUID", taskUUID).Msgf("Stopping step: %s", step.Name)
|
log.Trace().Str("taskUUID", taskUUID).Msgf("Stopping step: %s", step.Name)
|
||||||
|
if needsRegistrySecret(step) {
|
||||||
|
err := stopRegistrySecret(ctx, e, step, defaultDeleteOptions)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := stopPod(ctx, e, step, defaultDeleteOptions)
|
err := stopPod(ctx, e, step, defaultDeleteOptions)
|
||||||
return err
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
return std_errs.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DestroyWorkflow destroys the pipeline environment.
|
// DestroyWorkflow destroys the pipeline environment.
|
||||||
|
|
|
@ -163,6 +163,14 @@ func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativ
|
||||||
|
|
||||||
log.Trace().Msgf("using the image pull secrets: %v", config.ImagePullSecretNames)
|
log.Trace().Msgf("using the image pull secrets: %v", config.ImagePullSecretNames)
|
||||||
spec.ImagePullSecrets = secretsReferences(config.ImagePullSecretNames)
|
spec.ImagePullSecrets = secretsReferences(config.ImagePullSecretNames)
|
||||||
|
if needsRegistrySecret(step) {
|
||||||
|
log.Trace().Msgf("using an image pull secret from registries")
|
||||||
|
name, err := registrySecretName(step)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
spec.ImagePullSecrets = append(spec.ImagePullSecrets, secretReference(name))
|
||||||
|
}
|
||||||
|
|
||||||
spec.Volumes = append(spec.Volumes, nsp.volumes...)
|
spec.Volumes = append(spec.Volumes, nsp.volumes...)
|
||||||
|
|
||||||
|
@ -514,6 +522,7 @@ func stopPod(ctx context.Context, engine *kube, step *types.Step, deleteOpts met
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace().Str("name", podName).Msg("deleting pod")
|
log.Trace().Str("name", podName).Msg("deleting pod")
|
||||||
|
|
||||||
err = engine.client.CoreV1().Pods(engine.config.Namespace).Delete(ctx, podName, deleteOpts)
|
err = engine.client.CoreV1().Pods(engine.config.Namespace).Delete(ctx, podName, deleteOpts)
|
||||||
|
|
|
@ -264,6 +264,9 @@ func TestFullPod(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "another-pull-secret"
|
"name": "another-pull-secret"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wp-01he8bebctabr3kgk0qj36d2me-0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tolerations": [
|
"tolerations": [
|
||||||
|
@ -317,6 +320,7 @@ func TestFullPod(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
pod, err := mkPod(&types.Step{
|
pod, err := mkPod(&types.Step{
|
||||||
|
UUID: "01he8bebctabr3kgk0qj36d2me-0",
|
||||||
Name: "go-test",
|
Name: "go-test",
|
||||||
Image: "meltwater/drone-cache",
|
Image: "meltwater/drone-cache",
|
||||||
WorkingDir: "/woodpecker/src",
|
WorkingDir: "/woodpecker/src",
|
||||||
|
@ -328,6 +332,10 @@ func TestFullPod(t *testing.T) {
|
||||||
Environment: map[string]string{"CGO": "0"},
|
Environment: map[string]string{"CGO": "0"},
|
||||||
ExtraHosts: hostAliases,
|
ExtraHosts: hostAliases,
|
||||||
Ports: ports,
|
Ports: ports,
|
||||||
|
AuthConfig: types.Auth{
|
||||||
|
Username: "foo",
|
||||||
|
Password: "bar",
|
||||||
|
},
|
||||||
}, &config{
|
}, &config{
|
||||||
Namespace: "woodpecker",
|
Namespace: "woodpecker",
|
||||||
ImagePullSecretNames: []string{"regcred", "another-pull-secret"},
|
ImagePullSecretNames: []string{"regcred", "another-pull-secret"},
|
||||||
|
|
|
@ -15,11 +15,21 @@
|
||||||
package kubernetes
|
package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
config_file "github.com/docker/cli/cli/config/configfile"
|
||||||
|
config_file_types "github.com/docker/cli/cli/config/types"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types"
|
||||||
|
"go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type nativeSecretsProcessor struct {
|
type nativeSecretsProcessor struct {
|
||||||
|
@ -189,3 +199,96 @@ func secretReference(name string) v1.LocalObjectReference {
|
||||||
Name: name,
|
Name: name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func needsRegistrySecret(step *types.Step) bool {
|
||||||
|
return step.AuthConfig.Username != "" && step.AuthConfig.Password != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkRegistrySecret(step *types.Step, config *config) (*v1.Secret, error) {
|
||||||
|
name, err := registrySecretName(step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, err := registrySecretLabels(step)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
named, err := utils.ParseNamed(step.Image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authConfig := config_file.ConfigFile{
|
||||||
|
AuthConfigs: map[string]config_file_types.AuthConfig{
|
||||||
|
reference.Domain(named): {
|
||||||
|
Username: step.AuthConfig.Username,
|
||||||
|
Password: step.AuthConfig.Password,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
configFileJSON, err := json.Marshal(authConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &v1.Secret{
|
||||||
|
ObjectMeta: meta_v1.ObjectMeta{
|
||||||
|
Namespace: config.Namespace,
|
||||||
|
Name: name,
|
||||||
|
Labels: labels,
|
||||||
|
},
|
||||||
|
Type: v1.SecretTypeDockerConfigJson,
|
||||||
|
Data: map[string][]byte{
|
||||||
|
v1.DockerConfigJsonKey: configFileJSON,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrySecretName(step *types.Step) (string, error) {
|
||||||
|
return podName(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrySecretLabels(step *types.Step) (map[string]string, error) {
|
||||||
|
var err error
|
||||||
|
labels := make(map[string]string)
|
||||||
|
|
||||||
|
if step.Type == types.StepTypeService {
|
||||||
|
labels[ServiceLabel], _ = serviceName(step)
|
||||||
|
}
|
||||||
|
labels[StepLabel], err = stepLabel(step)
|
||||||
|
if err != nil {
|
||||||
|
return labels, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRegistrySecret(ctx context.Context, engine *kube, step *types.Step) error {
|
||||||
|
secret, err := mkRegistrySecret(step, engine.config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Trace().Msgf("creating secret: %s", secret.Name)
|
||||||
|
_, err = engine.client.CoreV1().Secrets(engine.config.Namespace).Create(ctx, secret, meta_v1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRegistrySecret(ctx context.Context, engine *kube, step *types.Step, deleteOpts meta_v1.DeleteOptions) error {
|
||||||
|
name, err := registrySecretName(step)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Trace().Str("name", name).Msg("deleting secret")
|
||||||
|
|
||||||
|
err = engine.client.CoreV1().Secrets(engine.config.Namespace).Delete(ctx, name, deleteOpts)
|
||||||
|
if errors.IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
@ -15,10 +15,14 @@
|
||||||
package kubernetes
|
package kubernetes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kinbiko/jsonassert"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
|
|
||||||
|
"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNativeSecretsEnabled(t *testing.T) {
|
func TestNativeSecretsEnabled(t *testing.T) {
|
||||||
|
@ -178,3 +182,61 @@ func TestFileSecret(t *testing.T) {
|
||||||
},
|
},
|
||||||
}, nsp.mounts)
|
}, nsp.mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNoAuthNoSecret(t *testing.T) {
|
||||||
|
assert.False(t, needsRegistrySecret(&types.Step{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoPasswordNoSecret(t *testing.T) {
|
||||||
|
assert.False(t, needsRegistrySecret(&types.Step{
|
||||||
|
AuthConfig: types.Auth{Username: "foo"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoUsernameNoSecret(t *testing.T) {
|
||||||
|
assert.False(t, needsRegistrySecret(&types.Step{
|
||||||
|
AuthConfig: types.Auth{Password: "foo"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsernameAndPasswordNeedsSecret(t *testing.T) {
|
||||||
|
assert.True(t, needsRegistrySecret(&types.Step{
|
||||||
|
AuthConfig: types.Auth{Username: "foo", Password: "bar"},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrySecret(t *testing.T) {
|
||||||
|
const expected = `{
|
||||||
|
"metadata": {
|
||||||
|
"name": "wp-01he8bebctabr3kgk0qj36d2me-0",
|
||||||
|
"namespace": "woodpecker",
|
||||||
|
"creationTimestamp": null,
|
||||||
|
"labels": {
|
||||||
|
"step": "go-test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "kubernetes.io/dockerconfigjson",
|
||||||
|
"data": {
|
||||||
|
".dockerconfigjson": "eyJhdXRocyI6eyJkb2NrZXIuaW8iOnsidXNlcm5hbWUiOiJmb28iLCJwYXNzd29yZCI6ImJhciJ9fX0="
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
secret, err := mkRegistrySecret(&types.Step{
|
||||||
|
UUID: "01he8bebctabr3kgk0qj36d2me-0",
|
||||||
|
Name: "go-test",
|
||||||
|
Image: "meltwater/drone-cache",
|
||||||
|
AuthConfig: types.Auth{
|
||||||
|
Username: "foo",
|
||||||
|
Password: "bar",
|
||||||
|
},
|
||||||
|
}, &config{
|
||||||
|
Namespace: "woodpecker",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
secretJSON, err := json.Marshal(secret)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ja := jsonassert.New(t)
|
||||||
|
ja.Assertf(string(secretJSON), expected)
|
||||||
|
}
|
||||||
|
|
|
@ -84,14 +84,19 @@ func imageHasTag(name string) bool {
|
||||||
return strings.Contains(name, ":")
|
return strings.Contains(name, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseNamed parses an image as a reference to validate it then parses it as a named reference.
|
||||||
|
func ParseNamed(image string) (reference.Named, error) {
|
||||||
|
ref, err := reference.ParseAnyReference(image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reference.ParseNamed(ref.String())
|
||||||
|
}
|
||||||
|
|
||||||
// MatchHostname returns true if the image hostname
|
// MatchHostname returns true if the image hostname
|
||||||
// matches the specified hostname.
|
// matches the specified hostname.
|
||||||
func MatchHostname(image, hostname string) bool {
|
func MatchHostname(image, hostname string) bool {
|
||||||
ref, err := reference.ParseAnyReference(image)
|
named, err := ParseNamed(image)
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
named, err := reference.ParseNamed(ref.String())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue