Skip to content

Commit 0ffe6bb

Browse files
committed
feat: propagate workloadServiceAccount imagePullSecrets to selected synced ServiceAccounts
1 parent 8d32bd4 commit 0ffe6bb

11 files changed

Lines changed: 592 additions & 7 deletions

File tree

chart/values.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,10 @@
922922
"type": "array",
923923
"description": "ImagePullSecrets defines extra image pull secrets for the workload service account."
924924
},
925+
"imagePullSecretSelector": {
926+
"$ref": "#/$defs/StandardLabelSelector",
927+
"description": "ImagePullSecretSelector selects which virtual ServiceAccounts receive the imagePullSecrets\nabove on the host side when serviceAccount syncing is enabled. If empty, no propagation\noccurs. Use matchLabels: {} to match all ServiceAccounts."
928+
},
925929
"annotations": {
926930
"additionalProperties": {
927931
"type": "string"

chart/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,10 @@ controlPlane:
746746
name: ""
747747
# ImagePullSecrets defines extra image pull secrets for the workload service account.
748748
imagePullSecrets: []
749+
# ImagePullSecretSelector selects which virtual ServiceAccounts receive the imagePullSecrets
750+
# above on the host side when serviceAccount syncing is enabled. If empty, no propagation
751+
# occurs. Use matchLabels: {} to match all ServiceAccounts.
752+
imagePullSecretSelector: {}
749753
annotations: {}
750754
labels: {}
751755
# HeadlessService specifies options for the headless service used for the vCluster StatefulSet.

config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2594,6 +2594,11 @@ type ControlPlaneWorkloadServiceAccount struct {
25942594
// ImagePullSecrets defines extra image pull secrets for the workload service account.
25952595
ImagePullSecrets []ImagePullSecretName `json:"imagePullSecrets,omitempty"`
25962596

2597+
// ImagePullSecretSelector selects which virtual ServiceAccounts receive the imagePullSecrets
2598+
// above on the host side when serviceAccount syncing is enabled. If empty, no propagation
2599+
// occurs. Use matchLabels: {} to match all ServiceAccounts.
2600+
ImagePullSecretSelector StandardLabelSelector `json:"imagePullSecretSelector,omitempty"`
2601+
25972602
// Annotations are extra annotations for this resource.
25982603
Annotations map[string]string `json:"annotations,omitempty"`
25992604

config/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ controlPlane:
403403
enabled: true
404404
name: ""
405405
imagePullSecrets: []
406+
imagePullSecretSelector: {}
406407
annotations: {}
407408
labels: {}
408409

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package clusters
2+
3+
import _ "embed"
4+
5+
// SAImagePullSecretsVCluster has ServiceAccount syncing enabled and configures
6+
// workloadServiceAccount.imagePullSecrets with a label selector so that only
7+
// virtual ServiceAccounts labelled inject-pull-secrets=true receive the
8+
// imagePullSecrets on the corresponding host ServiceAccount.
9+
10+
//go:embed vcluster-sa-imagepullsecrets.yaml
11+
var saImagePullSecretsVClusterYAML string
12+
13+
var (
14+
SAImagePullSecretsVClusterName = "sa-imagepullsecrets-vcluster"
15+
SAImagePullSecretsVCluster = register(SAImagePullSecretsVClusterName, saImagePullSecretsVClusterYAML)
16+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
controlPlane:
2+
statefulSet:
3+
image:
4+
registry: ""
5+
repository: {{ .Repository }}
6+
tag: {{ .Tag }}
7+
advanced:
8+
workloadServiceAccount:
9+
imagePullSecrets:
10+
- name: e2e-test-registry-secret
11+
imagePullSecretSelector:
12+
matchLabels:
13+
inject-pull-secrets: "true"
14+
sync:
15+
toHost:
16+
serviceAccounts:
17+
enabled: true

e2e-next/labels/labels.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var (
1717
Vind = Label("vind")
1818

1919
// Resource-specific labels for fromHost sync tests
20+
ServiceAccounts = Label("serviceaccounts")
2021
PriorityClasses = Label("priorityclasses")
2122
RuntimeClasses = Label("runtimeclasses")
2223
StorageClasses = Label("storageclasses")
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Suite: sa-imagepullsecrets-vcluster
2+
// vCluster: SAImagePullSecretsVCluster (workloadServiceAccount.imagePullSecrets config)
3+
// Run: just run-e2e 'pr && sa-imagepullsecrets-vcluster'
4+
package e2e_next
5+
6+
import (
7+
"github.com/loft-sh/e2e-framework/pkg/setup/cluster"
8+
"github.com/loft-sh/vcluster/e2e-next/clusters"
9+
"github.com/loft-sh/vcluster/e2e-next/labels"
10+
test_core "github.com/loft-sh/vcluster/e2e-next/test_core/sync"
11+
. "github.com/onsi/ginkgo/v2"
12+
)
13+
14+
func init() {
15+
suiteSAImagePullSecretsVCluster()
16+
}
17+
18+
func suiteSAImagePullSecretsVCluster() {
19+
Describe("sa-imagepullsecrets-vcluster", labels.PR,
20+
cluster.Use(clusters.SAImagePullSecretsVCluster),
21+
cluster.Use(clusters.HostCluster),
22+
func() {
23+
test_core.DescribeSAImagePullSecrets()
24+
},
25+
)
26+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package test_core
2+
3+
import (
4+
"context"
5+
6+
"github.com/loft-sh/e2e-framework/pkg/setup/cluster"
7+
"github.com/loft-sh/vcluster/e2e-next/constants"
8+
"github.com/loft-sh/vcluster/e2e-next/labels"
9+
"github.com/loft-sh/vcluster/pkg/util/random"
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
corev1 "k8s.io/api/core/v1"
13+
kerrors "k8s.io/apimachinery/pkg/api/errors"
14+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+
"k8s.io/client-go/kubernetes"
16+
)
17+
18+
// hostSAAnnotationName is the annotation set by the SA syncer to record the virtual SA name.
19+
const hostSAAnnotationName = "vcluster.loft.sh/object-name"
20+
21+
// hostSANamespaceLabel is the label set by the SA syncer to record the virtual namespace.
22+
const hostSANamespaceLabel = "vcluster.loft.sh/namespace"
23+
24+
// expectedPullSecret is the imagePullSecret name configured in vcluster-sa-imagepullsecrets.yaml.
25+
const expectedPullSecret = "e2e-test-registry-secret"
26+
27+
// selectorLabel is the label key/value that the imagePullSecretSelector matches on.
28+
const selectorLabelKey = "inject-pull-secrets"
29+
const selectorLabelValue = "true"
30+
31+
// DescribeSAImagePullSecrets registers tests that verify workloadServiceAccount imagePullSecrets
32+
// are propagated to synced host ServiceAccounts when they match the configured selector.
33+
func DescribeSAImagePullSecrets() {
34+
Describe("ServiceAccount imagePullSecrets propagation",
35+
labels.Core,
36+
labels.PR,
37+
labels.Sync,
38+
labels.ServiceAccounts,
39+
func() {
40+
var (
41+
hostClient kubernetes.Interface
42+
vClusterClient kubernetes.Interface
43+
)
44+
45+
BeforeEach(func(ctx context.Context) {
46+
hostClient = cluster.KubeClientFrom(ctx, constants.GetHostClusterName())
47+
Expect(hostClient).NotTo(BeNil())
48+
vClusterClient = cluster.CurrentKubeClientFrom(ctx)
49+
Expect(vClusterClient).NotTo(BeNil())
50+
})
51+
52+
// findHostSA lists host ServiceAccounts for the given virtual namespace and returns
53+
// the one whose vcluster ownership annotation matches saName.
54+
findHostSA := func(ctx context.Context, g Gomega, virtualNS, saName string) *corev1.ServiceAccount {
55+
GinkgoHelper()
56+
sas, err := hostClient.CoreV1().ServiceAccounts("").List(ctx, metav1.ListOptions{
57+
LabelSelector: hostSANamespaceLabel + "=" + virtualNS,
58+
})
59+
g.Expect(err).To(Succeed(), "listing host SAs for virtual namespace %q", virtualNS)
60+
61+
for i := range sas.Items {
62+
if sas.Items[i].Annotations[hostSAAnnotationName] == saName {
63+
return &sas.Items[i]
64+
}
65+
}
66+
g.Expect(false).To(BeTrue(), "host SA for virtual SA %q/%q not found yet", virtualNS, saName)
67+
return nil
68+
}
69+
70+
It("propagates imagePullSecrets to a SA whose labels match the selector", func(ctx context.Context) {
71+
suffix := random.String(6)
72+
nsName := "sa-pull-secret-test-" + suffix
73+
saName := "sa-matching-" + suffix
74+
75+
By("creating a virtual namespace", func() {
76+
_, err := vClusterClient.CoreV1().Namespaces().Create(ctx,
77+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}},
78+
metav1.CreateOptions{})
79+
Expect(err).To(Succeed())
80+
DeferCleanup(func(ctx context.Context) {
81+
err := vClusterClient.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
82+
if !kerrors.IsNotFound(err) {
83+
Expect(err).To(Succeed())
84+
}
85+
})
86+
})
87+
88+
By("creating a virtual SA with the matching label", func() {
89+
_, err := vClusterClient.CoreV1().ServiceAccounts(nsName).Create(ctx,
90+
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{
91+
Name: saName,
92+
Namespace: nsName,
93+
Labels: map[string]string{selectorLabelKey: selectorLabelValue},
94+
}},
95+
metav1.CreateOptions{})
96+
Expect(err).To(Succeed())
97+
})
98+
99+
By("waiting for the host SA to have the configured imagePullSecrets", func() {
100+
Eventually(func(g Gomega) {
101+
hostSA := findHostSA(ctx, g, nsName, saName)
102+
g.Expect(hostSA.ImagePullSecrets).To(ContainElement(
103+
corev1.LocalObjectReference{Name: expectedPullSecret},
104+
), "host SA imagePullSecrets should contain %q", expectedPullSecret)
105+
}).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed())
106+
})
107+
})
108+
109+
It("does not propagate imagePullSecrets to a SA whose labels do not match the selector", func(ctx context.Context) {
110+
suffix := random.String(6)
111+
nsName := "sa-pull-secret-test-" + suffix
112+
saName := "sa-nonmatching-" + suffix
113+
114+
By("creating a virtual namespace", func() {
115+
_, err := vClusterClient.CoreV1().Namespaces().Create(ctx,
116+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}},
117+
metav1.CreateOptions{})
118+
Expect(err).To(Succeed())
119+
DeferCleanup(func(ctx context.Context) {
120+
err := vClusterClient.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
121+
if !kerrors.IsNotFound(err) {
122+
Expect(err).To(Succeed())
123+
}
124+
})
125+
})
126+
127+
By("creating a virtual SA without the matching label", func() {
128+
_, err := vClusterClient.CoreV1().ServiceAccounts(nsName).Create(ctx,
129+
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{
130+
Name: saName,
131+
Namespace: nsName,
132+
// no inject-pull-secrets label
133+
}},
134+
metav1.CreateOptions{})
135+
Expect(err).To(Succeed())
136+
})
137+
138+
By("waiting for the host SA to exist and verifying it has no imagePullSecrets", func() {
139+
Consistently(func(g Gomega) {
140+
hostSA := findHostSA(ctx, g, nsName, saName)
141+
g.Expect(hostSA.ImagePullSecrets).To(BeEmpty(),
142+
"host SA imagePullSecrets should be empty for a non-matching SA")
143+
}).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutShort).Should(Succeed())
144+
})
145+
})
146+
147+
// Ordered: the second spec removes the SA label; it must run after the first spec
148+
// has verified that imagePullSecrets were initially set.
149+
Context("when the SA label is removed after initial sync", Ordered, func() {
150+
var (
151+
nsName string
152+
saName string
153+
)
154+
155+
BeforeAll(func(ctx context.Context) {
156+
suffix := random.String(6)
157+
nsName = "sa-pull-secret-test-" + suffix
158+
saName = "sa-label-removed-" + suffix
159+
160+
_, err := vClusterClient.CoreV1().Namespaces().Create(ctx,
161+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}},
162+
metav1.CreateOptions{})
163+
Expect(err).To(Succeed())
164+
DeferCleanup(func(ctx context.Context) {
165+
err := vClusterClient.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
166+
if !kerrors.IsNotFound(err) {
167+
Expect(err).To(Succeed())
168+
}
169+
})
170+
171+
_, err = vClusterClient.CoreV1().ServiceAccounts(nsName).Create(ctx,
172+
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{
173+
Name: saName,
174+
Namespace: nsName,
175+
Labels: map[string]string{selectorLabelKey: selectorLabelValue},
176+
}},
177+
metav1.CreateOptions{})
178+
Expect(err).To(Succeed())
179+
})
180+
181+
It("host SA initially has imagePullSecrets while the SA matches the selector", func(ctx context.Context) {
182+
By("waiting for imagePullSecrets to appear on the host SA", func() {
183+
Eventually(func(g Gomega) {
184+
hostSA := findHostSA(ctx, g, nsName, saName)
185+
g.Expect(hostSA.ImagePullSecrets).To(ContainElement(
186+
corev1.LocalObjectReference{Name: expectedPullSecret},
187+
), "host SA should have imagePullSecrets while selector label is set")
188+
}).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed())
189+
})
190+
})
191+
192+
It("host SA loses imagePullSecrets after the selector label is removed", func(ctx context.Context) {
193+
By("removing the selector label from the virtual SA", func() {
194+
sa, err := vClusterClient.CoreV1().ServiceAccounts(nsName).Get(ctx, saName, metav1.GetOptions{})
195+
Expect(err).To(Succeed())
196+
delete(sa.Labels, selectorLabelKey)
197+
_, err = vClusterClient.CoreV1().ServiceAccounts(nsName).Update(ctx, sa, metav1.UpdateOptions{})
198+
Expect(err).To(Succeed())
199+
})
200+
201+
By("waiting for imagePullSecrets to be cleared on the host SA", func() {
202+
Eventually(func(g Gomega) {
203+
hostSA := findHostSA(ctx, g, nsName, saName)
204+
g.Expect(hostSA.ImagePullSecrets).To(BeEmpty(),
205+
"host SA imagePullSecrets should be cleared after selector label is removed")
206+
}).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeout).Should(Succeed())
207+
})
208+
})
209+
})
210+
},
211+
)
212+
}

0 commit comments

Comments
 (0)