Skip to content

Commit 7f8e61d

Browse files
committed
feat(k8s): imagePullSecrets support + populate ProvisionResult endpoints
- Config.ImagePullSecrets []string (CP_K8S_IMAGE_PULL_SECRETS env) - WithImagePullSecrets(...string) option; WithConfig copies non-empty slice - buildPodSpec/buildDeployment/buildStatefulSet forward pull secrets to pod template imagePullSecrets - buildEndpoints helper derives in-cluster DNS URLs (http://<svc>.<ns>.svc.cluster.local:<port>) and populates ProvisionResult.Endpoints so twinos firstEndpoint/injectUpstreamEnv get a usable upstream base URL - 3 new unit tests: imagePullSecrets propagation + endpoint URL shape
1 parent 2e21cf0 commit 7f8e61d

5 files changed

Lines changed: 199 additions & 14 deletions

File tree

provider/kubernetes/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,10 @@ type Config struct {
3636

3737
// Labels are additional labels applied to all managed resources.
3838
Labels map[string]string `json:"labels,omitempty"`
39+
40+
// ImagePullSecrets is the list of Secret names in the same namespace
41+
// that contain credentials to pull private container images.
42+
// Corresponds to the Kubernetes podSpec.imagePullSecrets field.
43+
// Use CP_K8S_IMAGE_PULL_SECRETS (comma-separated) to configure via env.
44+
ImagePullSecrets []string `env:"CP_K8S_IMAGE_PULL_SECRETS" json:"image_pull_secrets,omitempty"`
3945
}

provider/kubernetes/multi_service_test.go

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func TestBuildPodSpec_MultiService(t *testing.T) {
5252
},
5353
}
5454

55-
spec := buildPodSpec(req)
55+
spec := buildPodSpec(req, nil)
5656

5757
// Init goes to initContainers[]; Main + Sidecar to containers[].
5858
if len(spec.InitContainers) != 1 {
@@ -98,7 +98,7 @@ func TestBuildDeployment_ReplicasFromMain(t *testing.T) {
9898
},
9999
}
100100

101-
dep := buildDeployment(req, "default", map[string]string{})
101+
dep := buildDeployment(req, "default", map[string]string{}, nil)
102102

103103
if dep.Spec.Replicas == nil {
104104
t.Fatalf("expected replicas pointer, got nil")
@@ -131,7 +131,7 @@ func TestBuildStatefulSet_PVCTemplates(t *testing.T) {
131131
},
132132
}
133133

134-
ss := buildStatefulSet(req, "default", map[string]string{})
134+
ss := buildStatefulSet(req, "default", map[string]string{}, nil)
135135

136136
if len(ss.Spec.VolumeClaimTemplates) != 1 {
137137
t.Fatalf("volume claim templates: want 1, got %d", len(ss.Spec.VolumeClaimTemplates))
@@ -171,7 +171,7 @@ func TestBuildService_HeadlessForStatefulSet(t *testing.T) {
171171
},
172172
}
173173

174-
svc := buildService(req, "default", map[string]string{}, true)
174+
svc := buildService(req, "default", map[string]string{}, true /*headless*/)
175175
if svc == nil {
176176
t.Fatalf("expected Service, got nil")
177177
}
@@ -217,3 +217,114 @@ func TestBuildConfigMaps_PerService(t *testing.T) {
217217
t.Fatalf("config maps: want 2 (main + logger), got %d", len(cms))
218218
}
219219
}
220+
221+
// TestBuildPodSpec_ImagePullSecrets verifies that imagePullSecrets are
222+
// forwarded to the PodSpec so private registry images can be pulled.
223+
func TestBuildPodSpec_ImagePullSecrets(t *testing.T) {
224+
t.Parallel()
225+
226+
req := provider.ProvisionRequest{
227+
InstanceID: id.New(id.PrefixInstance),
228+
TenantID: "ten_test",
229+
Services: []provider.ServiceSpec{
230+
{
231+
Name: "main",
232+
Image: "ghcr.io/private/app:1.0",
233+
Role: provider.RoleMain,
234+
},
235+
},
236+
}
237+
238+
spec := buildPodSpec(req, []string{"ghcr-pull"})
239+
240+
if len(spec.ImagePullSecrets) != 1 {
241+
t.Fatalf("imagePullSecrets: want 1, got %d", len(spec.ImagePullSecrets))
242+
}
243+
244+
if spec.ImagePullSecrets[0].Name != "ghcr-pull" {
245+
t.Fatalf("imagePullSecrets[0].Name: want ghcr-pull, got %q", spec.ImagePullSecrets[0].Name)
246+
}
247+
}
248+
249+
// TestBuildDeployment_ImagePullSecrets verifies that imagePullSecrets
250+
// propagate through buildDeployment into the pod template spec.
251+
func TestBuildDeployment_ImagePullSecrets(t *testing.T) {
252+
t.Parallel()
253+
254+
req := provider.ProvisionRequest{
255+
InstanceID: id.New(id.PrefixInstance),
256+
TenantID: "ten_test",
257+
Kind: provider.KindDeployment,
258+
Services: []provider.ServiceSpec{
259+
{
260+
Name: "main",
261+
Image: "ghcr.io/private/app:1.0",
262+
Role: provider.RoleMain,
263+
Ports: []provider.PortSpec{
264+
{Container: 7903, Protocol: "TCP"},
265+
},
266+
},
267+
},
268+
}
269+
270+
dep := buildDeployment(req, "default", map[string]string{}, []string{"ghcr-pull"})
271+
272+
got := dep.Spec.Template.Spec.ImagePullSecrets
273+
if len(got) != 1 {
274+
t.Fatalf("deployment imagePullSecrets: want 1, got %d", len(got))
275+
}
276+
277+
if got[0].Name != "ghcr-pull" {
278+
t.Fatalf("deployment imagePullSecrets[0].Name: want ghcr-pull, got %q", got[0].Name)
279+
}
280+
}
281+
282+
// TestBuildEndpoints verifies that buildEndpoints produces the correct
283+
// in-cluster DNS URL for each service with ports.
284+
func TestBuildEndpoints(t *testing.T) {
285+
t.Parallel()
286+
287+
req := provider.ProvisionRequest{
288+
InstanceID: id.New(id.PrefixInstance),
289+
TenantID: "ten_test",
290+
Services: []provider.ServiceSpec{
291+
{
292+
Name: "main",
293+
Image: "app:1",
294+
Role: provider.RoleMain,
295+
Ports: []provider.PortSpec{
296+
{Container: 7903, Protocol: "TCP"},
297+
},
298+
},
299+
{
300+
// No ports — should not produce an endpoint.
301+
Name: "init-db",
302+
Image: "alpine:3",
303+
Role: provider.RoleInit,
304+
},
305+
},
306+
}
307+
308+
endpoints := buildEndpoints(req, "staging")
309+
310+
if len(endpoints) != 1 {
311+
t.Fatalf("endpoints: want 1 (main only), got %d", len(endpoints))
312+
}
313+
314+
ep := endpoints[0]
315+
316+
if ep.ServiceName != "main" {
317+
t.Fatalf("endpoint ServiceName: want main, got %q", ep.ServiceName)
318+
}
319+
320+
if ep.Port != 7903 {
321+
t.Fatalf("endpoint Port: want 7903, got %d", ep.Port)
322+
}
323+
324+
svcName := serviceName(req.InstanceID)
325+
wantURL := "http://" + svcName + ".staging.svc.cluster.local:7903"
326+
327+
if ep.URL != wantURL {
328+
t.Fatalf("endpoint URL:\n want %q\n got %q", wantURL, ep.URL)
329+
}
330+
}

provider/kubernetes/options.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ func WithInCluster() Option {
8989
}
9090
}
9191

92+
// WithImagePullSecrets sets the list of Kubernetes Secret names that
93+
// contain credentials for pulling private container images. Each name
94+
// is added to the pod spec's imagePullSecrets field verbatim.
95+
func WithImagePullSecrets(secrets ...string) Option {
96+
return func(p *Provider) error {
97+
p.cfg.ImagePullSecrets = secrets
98+
99+
return nil
100+
}
101+
}
102+
92103
// WithConfig applies all non-zero fields from a Config struct.
93104
// This is useful when loading configuration from files or environment variables.
94105
func WithConfig(cfg Config) Option {
@@ -125,6 +136,10 @@ func WithConfig(cfg Config) Option {
125136
p.cfg.Labels = cfg.Labels
126137
}
127138

139+
if len(cfg.ImagePullSecrets) > 0 {
140+
p.cfg.ImagePullSecrets = cfg.ImagePullSecrets
141+
}
142+
128143
return nil
129144
}
130145
}

provider/kubernetes/provider.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,11 @@ func (p *Provider) Provision(ctx context.Context, req provider.ProvisionRequest)
117117
}
118118
}
119119

120+
pullSecrets := p.cfg.ImagePullSecrets
121+
120122
switch req.Kind {
121123
case provider.KindStatefulSet:
122-
ss := buildStatefulSet(req, ns, labels)
124+
ss := buildStatefulSet(req, ns, labels, pullSecrets)
123125
if _, err := p.client.AppsV1().StatefulSets(ns).Create(ctx, ss, metav1.CreateOptions{}); err != nil {
124126
return nil, fmt.Errorf("kubernetes: create statefulset: %w", err)
125127
}
@@ -131,7 +133,7 @@ func (p *Provider) Provision(ctx context.Context, req provider.ProvisionRequest)
131133
}
132134
}
133135
default: // KindDeployment (also covers empty-string for legacy paths)
134-
dep := buildDeployment(req, ns, labels)
136+
dep := buildDeployment(req, ns, labels, pullSecrets)
135137
if _, err := p.client.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}); err != nil {
136138
return nil, fmt.Errorf("kubernetes: create deployment: %w", err)
137139
}
@@ -155,9 +157,16 @@ func (p *Provider) Provision(ctx context.Context, req provider.ProvisionRequest)
155157
serviceRefs[req.Services[i].Name] = pref + "/" + req.Services[i].Name
156158
}
157159

160+
// Populate Endpoints from the in-cluster Service DNS address for
161+
// each service that declares ports. Consumers (e.g. twinos
162+
// firstEndpoint / injectUpstreamEnv) read Endpoints[0].URL as the
163+
// upstream base URL.
164+
endpoints := buildEndpoints(req, ns)
165+
158166
return &provider.ProvisionResult{
159167
ProviderRef: pref,
160168
ServiceRefs: serviceRefs,
169+
Endpoints: endpoints,
161170
}, nil
162171
}
163172

provider/kubernetes/resources.go

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ func replicaCountFor(req provider.ProvisionRequest) int32 {
9797
// Init services land in InitContainers (run-once before main start);
9898
// Main and Sidecar services land in Containers and run for the pod's
9999
// lifetime.
100-
func buildPodSpec(req provider.ProvisionRequest) corev1.PodSpec {
100+
//
101+
// imagePullSecrets is the list of Secret names in the same namespace
102+
// that hold registry credentials. Pass nil/empty for public images.
103+
func buildPodSpec(req provider.ProvisionRequest, imagePullSecrets []string) corev1.PodSpec {
101104
var (
102105
containers []corev1.Container
103106
initContainers []corev1.Container
@@ -137,10 +140,16 @@ func buildPodSpec(req provider.ProvisionRequest) corev1.PodSpec {
137140
}
138141
}
139142

143+
var pullSecretRefs []corev1.LocalObjectReference
144+
for _, s := range imagePullSecrets {
145+
pullSecretRefs = append(pullSecretRefs, corev1.LocalObjectReference{Name: s})
146+
}
147+
140148
return corev1.PodSpec{
141-
Containers: containers,
142-
InitContainers: initContainers,
143-
Volumes: podVolumes,
149+
Containers: containers,
150+
InitContainers: initContainers,
151+
Volumes: podVolumes,
152+
ImagePullSecrets: pullSecretRefs,
144153
}
145154
}
146155

@@ -176,7 +185,7 @@ func buildContainer(instanceID id.ID, svc provider.ServiceSpec) corev1.Container
176185

177186
// buildDeployment creates a Kubernetes Deployment for a stateless
178187
// workload (req.Kind == KindDeployment, the default).
179-
func buildDeployment(req provider.ProvisionRequest, namespace string, labels map[string]string) *appsv1.Deployment {
188+
func buildDeployment(req provider.ProvisionRequest, namespace string, labels map[string]string, imagePullSecrets []string) *appsv1.Deployment {
180189
replicas := replicaCountFor(req)
181190

182191
return &appsv1.Deployment{
@@ -196,7 +205,7 @@ func buildDeployment(req provider.ProvisionRequest, namespace string, labels map
196205
ObjectMeta: metav1.ObjectMeta{
197206
Labels: labels,
198207
},
199-
Spec: buildPodSpec(req),
208+
Spec: buildPodSpec(req, imagePullSecrets),
200209
},
201210
},
202211
}
@@ -206,9 +215,9 @@ func buildDeployment(req provider.ProvisionRequest, namespace string, labels map
206215
// stateful workload (req.Kind == KindStatefulSet). Persistent volumes
207216
// declared on services become volumeClaimTemplates so each replica
208217
// gets its own PVC.
209-
func buildStatefulSet(req provider.ProvisionRequest, namespace string, labels map[string]string) *appsv1.StatefulSet {
218+
func buildStatefulSet(req provider.ProvisionRequest, namespace string, labels map[string]string, imagePullSecrets []string) *appsv1.StatefulSet {
210219
replicas := replicaCountFor(req)
211-
podSpec := buildPodSpec(req)
220+
podSpec := buildPodSpec(req, imagePullSecrets)
212221

213222
// Volume claims: take every unique named volume across services
214223
// and emit a volumeClaimTemplate for it. The PodSpec volumes that
@@ -408,6 +417,41 @@ func buildVolumeMounts(volumes []provider.VolumeSpec) []corev1.VolumeMount {
408417
return result
409418
}
410419

420+
// buildEndpoints derives the in-cluster DNS endpoints for a provisioned
421+
// instance. One Endpoint is emitted per service that declares at least
422+
// one port. The URL has the form:
423+
//
424+
// http://<serviceName>.<namespace>.svc.cluster.local:<firstPort>
425+
//
426+
// This is the stable address consumers (e.g. twinos injectUpstreamEnv)
427+
// should use to reach the workload from within the cluster. Non-HTTP
428+
// protocols are not distinguished here — callers that need TLS or gRPC
429+
// URLs should wrap the value themselves.
430+
func buildEndpoints(req provider.ProvisionRequest, namespace string) []provider.Endpoint {
431+
svcName := serviceName(req.InstanceID)
432+
433+
var endpoints []provider.Endpoint
434+
435+
for i := range req.Services {
436+
svc := req.Services[i]
437+
if len(svc.Ports) == 0 {
438+
continue
439+
}
440+
441+
firstPort := svc.Ports[0].Container
442+
url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", svcName, namespace, firstPort)
443+
444+
endpoints = append(endpoints, provider.Endpoint{
445+
ServiceName: svc.Name,
446+
URL: url,
447+
Port: firstPort,
448+
Protocol: "TCP",
449+
})
450+
}
451+
452+
return endpoints
453+
}
454+
411455
// toK8sProtocol maps a protocol string to a Kubernetes Protocol constant.
412456
func toK8sProtocol(protocol string) corev1.Protocol {
413457
switch strings.ToUpper(protocol) {

0 commit comments

Comments
 (0)