Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement registries for Kubernetes backend #4092

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions docs/docs/20-usage/41-registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ Example registry hostname matching logic:
- Hostname `docker.io` matches `bradrydzewski/golang`
- 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

To make a private registry globally available, check the [server configuration docs](../30-administration/10-server-config.md#global-registry-setting).
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/30-administration/22-backends/40-kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is

## 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/).
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`.
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.

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

Expand Down
21 changes: 20 additions & 1 deletion pipeline/backend/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package kubernetes

import (
"context"
std_errs "errors"
"fmt"
"io"
"maps"
Expand Down Expand Up @@ -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")
}

if needsRegistrySecret(step) {
err = startRegistrySecret(ctx, e, step)
if err != nil {
return err
}
}

log.Trace().Str("taskUUID", taskUUID).Msgf("starting step: %s", step.Name)
_, err = startPod(ctx, e, step, options)
return err
Expand Down Expand Up @@ -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 {
var errs []error
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)
return err
if err != nil {
errs = append(errs, err)
}
return std_errs.Join(errs...)
}

// DestroyWorkflow destroys the pipeline environment.
Expand Down
9 changes: 9 additions & 0 deletions pipeline/backend/kubernetes/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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...)

Expand Down Expand Up @@ -514,6 +522,7 @@ func stopPod(ctx context.Context, engine *kube, step *types.Step, deleteOpts met
if err != nil {
return err
}

log.Trace().Str("name", podName).Msg("deleting pod")

err = engine.client.CoreV1().Pods(engine.config.Namespace).Delete(ctx, podName, deleteOpts)
Expand Down
8 changes: 8 additions & 0 deletions pipeline/backend/kubernetes/pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ func TestFullPod(t *testing.T) {
},
{
"name": "another-pull-secret"
},
{
"name": "wp-01he8bebctabr3kgk0qj36d2me-0"
}
],
"tolerations": [
Expand Down Expand Up @@ -317,6 +320,7 @@ func TestFullPod(t *testing.T) {
},
}
pod, err := mkPod(&types.Step{
UUID: "01he8bebctabr3kgk0qj36d2me-0",
Name: "go-test",
Image: "meltwater/drone-cache",
WorkingDir: "/woodpecker/src",
Expand All @@ -328,6 +332,10 @@ func TestFullPod(t *testing.T) {
Environment: map[string]string{"CGO": "0"},
ExtraHosts: hostAliases,
Ports: ports,
AuthConfig: types.Auth{
Username: "foo",
Password: "bar",
},
}, &config{
Namespace: "woodpecker",
ImagePullSecretNames: []string{"regcred", "another-pull-secret"},
Expand Down
103 changes: 103 additions & 0 deletions pipeline/backend/kubernetes/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,21 @@
package kubernetes

import (
"context"
"encoding/json"
"fmt"
"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"
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 {
Expand Down Expand Up @@ -189,3 +199,96 @@ func secretReference(name string) v1.LocalObjectReference {
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work without encoding to Base64?
If works, then OK. Otherwise, maybe we can generalize code with Docker (#3122, func (a Auth) EncodeToBase64() (string, error)).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. If you're thinking of needing to use base64 in secret YAML files, that is accomplished by the encoding/json package "Array and slice values encode as JSON arrays, except that []byte encodes as a base64-encoded string". The serialized JSON form contains a base64 string, but the k8s data structure contains raw bytes.

I've tested prior to each push with a full install into a k8s cluster, both testing that a private image fails without the registry, and succeeds with it, so if it didn't work without base64, that would have caught it. I'm happy to share or commit this test setup script if you'd like.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant more auth field in the auths, than entire .dockerconfigjson in the secret.
https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/

{"auths":{"your.private.registry.example.com":{"username":"janedoe","password":"xxxxxxxxxxx","email":"[email protected]","auth":"c3R...zE2"}}}

To understand what is in the auth field, convert the base64-encoded data to a readable format:

$ echo "c3R...zE2" | base64 --decode
janedoe:xxxxxxxxxxx

Copy link
Author

@meln5674 meln5674 Sep 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. In k8s, as far as I know, that field isn't actually required, and empirically, it works without it.

But don't take my word for it, here's the tests from kubernetes itself where they work with just username and password.

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
}
62 changes: 62 additions & 0 deletions pipeline/backend/kubernetes/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
package kubernetes

import (
"encoding/json"
"testing"

"github.com/kinbiko/jsonassert"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"

"go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types"
)

func TestNativeSecretsEnabled(t *testing.T) {
Expand Down Expand Up @@ -178,3 +182,61 @@ func TestFileSecret(t *testing.T) {
},
}, 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)
}
15 changes: 10 additions & 5 deletions pipeline/frontend/yaml/utils/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,19 @@ func imageHasTag(name string) bool {
return strings.Contains(name, ":")
}

// MatchHostname returns true if the image hostname
// matches the specified hostname.
func MatchHostname(image, hostname string) bool {
// 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 false
return nil, err
}
named, err := reference.ParseNamed(ref.String())
return reference.ParseNamed(ref.String())
}

// MatchHostname returns true if the image hostname
// matches the specified hostname.
func MatchHostname(image, hostname string) bool {
named, err := ParseNamed(image)
if err != nil {
return false
}
Expand Down