Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cmd/ci-operator-checkconfig/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,23 @@ type options struct {
clusterProfiles api.ClusterProfilesMap
clusterClaimOwners api.ClusterClaimOwnersMap
clusterProfileSetDetails api.ClusterProfileSetDetails
allowedAudiences api.AllowedAudiencesMap
}

func (o *options) parse() error {
var registryDir string
var profilesConfigPath string
var clusterClaimConfigPath string
var clusterProfileSetDetailsPath string
var allowedAudiencesConfigPath string

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

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

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

if allowedAudiencesConfigPath != "" {
allowedAudiences, err := load.AllowedAudiencesConfig(allowedAudiencesConfigPath)
if err != nil {
return fmt.Errorf("failed to load allowed audiences config: %w", err)
}
o.allowedAudiences = allowedAudiences
}

ciOPConfigAgent, err := agents.NewConfigAgent(o.ConfigDir, nil, agents.WithOrg(o.Org), agents.WithRepo(o.Repo))
if err != nil {
return fmt.Errorf("failed to create CI Op config agent: %w", err)
Expand Down Expand Up @@ -111,7 +122,8 @@ func (o *options) validate() (ret []error) {
errCh := make(chan error)
map_ := func() error {
validator := validation.NewValidator(o.clusterProfiles, o.clusterClaimOwners,
validation.WithClusterProfileSetDetails(o.clusterProfileSetDetails))
validation.WithClusterProfileSetDetails(o.clusterProfileSetDetails),
validation.WithAllowedAudiences(o.allowedAudiences))
for c := range inputCh {
if err := o.validateConfiguration(&validator, outputCh, c); err != nil {
errCh <- fmt.Errorf("failed to validate configuration %s: %w", c.Metadata.RelativePath(), err)
Expand Down
36 changes: 36 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,27 @@ type LiteralTestStep struct {
// NodeArchitecture is the architecture for the node where the test will run.
// If set, the generated test pod will include a nodeSelector for this architecture.
NodeArchitecture *NodeArchitecture `json:"node_architecture,omitempty"`
// ServiceAccountTokens configures additional projected service account token
// volumes with custom audiences, mounted into the step container. This is
// useful for workloads that need to exchange tokens with external identity
// providers (e.g., GCP Workload Identity Federation).
ServiceAccountTokens []ServiceAccountTokenVolume `json:"service_account_tokens,omitempty"`
}

// ServiceAccountTokenVolume configures a projected service account token volume
// with a custom audience mounted into the step container. The kubelet handles
// the token request transparently — no additional RBAC is required beyond pod
// creation.
type ServiceAccountTokenVolume struct {
// Audience is the intended audience of the token. The token will only be
// valid for recipients that identify themselves with this audience.
Audience string `json:"audience"`
// MountPath is the path where the token will be mounted in the container.
MountPath string `json:"mount_path"`
// ExpirationSeconds is the requested duration of validity of the token,
// in seconds. The kubelet will automatically rotate the token at 80% of
// its TTL. Defaults to 3600 (1 hour) if not set.
ExpirationSeconds *int64 `json:"expiration_seconds,omitempty"`
}

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

// AllowedAudiencesMap maps audience strings to their ownership details.
// Audiences in this map are restricted to configs from the listed org/repo owners.
// Audiences not in this map are unrestricted.
type AllowedAudiencesMap map[string]AllowedAudienceDetails

type AllowedAudienceDetails struct {
Audience string `yaml:"audience" json:"audience"`
Owners []AllowedAudienceOwners `yaml:"owners,omitempty" json:"owners,omitempty"`
}

type AllowedAudienceOwners struct {
Org string `yaml:"org" json:"org"`
Repos []string `yaml:"repos,omitempty" json:"repos,omitempty"`
}

const (
EphemeralClusterTestDoneSignalSecretName = "test-done-signal"
)
90 changes: 90 additions & 0 deletions pkg/api/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions pkg/load/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,31 @@ func ClusterClaimOwnersConfig(configPath string) (api.ClusterClaimOwnersMap, err
}
return clusterClaimOwnersMap, nil
}

// AllowedAudiencesConfig loads allowed audiences information from its config in the release repository.
// If the file does not exist, an empty map is returned.
func AllowedAudiencesConfig(configPath string) (api.AllowedAudiencesMap, error) {
configContents, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return make(api.AllowedAudiencesMap), nil
}
return nil, fmt.Errorf("failed to read allowed audiences config: %w", err)
}

var audiencesList []api.AllowedAudienceDetails
if err = yaml.UnmarshalStrict(configContents, &audiencesList); err != nil {
return nil, fmt.Errorf("failed to unmarshall allowed audiences config: %w", err)
}
allowedAudiencesMap := make(api.AllowedAudiencesMap, len(audiencesList))
for i, a := range audiencesList {
if a.Audience == "" {
return nil, fmt.Errorf("allowed audiences config entry %d: audience must not be empty", i)
}
if _, exists := allowedAudiencesMap[a.Audience]; exists {
return nil, fmt.Errorf("allowed audiences config: duplicate audience %q", a.Audience)
}
allowedAudiencesMap[a.Audience] = a
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return allowedAudiencesMap, nil
}
34 changes: 33 additions & 1 deletion pkg/steps/multi_stage/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,12 @@ func (s *multiStageTestStep) generatePods(
pod.Annotations[base_steps.AnnotationSaveContainerLogs] = "true"
pod.Labels[MultiStageTestLabel] = s.name
needsKubeConfig := isKubeconfigNeeded(&step, genPodOpts)
if needsKubeConfig {
if needsKubeConfig || len(step.ServiceAccountTokens) > 0 {
pod.Spec.ServiceAccountName = s.name
} else {
pod.Spec.ServiceAccountName = ""
}
if !needsKubeConfig {
no := false
pod.Spec.AutomountServiceAccountToken = &no
}
Expand Down Expand Up @@ -249,6 +251,7 @@ func (s *multiStageTestStep) generatePods(
if step.RunAsScript != nil && *step.RunAsScript {
addCommandScript(commandConfigMapForTest(s.name), pod)
}
addServiceAccountTokenVolumes(step.ServiceAccountTokens, pod)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if s.vpnConf != nil {
caps := coreapi.Capabilities{
Add: []coreapi.Capability{"NET_ADMIN"},
Expand Down Expand Up @@ -636,6 +639,35 @@ func addCommandScript(name string, pod *coreapi.Pod) {
})
}

func addServiceAccountTokenVolumes(tokens []api.ServiceAccountTokenVolume, pod *coreapi.Pod) {
for i, token := range tokens {
volumeName := fmt.Sprintf("sa-token-%d", i)
expSeconds := int64(3600)
if token.ExpirationSeconds != nil {
expSeconds = *token.ExpirationSeconds
}
pod.Spec.Volumes = append(pod.Spec.Volumes, coreapi.Volume{
Name: volumeName,
VolumeSource: coreapi.VolumeSource{
Projected: &coreapi.ProjectedVolumeSource{
Sources: []coreapi.VolumeProjection{{
ServiceAccountToken: &coreapi.ServiceAccountTokenProjection{
Audience: token.Audience,
ExpirationSeconds: &expSeconds,
Path: "token",
},
}},
},
},
})
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, coreapi.VolumeMount{
Name: volumeName,
MountPath: token.MountPath,
ReadOnly: true,
})
}
}

func addLeaseProxyScripts(pod *coreapi.Pod, c *coreapi.Container) {
pod.Spec.Volumes = append(pod.Spec.Volumes, coreapi.Volume{
Name: "lease-proxy",
Expand Down
43 changes: 43 additions & 0 deletions pkg/steps/multi_stage/gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,49 @@ func TestGeneratePods(t *testing.T) {
}},
},
},
{
name: "service account token projection",
config: &api.ReleaseBuildConfiguration{
Tests: []api.TestStepConfiguration{{
As: "test",
MultiStageTestConfigurationLiteral: &api.MultiStageTestConfigurationLiteral{
Test: []api.LiteralTestStep{{
As: "step0",
From: "src",
Commands: "command0",
ServiceAccountTokens: []api.ServiceAccountTokenVolume{{
Audience: "gcp-wif-audience",
MountPath: "/var/run/secrets/wif",
}, {
Audience: "vault",
MountPath: "/var/run/secrets/vault",
ExpirationSeconds: ptr.To(int64(7200)),
}},
}},
},
}},
},
},
{
name: "service account token projection with no_kubeconfig",
config: &api.ReleaseBuildConfiguration{
Tests: []api.TestStepConfiguration{{
As: "test",
MultiStageTestConfigurationLiteral: &api.MultiStageTestConfigurationLiteral{
Test: []api.LiteralTestStep{{
As: "step0",
From: "src",
Commands: "command0",
NoKubeconfig: ptr.To(true),
ServiceAccountTokens: []api.ServiceAccountTokenVolume{{
Audience: "gcp-wif-audience",
MountPath: "/var/run/secrets/wif",
}},
}},
},
}},
},
},
{
name: "lease proxy server available",
config: &api.ReleaseBuildConfiguration{
Expand Down
Loading