mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-02-13 21:00:00 +00:00
Add support for headless Kubernetes services (#5764)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Henrik Huitti <henrik.huitti@henhu.fi> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Robert Kaussow <mail@thegeeklab.de>
This commit is contained in:
@@ -304,6 +304,36 @@ It configures the address of the Kubernetes API server to connect to.
|
||||
|
||||
If running the agent within Kubernetes, this will already be set and you don't have to add it manually.
|
||||
|
||||
### Headless services
|
||||
|
||||
For each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created,
|
||||
and all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname.
|
||||
|
||||
Using the headless services, the step pod is connected to directly, so any port on the other step pods can be reached.
|
||||
|
||||
This is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: test
|
||||
image: docker:cli # use 'docker:<major-version>-cli' or similar in production
|
||||
environment:
|
||||
DOCKER_HOST: 'tcp://docker:2376'
|
||||
DOCKER_CERT_PATH: '/woodpecker/dind-certs/client'
|
||||
DOCKER_TLS_VERIFY: '1'
|
||||
commands:
|
||||
- docker run hello-world
|
||||
|
||||
- name: docker
|
||||
image: docker:dind # use 'docker:<major-version>-dind' or similar in production
|
||||
detached: true
|
||||
privileged: true
|
||||
environment:
|
||||
DOCKER_TLS_CERTDIR: /woodpecker/dind-certs
|
||||
```
|
||||
|
||||
If ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP.
|
||||
|
||||
## Environment variables
|
||||
|
||||
These env vars can be set in the `env:` sections of the agent.
|
||||
|
||||
@@ -41,7 +41,6 @@ import (
|
||||
"k8s.io/client-go/tools/cache"
|
||||
|
||||
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
||||
pipelineErrors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -221,38 +220,23 @@ func (e *kube) SetupWorkflow(ctx context.Context, conf *types.Config, taskUUID s
|
||||
namespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID)
|
||||
|
||||
if e.config.EnableNamespacePerOrg {
|
||||
log.Trace().Str("taskUUID", taskUUID).Msgf("Ensure organization namespace: %s", namespace)
|
||||
err := mkNamespace(ctx, e.client.CoreV1().Namespaces(), namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace().Str("taskUUID", taskUUID).Msgf("Creating workflow volume")
|
||||
_, err := startVolume(ctx, e, conf.Volume, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var extraHosts []types.HostAlias
|
||||
for _, stage := range conf.Stages {
|
||||
for _, step := range stage.Steps {
|
||||
if isService(step) {
|
||||
svc, err := startService(ctx, e, step)
|
||||
if err != nil {
|
||||
return &pipelineErrors.ErrInvalidWorkflowSetup{
|
||||
Err: err,
|
||||
Step: step,
|
||||
}
|
||||
}
|
||||
hostAlias := types.HostAlias{Name: step.Networks[0].Aliases[0], IP: svc.Spec.ClusterIP}
|
||||
extraHosts = append(extraHosts, hostAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Trace().Msgf("adding extra hosts: %v", extraHosts)
|
||||
for _, stage := range conf.Stages {
|
||||
for _, step := range stage.Steps {
|
||||
step.ExtraHosts = extraHosts
|
||||
}
|
||||
log.Trace().Str("taskUUID", taskUUID).Msgf("Creating workflow headless service")
|
||||
_, err = startHeadlessService(ctx, e, namespace, taskUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -471,23 +455,16 @@ func (e *kube) DestroyStep(ctx context.Context, step *types.Step, taskUUID strin
|
||||
func (e *kube) DestroyWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error {
|
||||
log.Trace().Str("taskUUID", taskUUID).Msg("deleting Kubernetes primitives")
|
||||
|
||||
for _, stage := range conf.Stages {
|
||||
for _, step := range stage.Steps {
|
||||
err := stopPod(ctx, e, step, defaultDeleteOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
namespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID)
|
||||
|
||||
if isService(step) {
|
||||
err := stopService(ctx, e, step, defaultDeleteOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Trace().Str("taskUUID", taskUUID).Msgf("deleting workflow headless service")
|
||||
err := stopHeadlessService(ctx, e, namespace, taskUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := stopVolume(ctx, e, conf.Volume, e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID), defaultDeleteOptions)
|
||||
log.Trace().Str("taskUUID", taskUUID).Msgf("deleting workflow volume")
|
||||
err = stopVolume(ctx, e, conf.Volume, e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID), defaultDeleteOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
||||
)
|
||||
|
||||
func TestGettingConfig(t *testing.T) {
|
||||
@@ -51,3 +56,77 @@ func TestGettingConfig(t *testing.T) {
|
||||
assert.Len(t, engine.config.ImagePullSecretNames, 1)
|
||||
assert.False(t, engine.config.SecurityContext.RunAsNonRoot)
|
||||
}
|
||||
|
||||
func TestSetupWorkflow(t *testing.T) {
|
||||
namespace := "foo"
|
||||
volumeName := "volume-name"
|
||||
volumePath := volumeName + ":/woodpecker"
|
||||
networkName := "test-network"
|
||||
taskUUID := "11301"
|
||||
|
||||
engine := kube{
|
||||
config: &config{
|
||||
Namespace: namespace,
|
||||
StorageClass: "hdd",
|
||||
VolumeSize: "1G",
|
||||
StorageRwx: false,
|
||||
PodLabels: map[string]string{"l1": "v1"},
|
||||
PodAnnotations: map[string]string{"a1": "v1"},
|
||||
ImagePullSecretNames: []string{"regcred"},
|
||||
SecurityContext: SecurityContextConfig{RunAsNonRoot: false},
|
||||
},
|
||||
client: fake.NewClientset(),
|
||||
}
|
||||
|
||||
serviceWithPorts := types.Step{
|
||||
OrgID: 42,
|
||||
Name: "service",
|
||||
UUID: "123",
|
||||
Type: types.StepTypeService,
|
||||
Volumes: []string{volumePath},
|
||||
Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}},
|
||||
Ports: []types.Port{
|
||||
{Number: 8080, Protocol: "tcp"},
|
||||
},
|
||||
}
|
||||
|
||||
conf := &types.Config{
|
||||
Volume: volumePath,
|
||||
Network: networkName,
|
||||
Stages: []*types.Stage{
|
||||
{
|
||||
Steps: []*types.Step{
|
||||
&serviceWithPorts,
|
||||
{
|
||||
OrgID: 42,
|
||||
UUID: "234",
|
||||
Name: "service2",
|
||||
Type: types.StepTypeService,
|
||||
Volumes: []string{volumePath},
|
||||
Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Steps: []*types.Step{
|
||||
{
|
||||
OrgID: 42,
|
||||
UUID: "456",
|
||||
Name: "step-1",
|
||||
Volumes: []string{volumePath},
|
||||
Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := engine.SetupWorkflow(context.Background(), conf, taskUUID)
|
||||
assert.NoError(t, err, "SetupWorkflow should not error with minimal config and fake client")
|
||||
|
||||
_, err = engine.client.CoreV1().PersistentVolumeClaims(namespace).Get(context.Background(), "volume-name", meta_v1.GetOptions{})
|
||||
assert.NoError(t, err, "persistent volume should be created during workflow setup")
|
||||
|
||||
_, err = engine.client.CoreV1().Services(namespace).Get(context.Background(), "wp-hsvc-"+taskUUID, meta_v1.GetOptions{})
|
||||
assert.NoError(t, err, "headless service should be created during workflow setup")
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func mkPod(step *types.Step, config *config, podName, goos string, options Backe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec, err := podSpec(step, config, options, nsp)
|
||||
spec, err := podSpec(step, config, options, nsp, taskUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -172,14 +172,21 @@ func podAnnotations(config *config, options BackendOptions) map[string]string {
|
||||
return annotations
|
||||
}
|
||||
|
||||
func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativeSecretsProcessor) (v1.PodSpec, error) {
|
||||
var err error
|
||||
func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativeSecretsProcessor, taskUUID string) (v1.PodSpec, error) {
|
||||
subdomain, err := subdomain(taskUUID)
|
||||
if err != nil {
|
||||
return v1.PodSpec{}, err
|
||||
}
|
||||
|
||||
spec := v1.PodSpec{
|
||||
RestartPolicy: v1.RestartPolicyNever,
|
||||
RuntimeClassName: options.RuntimeClassName,
|
||||
ServiceAccountName: options.ServiceAccountName,
|
||||
PriorityClassName: config.PriorityClassName,
|
||||
HostAliases: hostAliases(step.ExtraHosts),
|
||||
Hostname: step.Name,
|
||||
Subdomain: subdomain,
|
||||
DNSConfig: dnsConfig(config.GetNamespace(step.OrgID), subdomain),
|
||||
NodeSelector: nodeSelector(options.NodeSelector, config.PodNodeSelector, step.Environment["CI_SYSTEM_PLATFORM"]),
|
||||
Tolerations: tolerations(options.Tolerations),
|
||||
SecurityContext: podSecurityContext(options.SecurityContext, config.SecurityContext, step.Privileged),
|
||||
@@ -603,6 +610,12 @@ func mapToEnvVars(m map[string]string) []v1.EnvVar {
|
||||
return ev
|
||||
}
|
||||
|
||||
func dnsConfig(namespace, subdomain string) *v1.PodDNSConfig {
|
||||
return &v1.PodDNSConfig{
|
||||
Searches: []string{fmt.Sprintf("%s.%s.svc.cluster.local", subdomain, namespace)},
|
||||
}
|
||||
}
|
||||
|
||||
func startPod(ctx context.Context, engine *kube, step *types.Step, options BackendOptions, taskUUID string) (*v1.Pod, error) {
|
||||
podName, err := stepToPodName(step)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,11 +52,11 @@ func TestStepToPodName(t *testing.T) {
|
||||
name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "prepare-env", Type: types.StepTypeCommands})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "wp-01he8bebctabr3kg", name)
|
||||
name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Type: types.StepTypeService})
|
||||
name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Type: types.StepTypeService, Ports: []types.Port{{Number: 5432}}})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", name)
|
||||
// Detached service
|
||||
name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Detached: true})
|
||||
name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Detached: true, Ports: []types.Port{{Number: 5432}}})
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", name)
|
||||
// Detached long running container
|
||||
@@ -73,6 +73,7 @@ func TestPodMeta(t *testing.T) {
|
||||
Image: "postgres:16",
|
||||
WorkingDir: "/woodpecker/src",
|
||||
Environment: map[string]string{"CI": "woodpecker"},
|
||||
Ports: []types.Port{{Number: 5432}},
|
||||
}, &config{
|
||||
Namespace: "woodpecker",
|
||||
}, BackendOptions{}, "wp-01he8bebctabr3kg-0", taskUUID)
|
||||
@@ -88,6 +89,7 @@ func TestPodMeta(t *testing.T) {
|
||||
Image: "postgres:16",
|
||||
WorkingDir: "/woodpecker/src",
|
||||
Environment: map[string]string{"CI": "woodpecker"},
|
||||
Ports: []types.Port{{Number: 5432}},
|
||||
}, &config{
|
||||
Namespace: "woodpecker",
|
||||
}, BackendOptions{}, "wp-01he8bebctabr3kg-0", taskUUID)
|
||||
@@ -172,7 +174,12 @@ func TestTinyPod(t *testing.T) {
|
||||
]
|
||||
}
|
||||
],
|
||||
"restartPolicy": "Never"
|
||||
"restartPolicy": "Never",
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "build-via-gradle"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -337,7 +344,11 @@ func TestFullPod(t *testing.T) {
|
||||
"cf.v6"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "go-test"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -433,7 +444,7 @@ func TestPodPrivilege(t *testing.T) {
|
||||
SecurityContext: SecurityContextConfig{RunAsNonRoot: globalRunAsRoot},
|
||||
}, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{
|
||||
SecurityContext: &secCtx,
|
||||
}, "")
|
||||
}, "11301")
|
||||
}
|
||||
|
||||
// securty context is requesting user and group 101 (non-root)
|
||||
@@ -529,7 +540,12 @@ func TestScratchPod(t *testing.T) {
|
||||
"resources": {}
|
||||
}
|
||||
],
|
||||
"restartPolicy": "Never"
|
||||
"restartPolicy": "Never",
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "curl-google"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -628,7 +644,12 @@ func TestSecrets(t *testing.T) {
|
||||
]
|
||||
}
|
||||
],
|
||||
"restartPolicy": "Never"
|
||||
"restartPolicy": "Never",
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "test-secrets"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -704,7 +725,12 @@ func TestPodTolerations(t *testing.T) {
|
||||
"value": "qux",
|
||||
"effect": "NoExecute"
|
||||
}
|
||||
]
|
||||
],
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "toleration-test"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -752,7 +778,12 @@ func TestPodTolerationsAllowFromStep(t *testing.T) {
|
||||
"resources": {}
|
||||
}
|
||||
],
|
||||
"restartPolicy": "Never"
|
||||
"restartPolicy": "Never",
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "toleration-test"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
@@ -782,7 +813,12 @@ func TestPodTolerationsAllowFromStep(t *testing.T) {
|
||||
"value": "value",
|
||||
"effect": "NoSchedule"
|
||||
}
|
||||
]
|
||||
],
|
||||
"dnsConfig": {
|
||||
"searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]
|
||||
},
|
||||
"subdomain": "wp-hsvc-11301",
|
||||
"hostname": "toleration-test"
|
||||
},
|
||||
"status": {}
|
||||
}`
|
||||
|
||||
@@ -16,93 +16,79 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
int_str "k8s.io/apimachinery/pkg/util/intstr"
|
||||
|
||||
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceLabel = "service"
|
||||
servicePrefix = "wp-svc-"
|
||||
ServiceLabel = "service"
|
||||
HeadlessServicePrefix = "wp-hsvc-"
|
||||
ServicePrefix = "wp-svc-"
|
||||
)
|
||||
|
||||
func mkService(step *types.Step, config *config) (*v1.Service, error) {
|
||||
name, err := serviceName(step)
|
||||
func mkHeadlessService(namespace, taskUUID string) (*v1.Service, error) {
|
||||
selector := map[string]string{
|
||||
TaskUUIDLabel: taskUUID,
|
||||
}
|
||||
|
||||
name, err := subdomain(taskUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selector := map[string]string{
|
||||
ServiceLabel: name,
|
||||
}
|
||||
|
||||
if len(step.Ports) == 0 {
|
||||
return nil, fmt.Errorf("kubernetes backend requires explicitly exposed ports for service steps, add 'ports' configuration to step '%s'", step.Name)
|
||||
}
|
||||
|
||||
var svcPorts []v1.ServicePort
|
||||
for _, port := range step.Ports {
|
||||
svcPorts = append(svcPorts, servicePort(port))
|
||||
}
|
||||
|
||||
log.Trace().Str("name", name).Interface("selector", selector).Interface("ports", svcPorts).Msg("creating service")
|
||||
log.Trace().Str("name", name).Interface("selector", selector).Msg("creating headless service")
|
||||
return &v1.Service{
|
||||
ObjectMeta: meta_v1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: config.GetNamespace(step.OrgID),
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: v1.ServiceSpec{
|
||||
Type: v1.ServiceTypeClusterIP,
|
||||
Selector: selector,
|
||||
Ports: svcPorts,
|
||||
Type: v1.ServiceTypeClusterIP,
|
||||
ClusterIP: "None",
|
||||
Selector: selector,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serviceName(step *types.Step) (string, error) {
|
||||
return dnsName(servicePrefix + step.UUID + "-" + step.Name)
|
||||
return dnsName(ServicePrefix + step.UUID + "-" + step.Name)
|
||||
}
|
||||
|
||||
func servicePort(port types.Port) v1.ServicePort {
|
||||
portNumber := int32(port.Number)
|
||||
portProtocol := strings.ToUpper(port.Protocol)
|
||||
return v1.ServicePort{
|
||||
Name: fmt.Sprintf("port-%d", portNumber),
|
||||
Port: portNumber,
|
||||
Protocol: v1.Protocol(portProtocol),
|
||||
TargetPort: int_str.IntOrString{IntVal: portNumber},
|
||||
}
|
||||
func isService(step *types.Step) bool {
|
||||
return step.Type == types.StepTypeService || (step.Detached && dnsPattern.FindStringIndex(step.Name) != nil)
|
||||
}
|
||||
|
||||
func startService(ctx context.Context, engine *kube, step *types.Step) (*v1.Service, error) {
|
||||
engineConfig := engine.getConfig()
|
||||
svc, err := mkService(step, engineConfig)
|
||||
func subdomain(taskUUID string) (string, error) {
|
||||
return dnsName(HeadlessServicePrefix + taskUUID)
|
||||
}
|
||||
|
||||
func startHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) (*v1.Service, error) {
|
||||
svc, err := mkHeadlessService(namespace, taskUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace().Str("name", svc.Name).Interface("selector", svc.Spec.Selector).Interface("ports", svc.Spec.Ports).Msg("creating service")
|
||||
return engine.client.CoreV1().Services(engineConfig.GetNamespace(step.OrgID)).Create(ctx, svc, meta_v1.CreateOptions{})
|
||||
log.Trace().Str("name", svc.Name).Interface("selector", svc.Spec.Selector).Msg("creating headless service")
|
||||
return engine.client.CoreV1().Services(namespace).Create(ctx, svc, meta_v1.CreateOptions{})
|
||||
}
|
||||
|
||||
func stopService(ctx context.Context, engine *kube, step *types.Step, deleteOpts meta_v1.DeleteOptions) error {
|
||||
svcName, err := serviceName(step)
|
||||
func stopHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) error {
|
||||
name, err := subdomain(taskUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace().Str("name", svcName).Msg("deleting service")
|
||||
|
||||
err = engine.client.CoreV1().Services(engine.config.GetNamespace(step.OrgID)).Delete(ctx, svcName, deleteOpts)
|
||||
log.Trace().Str("name", name).Msg("deleting headless service")
|
||||
|
||||
err = engine.client.CoreV1().Services(namespace).Delete(ctx, name, defaultDeleteOptions)
|
||||
if errors.IsNotFound(err) {
|
||||
// Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps.
|
||||
log.Trace().Err(err).Msgf("unable to delete service %s", svcName)
|
||||
log.Trace().Err(err).Msgf("unable to delete headless service %s", name)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
||||
)
|
||||
@@ -37,54 +41,111 @@ func TestServiceName(t *testing.T) {
|
||||
assert.Equal(t, "wp-svc-01he8bebctabr3kgk0qj36d2me-awesome-service", name)
|
||||
}
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
func TestHeadlessService(t *testing.T) {
|
||||
expected := `
|
||||
{
|
||||
"metadata": {
|
||||
"name": "wp-svc-01he8bebctabr3kgk0qj36d2me-0-bar",
|
||||
"namespace": "foo"
|
||||
"name": "wp-hsvc-11301",
|
||||
"namespace": "foo"
|
||||
},
|
||||
"spec": {
|
||||
"ports": [
|
||||
{
|
||||
"name": "port-1",
|
||||
"port": 1,
|
||||
"targetPort": 1
|
||||
},
|
||||
{
|
||||
"name": "port-2",
|
||||
"protocol": "TCP",
|
||||
"port": 2,
|
||||
"targetPort": 2
|
||||
},
|
||||
{
|
||||
"name": "port-3",
|
||||
"protocol": "UDP",
|
||||
"port": 3,
|
||||
"targetPort": 3
|
||||
}
|
||||
],
|
||||
"selector": {
|
||||
"service": "wp-svc-01he8bebctabr3kgk0qj36d2me-0-bar"
|
||||
},
|
||||
"type": "ClusterIP"
|
||||
"selector": {
|
||||
"woodpecker-ci.org/task-uuid": "11301"
|
||||
},
|
||||
"clusterIP": "None",
|
||||
"type": "ClusterIP"
|
||||
},
|
||||
"status": {
|
||||
"loadBalancer": {}
|
||||
"loadBalancer": {}
|
||||
}
|
||||
}`
|
||||
ports := []types.Port{
|
||||
{Number: 1},
|
||||
{Number: 2, Protocol: "tcp"},
|
||||
{Number: 3, Protocol: "udp"},
|
||||
}
|
||||
s, err := mkService(&types.Step{
|
||||
Name: "bar",
|
||||
UUID: "01he8bebctabr3kgk0qj36d2me-0",
|
||||
Ports: ports,
|
||||
}, &config{Namespace: "foo"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
s, err := mkHeadlessService("foo", "11301")
|
||||
assert.NoError(t, err, "expected no error when creating headless service")
|
||||
j, err := json.Marshal(s)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, expected, string(j))
|
||||
assert.NoError(t, err, "expected no error when marshaling headless service to JSON")
|
||||
assert.JSONEq(t, expected, string(j), "expected headless service JSON to match")
|
||||
}
|
||||
|
||||
func TestInvalidHeadlessService(t *testing.T) {
|
||||
_, err := mkHeadlessService("foo", "invalid_task_uuid!")
|
||||
assert.Error(t, err, "expected error due to invalid task UUID")
|
||||
}
|
||||
|
||||
func TestStartHeadlessService(t *testing.T) {
|
||||
t.Run("successfully creates headless service", func(t *testing.T) {
|
||||
engine := &kube{
|
||||
client: fake.NewClientset(),
|
||||
config: &config{Namespace: "test-namespace"},
|
||||
}
|
||||
|
||||
svc, err := startHeadlessService(context.Background(), engine, "foo", "11301")
|
||||
assert.NoError(t, err, "expected no error when starting headless service")
|
||||
|
||||
assert.NotNil(t, svc, "expected headless service to be created")
|
||||
assert.Equal(t, "wp-hsvc-11301", svc.Name, "expected headless service name to match")
|
||||
assert.Equal(t, "foo", svc.Namespace, "expected headless service namespace to match")
|
||||
assert.Equal(t, v1.ServiceTypeClusterIP, svc.Spec.Type, "expected headless service type to be ClusterIP")
|
||||
assert.Equal(t, "None", svc.Spec.ClusterIP, "expected headless service ClusterIP to be 'None'")
|
||||
assert.Equal(t, map[string]string{TaskUUIDLabel: "11301"}, svc.Spec.Selector)
|
||||
|
||||
createdSvc, err := engine.client.CoreV1().Services("foo").Get(context.Background(), "wp-hsvc-11301", meta_v1.GetOptions{})
|
||||
assert.NoError(t, err, "expected no error when getting the created service")
|
||||
assert.Equal(t, svc.Name, createdSvc.Name, "expected created service name to match")
|
||||
})
|
||||
|
||||
t.Run("error on invalid task UUID resulting in invalid domain-name", func(t *testing.T) {
|
||||
engine := &kube{
|
||||
client: fake.NewClientset(),
|
||||
config: &config{Namespace: "test-namespace"},
|
||||
}
|
||||
|
||||
_, err := startHeadlessService(context.Background(), engine, "test-namespace", "invalid_task_uuid!")
|
||||
assert.Error(t, err, "expected error due to invalid task UUID")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopHeadlessService(t *testing.T) {
|
||||
t.Run("successfully deletes headless service", func(t *testing.T) {
|
||||
engine := &kube{
|
||||
client: fake.NewClientset(),
|
||||
config: &config{Namespace: "test-namespace"},
|
||||
}
|
||||
|
||||
// arrage
|
||||
_, err := startHeadlessService(context.Background(), engine, "foo", "11301")
|
||||
assert.NoError(t, err, "expected no error when starting headless service")
|
||||
|
||||
_, err = engine.client.CoreV1().Services("foo").Get(context.Background(), "wp-hsvc-11301", meta_v1.GetOptions{})
|
||||
assert.NoError(t, err, "expected no error when getting the created service")
|
||||
|
||||
// act
|
||||
err = stopHeadlessService(context.Background(), engine, "foo", "11301")
|
||||
assert.NoError(t, err, "expected no error when deleting headless service")
|
||||
|
||||
// assert
|
||||
_, err = engine.client.CoreV1().Services("foo").Get(context.Background(), "wp-hsvc-11301", meta_v1.GetOptions{})
|
||||
assert.Error(t, err, "expected error when getting a deleted service")
|
||||
assert.True(t, err != nil, "expected error to be non-nil")
|
||||
})
|
||||
|
||||
t.Run("handles non-existent service gracefully", func(t *testing.T) {
|
||||
engine := &kube{
|
||||
client: fake.NewClientset(),
|
||||
config: &config{Namespace: "test-namespace"},
|
||||
}
|
||||
|
||||
err := stopHeadlessService(context.Background(), engine, "foo", "nonexistent")
|
||||
assert.NoError(t, err, "expected no error when deleting a non-existent service")
|
||||
})
|
||||
|
||||
t.Run("error on invalid task UUID resulting in invalid domain-name", func(t *testing.T) {
|
||||
engine := &kube{
|
||||
client: fake.NewClientset(),
|
||||
config: &config{Namespace: "test-namespace"},
|
||||
}
|
||||
|
||||
err := stopHeadlessService(context.Background(), engine, "test-namespace", "invalid_task_uuid!")
|
||||
assert.Error(t, err, "expected error due to invalid task UUID")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ import (
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
client_cmd "k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -105,10 +103,6 @@ func getClientInsideOfCluster() (kubernetes.Interface, error) {
|
||||
return kubernetes.NewForConfig(config)
|
||||
}
|
||||
|
||||
func isService(step *types.Step) bool {
|
||||
return step.Type == types.StepTypeService || (step.Detached && dnsPattern.FindStringIndex(step.Name) != nil)
|
||||
}
|
||||
|
||||
func newBool(val bool) *bool {
|
||||
ptr := new(bool)
|
||||
*ptr = val
|
||||
|
||||
Reference in New Issue
Block a user