Skip to content

Commit 07e8348

Browse files
patjlmclaude
andcommitted
multi-stage: support projected SA token volumes with custom audiences
Add support for configuring projected service account token volumes in multi-stage test steps, allowing steps to request tokens with custom audiences for workload identity federation. This includes API types, codegen, config loading, validation (absolute paths, overlap checks, audience ownership), and pod generation with proper volume/mount wiring. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aeb8c9d commit 07e8348

12 files changed

Lines changed: 861 additions & 8 deletions

cmd/ci-operator-checkconfig/main.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,23 @@ type options struct {
3535
clusterProfiles api.ClusterProfilesMap
3636
clusterClaimOwners api.ClusterClaimOwnersMap
3737
clusterProfileSetDetails api.ClusterProfileSetDetails
38+
allowedAudiences api.AllowedAudiencesMap
3839
}
3940

4041
func (o *options) parse() error {
4142
var registryDir string
4243
var profilesConfigPath string
4344
var clusterClaimConfigPath string
4445
var clusterProfileSetDetailsPath string
46+
var allowedAudiencesConfigPath string
4547

4648
fs := flag.NewFlagSet("", flag.ExitOnError)
4749

4850
fs.StringVar(&registryDir, "registry", "", "Path to the step registry directory")
4951
fs.StringVar(&profilesConfigPath, "cluster-profiles-config", "", "Path to the cluster profile config file")
5052
fs.StringVar(&clusterClaimConfigPath, "cluster-claim-owners-config", "", "Path to the cluster claim owners config file")
5153
fs.StringVar(&clusterProfileSetDetailsPath, "cluster-profile-set-details", "", "Path to the cluster profile set details file")
54+
fs.StringVar(&allowedAudiencesConfigPath, "allowed-audiences-config", "", "Path to the allowed audiences config file")
5255
o.Options.Bind(fs)
5356

5457
if err := fs.Parse(os.Args[1:]); err != nil {
@@ -71,6 +74,14 @@ func (o *options) parse() error {
7174
}
7275
o.clusterClaimOwners = claimOwners
7376

77+
if allowedAudiencesConfigPath != "" {
78+
allowedAudiences, err := load.AllowedAudiencesConfig(allowedAudiencesConfigPath)
79+
if err != nil {
80+
return fmt.Errorf("failed to load allowed audiences config: %w", err)
81+
}
82+
o.allowedAudiences = allowedAudiences
83+
}
84+
7485
ciOPConfigAgent, err := agents.NewConfigAgent(o.ConfigDir, nil, agents.WithOrg(o.Org), agents.WithRepo(o.Repo))
7586
if err != nil {
7687
return fmt.Errorf("failed to create CI Op config agent: %w", err)
@@ -111,7 +122,8 @@ func (o *options) validate() (ret []error) {
111122
errCh := make(chan error)
112123
map_ := func() error {
113124
validator := validation.NewValidator(o.clusterProfiles, o.clusterClaimOwners,
114-
validation.WithClusterProfileSetDetails(o.clusterProfileSetDetails))
125+
validation.WithClusterProfileSetDetails(o.clusterProfileSetDetails),
126+
validation.WithAllowedAudiences(o.allowedAudiences))
115127
for c := range inputCh {
116128
if err := o.validateConfiguration(&validator, outputCh, c); err != nil {
117129
errCh <- fmt.Errorf("failed to validate configuration %s: %w", c.Metadata.RelativePath(), err)

pkg/api/types.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,27 @@ type LiteralTestStep struct {
11541154
// NodeArchitecture is the architecture for the node where the test will run.
11551155
// If set, the generated test pod will include a nodeSelector for this architecture.
11561156
NodeArchitecture *NodeArchitecture `json:"node_architecture,omitempty"`
1157+
// ServiceAccountTokens configures additional projected service account token
1158+
// volumes with custom audiences, mounted into the step container. This is
1159+
// useful for workloads that need to exchange tokens with external identity
1160+
// providers (e.g., GCP Workload Identity Federation).
1161+
ServiceAccountTokens []ServiceAccountTokenVolume `json:"service_account_tokens,omitempty"`
1162+
}
1163+
1164+
// ServiceAccountTokenVolume configures a projected service account token volume
1165+
// with a custom audience mounted into the step container. The kubelet handles
1166+
// the token request transparently — no additional RBAC is required beyond pod
1167+
// creation.
1168+
type ServiceAccountTokenVolume struct {
1169+
// Audience is the intended audience of the token. The token will only be
1170+
// valid for recipients that identify themselves with this audience.
1171+
Audience string `json:"audience"`
1172+
// MountPath is the path where the token will be mounted in the container.
1173+
MountPath string `json:"mount_path"`
1174+
// ExpirationSeconds is the requested duration of validity of the token,
1175+
// in seconds. The kubelet will automatically rotate the token at 80% of
1176+
// its TTL. Defaults to 3600 (1 hour) if not set.
1177+
ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"`
11571178
}
11581179

11591180
// StepParameter is a variable set by the test, with an optional default.
@@ -3194,6 +3215,21 @@ type ClusterClaimOwnerDetails struct {
31943215
Repos []string `yaml:"repos,omitempty"`
31953216
}
31963217

3218+
// AllowedAudiencesMap maps audience strings to their ownership details.
3219+
// Audiences in this map are restricted to configs from the listed org/repo owners.
3220+
// Audiences not in this map are unrestricted.
3221+
type AllowedAudiencesMap map[string]AllowedAudienceDetails
3222+
3223+
type AllowedAudienceDetails struct {
3224+
Audience string `yaml:"audience" json:"audience"`
3225+
Owners []AllowedAudienceOwners `yaml:"owners,omitempty" json:"owners,omitempty"`
3226+
}
3227+
3228+
type AllowedAudienceOwners struct {
3229+
Org string `yaml:"org" json:"org"`
3230+
Repos []string `yaml:"repos,omitempty" json:"repos,omitempty"`
3231+
}
3232+
31973233
const (
31983234
EphemeralClusterTestDoneSignalSecretName = "test-done-signal"
31993235
)

pkg/api/zz_generated.deepcopy.go

Lines changed: 90 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/load/load.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,31 @@ func ClusterClaimOwnersConfig(configPath string) (api.ClusterClaimOwnersMap, err
338338
}
339339
return clusterClaimOwnersMap, nil
340340
}
341+
342+
// AllowedAudiencesConfig loads allowed audiences information from its config in the release repository.
343+
// If the file does not exist, an empty map is returned.
344+
func AllowedAudiencesConfig(configPath string) (api.AllowedAudiencesMap, error) {
345+
configContents, err := os.ReadFile(configPath)
346+
if err != nil {
347+
if os.IsNotExist(err) {
348+
return make(api.AllowedAudiencesMap), nil
349+
}
350+
return nil, fmt.Errorf("failed to read allowed audiences config: %w", err)
351+
}
352+
353+
var audiencesList []api.AllowedAudienceDetails
354+
if err = yaml.UnmarshalStrict(configContents, &audiencesList); err != nil {
355+
return nil, fmt.Errorf("failed to unmarshall allowed audiences config: %w", err)
356+
}
357+
allowedAudiencesMap := make(api.AllowedAudiencesMap, len(audiencesList))
358+
for i, a := range audiencesList {
359+
if a.Audience == "" {
360+
return nil, fmt.Errorf("allowed audiences config entry %d: audience must not be empty", i)
361+
}
362+
if _, exists := allowedAudiencesMap[a.Audience]; exists {
363+
return nil, fmt.Errorf("allowed audiences config: duplicate audience %q", a.Audience)
364+
}
365+
allowedAudiencesMap[a.Audience] = a
366+
}
367+
return allowedAudiencesMap, nil
368+
}

pkg/steps/multi_stage/gen.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,12 @@ func (s *multiStageTestStep) generatePods(
149149
pod.Annotations[base_steps.AnnotationSaveContainerLogs] = "true"
150150
pod.Labels[MultiStageTestLabel] = s.name
151151
needsKubeConfig := isKubeconfigNeeded(&step, genPodOpts)
152-
if needsKubeConfig {
152+
if needsKubeConfig || len(step.ServiceAccountTokens) > 0 {
153153
pod.Spec.ServiceAccountName = s.name
154154
} else {
155155
pod.Spec.ServiceAccountName = ""
156+
}
157+
if !needsKubeConfig {
156158
no := false
157159
pod.Spec.AutomountServiceAccountToken = &no
158160
}
@@ -249,6 +251,7 @@ func (s *multiStageTestStep) generatePods(
249251
if step.RunAsScript != nil && *step.RunAsScript {
250252
addCommandScript(commandConfigMapForTest(s.name), pod)
251253
}
254+
addServiceAccountTokenVolumes(step.ServiceAccountTokens, pod)
252255
if s.vpnConf != nil {
253256
caps := coreapi.Capabilities{
254257
Add: []coreapi.Capability{"NET_ADMIN"},
@@ -636,6 +639,35 @@ func addCommandScript(name string, pod *coreapi.Pod) {
636639
})
637640
}
638641

642+
func addServiceAccountTokenVolumes(tokens []api.ServiceAccountTokenVolume, pod *coreapi.Pod) {
643+
for i, token := range tokens {
644+
volumeName := fmt.Sprintf("sa-token-%d", i)
645+
expSeconds := int64(3600)
646+
if token.ExpirationSeconds != nil {
647+
expSeconds = *token.ExpirationSeconds
648+
}
649+
pod.Spec.Volumes = append(pod.Spec.Volumes, coreapi.Volume{
650+
Name: volumeName,
651+
VolumeSource: coreapi.VolumeSource{
652+
Projected: &coreapi.ProjectedVolumeSource{
653+
Sources: []coreapi.VolumeProjection{{
654+
ServiceAccountToken: &coreapi.ServiceAccountTokenProjection{
655+
Audience: token.Audience,
656+
ExpirationSeconds: &expSeconds,
657+
Path: "token",
658+
},
659+
}},
660+
},
661+
},
662+
})
663+
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, coreapi.VolumeMount{
664+
Name: volumeName,
665+
MountPath: token.MountPath,
666+
ReadOnly: true,
667+
})
668+
}
669+
}
670+
639671
func addLeaseProxyScripts(pod *coreapi.Pod, c *coreapi.Container) {
640672
pod.Spec.Volumes = append(pod.Spec.Volumes, coreapi.Volume{
641673
Name: "lease-proxy",

pkg/steps/multi_stage/gen_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,49 @@ func TestGeneratePods(t *testing.T) {
142142
}},
143143
},
144144
},
145+
{
146+
name: "service account token projection",
147+
config: &api.ReleaseBuildConfiguration{
148+
Tests: []api.TestStepConfiguration{{
149+
As: "test",
150+
MultiStageTestConfigurationLiteral: &api.MultiStageTestConfigurationLiteral{
151+
Test: []api.LiteralTestStep{{
152+
As: "step0",
153+
From: "src",
154+
Commands: "command0",
155+
ServiceAccountTokens: []api.ServiceAccountTokenVolume{{
156+
Audience: "gcp-wif-audience",
157+
MountPath: "/var/run/secrets/wif",
158+
}, {
159+
Audience: "vault",
160+
MountPath: "/var/run/secrets/vault",
161+
ExpirationSeconds: ptr.To(int64(7200)),
162+
}},
163+
}},
164+
},
165+
}},
166+
},
167+
},
168+
{
169+
name: "service account token projection with no_kubeconfig",
170+
config: &api.ReleaseBuildConfiguration{
171+
Tests: []api.TestStepConfiguration{{
172+
As: "test",
173+
MultiStageTestConfigurationLiteral: &api.MultiStageTestConfigurationLiteral{
174+
Test: []api.LiteralTestStep{{
175+
As: "step0",
176+
From: "src",
177+
Commands: "command0",
178+
NoKubeconfig: ptr.To(true),
179+
ServiceAccountTokens: []api.ServiceAccountTokenVolume{{
180+
Audience: "gcp-wif-audience",
181+
MountPath: "/var/run/secrets/wif",
182+
}},
183+
}},
184+
},
185+
}},
186+
},
187+
},
145188
{
146189
name: "lease proxy server available",
147190
config: &api.ReleaseBuildConfiguration{

0 commit comments

Comments
 (0)