@@ -3,10 +3,14 @@ package main
33import (
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
1216func 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+ }
0 commit comments