Skip to content

Commit f98c4ca

Browse files
feat: render core's PodTemplateOverlay in the deployment template (#44)
ConfigMounts are the agent's half of the pod seam: core binds serviceAccountName, the SA object and the pod labels/annotations into the rendered manifests after templating, but only warns when a mount's volume is absent, because the volume and volumeMount can only come from the agent's own template. automountServiceAccountToken becomes a deployment parameter instead of a constant, defaulting to false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 144810b commit f98c4ca

3 files changed

Lines changed: 208 additions & 1 deletion

File tree

‎builder.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ type Parameters struct {
246246
// binds (the app defaults to it when CODEFLY_GRPC_PORT is unset).
247247
GRPCEnabled bool
248248
GRPCPort int
249+
// AutomountServiceAccountToken projects the ServiceAccount token into the
250+
// pod. It stays false while the platform's identity path is the metadata
251+
// server or a webhook-projected token and the app never calls the API
252+
// server; a workload that does call it sets this.
253+
AutomountServiceAccountToken bool
249254
}
250255

251256
// Deploy renders and applies k8s manifests.

‎deployment_test.go‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ package main
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78

9+
"github.com/codefly-dev/core/agents/services"
810
agenttesting "github.com/codefly-dev/core/agents/testing"
11+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
912
"github.com/stretchr/testify/require"
13+
"gopkg.in/yaml.v3"
1014
)
1115

1216
func TestDeploymentTemplates(t *testing.T) {
@@ -29,3 +33,186 @@ func TestDeploymentTemplates(t *testing.T) {
2933
// read-only root filesystem.
3034
require.Contains(t, manifest, "value: /tmp")
3135
}
36+
37+
// podSpec is the slice of the rendered Deployment the overlay lands on.
38+
type podSpec struct {
39+
Metadata struct {
40+
Labels map[string]string `yaml:"labels"`
41+
Annotations map[string]string `yaml:"annotations"`
42+
} `yaml:"metadata"`
43+
Spec struct {
44+
ServiceAccountName string `yaml:"serviceAccountName"`
45+
AutomountServiceAccountToken *bool `yaml:"automountServiceAccountToken"`
46+
Containers []struct {
47+
VolumeMounts []struct {
48+
Name string `yaml:"name"`
49+
MountPath string `yaml:"mountPath"`
50+
ReadOnly *bool `yaml:"readOnly"`
51+
} `yaml:"volumeMounts"`
52+
} `yaml:"containers"`
53+
Volumes []struct {
54+
Name string `yaml:"name"`
55+
ConfigMap *struct {
56+
Name string `yaml:"name"`
57+
Optional *bool `yaml:"optional"`
58+
} `yaml:"configMap"`
59+
} `yaml:"volumes"`
60+
} `yaml:"spec"`
61+
}
62+
63+
type serviceAccount struct {
64+
Kind string `yaml:"kind"`
65+
Metadata struct {
66+
Name string `yaml:"name"`
67+
Namespace string `yaml:"namespace"`
68+
Labels map[string]string `yaml:"labels"`
69+
Annotations map[string]string `yaml:"annotations"`
70+
} `yaml:"metadata"`
71+
}
72+
73+
// renderDeployment renders the deployment templates with an overlay and returns
74+
// the workload's pod template, normalized the way DeployKustomize normalizes it
75+
// before rendering.
76+
func renderDeployment(t *testing.T, parameters Parameters, overlay *services.PodTemplateOverlay) (podSpec, string) {
77+
t.Helper()
78+
overlay.DefaultConfigMounts()
79+
destination := agenttesting.AssertKustomizeTemplatesWithOverlay(t, deploymentFS, deploymentTestParameters(t, parameters), overlay)
80+
81+
rendered, err := os.ReadFile(filepath.Join(destination, "base", "deployment.yaml"))
82+
require.NoError(t, err)
83+
var deployment struct {
84+
Spec struct {
85+
Template podSpec `yaml:"template"`
86+
} `yaml:"spec"`
87+
}
88+
require.NoError(t, yaml.Unmarshal(rendered, &deployment))
89+
return deployment.Spec.Template, destination
90+
}
91+
92+
func TestDeploymentWithoutOverlayIsUnchanged(t *testing.T) {
93+
pod, destination := renderDeployment(t, Parameters{}, nil)
94+
95+
require.Empty(t, pod.Spec.ServiceAccountName)
96+
require.Equal(t, false, *pod.Spec.AutomountServiceAccountToken)
97+
require.Nil(t, pod.Metadata.Annotations)
98+
require.Equal(t, map[string]string{"app": "example-service", "sha": "1.2.3"}, pod.Metadata.Labels)
99+
// Only the scratch mount: an absent overlay must add no volume.
100+
require.Len(t, pod.Spec.Volumes, 1)
101+
require.Equal(t, "tmp", pod.Spec.Volumes[0].Name)
102+
require.Len(t, pod.Spec.Containers[0].VolumeMounts, 1)
103+
104+
_, err := os.Stat(filepath.Join(destination, "base", "serviceaccount.yaml"))
105+
require.ErrorIs(t, err, os.ErrNotExist)
106+
}
107+
108+
func TestDeploymentRendersServiceAccountWithAnnotations(t *testing.T) {
109+
pod, destination := renderDeployment(t, Parameters{}, &services.PodTemplateOverlay{
110+
ServiceAccount: &services.WorkloadServiceAccount{
111+
Name: "model-gateway",
112+
Annotations: map[string]string{"iam.gke.io/gcp-service-account": "gateway@project.iam.gserviceaccount.com"},
113+
},
114+
})
115+
116+
require.Equal(t, "model-gateway", pod.Spec.ServiceAccountName)
117+
// The workload takes its identity from a projected token, not from a
118+
// mounted API-server token.
119+
require.Equal(t, false, *pod.Spec.AutomountServiceAccountToken)
120+
121+
rendered, err := os.ReadFile(filepath.Join(destination, "base", "serviceaccount.yaml"))
122+
require.NoError(t, err)
123+
var account serviceAccount
124+
require.NoError(t, yaml.Unmarshal(rendered, &account))
125+
require.Equal(t, "ServiceAccount", account.Kind)
126+
require.Equal(t, "model-gateway", account.Metadata.Name)
127+
require.Equal(t, "codefly-test", account.Metadata.Namespace)
128+
require.Equal(t, "gateway@project.iam.gserviceaccount.com", account.Metadata.Annotations["iam.gke.io/gcp-service-account"])
129+
require.Equal(t, "codefly", account.Metadata.Labels["app.kubernetes.io/managed-by"])
130+
131+
kustomization, err := os.ReadFile(filepath.Join(destination, "base", "kustomization.yaml"))
132+
require.NoError(t, err)
133+
require.Contains(t, string(kustomization), "serviceaccount.yaml")
134+
}
135+
136+
func TestDeploymentStampsPodLabelsAndAnnotations(t *testing.T) {
137+
pod, _ := renderDeployment(t, Parameters{}, &services.PodTemplateOverlay{
138+
PodLabels: map[string]string{"azure.workload.identity/use": "true"},
139+
PodAnnotations: map[string]string{"prometheus.io/scrape": "true"},
140+
})
141+
142+
require.Equal(t, "true", pod.Metadata.Labels["azure.workload.identity/use"])
143+
// The selector label must survive the stamp, or the Deployment adopts no pods.
144+
require.Equal(t, "example-service", pod.Metadata.Labels["app"])
145+
require.Equal(t, "true", pod.Metadata.Annotations["prometheus.io/scrape"])
146+
}
147+
148+
func TestDeploymentRendersConfigMount(t *testing.T) {
149+
pod, _ := renderDeployment(t, Parameters{}, &services.PodTemplateOverlay{
150+
ConfigMounts: []services.ConfigMount{
151+
{ConfigMapName: "trust-bundle", MountPath: "/etc/ssl/trust"},
152+
},
153+
})
154+
155+
require.Len(t, pod.Spec.Volumes, 2)
156+
volume := pod.Spec.Volumes[1]
157+
require.Equal(t, "trust-bundle", volume.Name)
158+
require.Equal(t, "trust-bundle", volume.ConfigMap.Name)
159+
require.Equal(t, false, *volume.ConfigMap.Optional)
160+
161+
mounts := pod.Spec.Containers[0].VolumeMounts
162+
require.Len(t, mounts, 2)
163+
require.Equal(t, "trust-bundle", mounts[1].Name)
164+
require.Equal(t, "/etc/ssl/trust", mounts[1].MountPath)
165+
// File config defaults to read-only.
166+
require.Equal(t, true, *mounts[1].ReadOnly)
167+
}
168+
169+
func TestDeploymentRendersTwoConfigMounts(t *testing.T) {
170+
pod, _ := renderDeployment(t, Parameters{}, &services.PodTemplateOverlay{
171+
ConfigMounts: []services.ConfigMount{
172+
{ConfigMapName: "trust-bundle", MountPath: "/etc/ssl/trust"},
173+
{ConfigMapName: "model-routes", MountPath: "/etc/gateway/routes"},
174+
},
175+
})
176+
177+
require.Len(t, pod.Spec.Volumes, 3)
178+
require.Equal(t, "trust-bundle", pod.Spec.Volumes[1].Name)
179+
require.Equal(t, "model-routes", pod.Spec.Volumes[2].Name)
180+
181+
mounts := pod.Spec.Containers[0].VolumeMounts
182+
require.Len(t, mounts, 3)
183+
require.Equal(t, "/etc/ssl/trust", mounts[1].MountPath)
184+
require.Equal(t, "/etc/gateway/routes", mounts[2].MountPath)
185+
}
186+
187+
func TestDeploymentRendersOptionalConfigMount(t *testing.T) {
188+
writable := false
189+
pod, _ := renderDeployment(t, Parameters{}, &services.PodTemplateOverlay{
190+
ConfigMounts: []services.ConfigMount{
191+
{ConfigMapName: "trust-bundle", MountPath: "/etc/ssl/trust", Optional: true, ReadOnly: &writable},
192+
},
193+
})
194+
195+
// Optional lets the pod start before the ConfigMap is supplied per environment.
196+
require.Equal(t, true, *pod.Spec.Volumes[1].ConfigMap.Optional)
197+
require.Equal(t, false, *pod.Spec.Containers[0].VolumeMounts[1].ReadOnly)
198+
}
199+
200+
// TestDeploymentAutomountServiceAccountTokenRejectsProjection pins the reason
201+
// the parameter defaults to false and cannot yet be flipped: core's manifest
202+
// conformance rejects a projected token for every workload, so a service that
203+
// needs to call the API server is blocked in core, not here.
204+
func TestDeploymentAutomountServiceAccountTokenRejectsProjection(t *testing.T) {
205+
_, destination := renderDeployment(t, Parameters{}, nil)
206+
207+
path := filepath.Join(destination, "base", "deployment.yaml")
208+
conformant, err := os.ReadFile(path)
209+
require.NoError(t, err)
210+
projected := strings.Replace(string(conformant), "automountServiceAccountToken: false", "automountServiceAccountToken: true", 1)
211+
require.NotEqual(t, string(conformant), projected)
212+
require.NoError(t, os.WriteFile(path, []byte(projected), 0o600))
213+
214+
validation := services.ValidateKubernetesManifestTree(t.Context(), destination, "test", "codefly-test",
215+
builderv0.KubernetesOutputProfile_KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1, false, "", "")
216+
217+
require.Equal(t, []string{"Deployment/example-service must set automountServiceAccountToken: false"}, validation.GetViolations())
218+
}

‎templates/deployment/kustomize/base/deployment.yaml.tmpl‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ spec:
1414
app: {{ .Service.Name.DNSCase }}
1515
sha: {{ .Sha }}
1616
spec:
17-
automountServiceAccountToken: false
17+
automountServiceAccountToken: {{ .Deployment.Parameters.AutomountServiceAccountToken }}
1818
terminationGracePeriodSeconds: 30
1919
securityContext:
2020
runAsNonRoot: true
@@ -51,6 +51,13 @@ spec:
5151
volumeMounts:
5252
- name: tmp
5353
mountPath: /tmp
54+
{{- with .PodOverlay }}
55+
{{- range .ConfigMounts }}
56+
- name: {{ .VolumeName }}
57+
mountPath: {{ .MountPath }}
58+
readOnly: {{ .ReadOnly }}
59+
{{- end }}
60+
{{- end }}
5461
envFrom:
5562
- configMapRef:
5663
name: config-{{ .Service.Name.DNSCase }}
@@ -77,3 +84,11 @@ spec:
7784
- name: tmp
7885
emptyDir:
7986
sizeLimit: 1Gi
87+
{{- with .PodOverlay }}
88+
{{- range .ConfigMounts }}
89+
- name: {{ .VolumeName }}
90+
configMap:
91+
name: {{ .ConfigMapName }}
92+
optional: {{ .Optional }}
93+
{{- end }}
94+
{{- end }}

0 commit comments

Comments
 (0)