From 613baaf22913df3c1a00ba80143958ac56f63b99 Mon Sep 17 00:00:00 2001 From: Patrice Ferlet Date: Sun, 15 Mar 2026 08:55:24 +0100 Subject: [PATCH] feat(depends): add RBAC --- go.mod | 3 + internal/generator/deployment.go | 29 ++-- internal/generator/deployment_test.go | 196 ++++++++++++++++++++++++++ internal/generator/generator.go | 65 ++++++++- internal/generator/rbac.go | 73 ++++++++++ 5 files changed, 355 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index ef72875..6298f93 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/invopop/jsonschema v0.13.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.10.0 github.com/thediveo/netdb v1.1.2 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 @@ -19,6 +20,7 @@ require ( require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -35,6 +37,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/internal/generator/deployment.go b/internal/generator/deployment.go index e692226..a64d90e 100644 --- a/internal/generator/deployment.go +++ b/internal/generator/deployment.go @@ -33,15 +33,16 @@ type ConfigMapMount struct { // Deployment is a kubernetes Deployment. type Deployment struct { - *appsv1.Deployment `yaml:",inline"` - chart *HelmChart `yaml:"-"` - configMaps map[string]*ConfigMapMount `yaml:"-"` - volumeMap map[string]string `yaml:"-"` // keep map of fixed named to original volume name - service *types.ServiceConfig `yaml:"-"` - defaultTag string `yaml:"-"` - isMainApp bool `yaml:"-"` - exchangesVolumes map[string]*labelstructs.ExchangeVolume `yaml:"-"` - boundEnvVar []string `yaml:"-"` // environement to remove + *appsv1.Deployment `yaml:",inline"` + chart *HelmChart `yaml:"-"` + configMaps map[string]*ConfigMapMount `yaml:"-"` + volumeMap map[string]string `yaml:"-"` // keep map of fixed named to original volume name + service *types.ServiceConfig `yaml:"-"` + defaultTag string `yaml:"-"` + isMainApp bool `yaml:"-"` + exchangesVolumes map[string]*labelstructs.ExchangeVolume `yaml:"-"` + boundEnvVar []string `yaml:"-"` // environement to remove + needsServiceAccount bool `yaml:"-"` } // NewDeployment creates a new Deployment from a compose service. The appName is the name of the application taken from the project name. @@ -273,6 +274,7 @@ func (d *Deployment) DependsOn(to *Deployment, servicename string) error { return d.dependsOnLegacy(to, servicename) } + d.needsServiceAccount = true return d.dependsOnK8sAPI(to) } @@ -611,7 +613,7 @@ func (d *Deployment) Yaml() ([]byte, error) { } // manage serviceAccount, add condition to use the serviceAccount from values.yaml - if strings.Contains(line, "serviceAccountName:") { + if strings.Contains(line, "serviceAccountName:") && !d.needsServiceAccount { spaces = strings.Repeat(" ", utils.CountStartingSpaces(line)) pre := spaces + `{{- if ne .Values.` + serviceName + `.serviceAccount "" }}` post := spaces + "{{- end }}" @@ -647,6 +649,13 @@ func (d *Deployment) Yaml() ([]byte, error) { return []byte(strings.Join(content, "\n")), nil } +func (d *Deployment) SetServiceAccountName() { + if d.needsServiceAccount { + d.Spec.Template.Spec.ServiceAccountName = utils.TplName(d.service.Name, d.chart.Name) + } else { + } +} + func (d *Deployment) appendDirectoryToConfigMap(service types.ServiceConfig, appName string, volume types.ServiceVolumeConfig) { pathnme := utils.PathToName(volume.Source) if _, ok := d.configMaps[pathnme]; !ok { diff --git a/internal/generator/deployment_test.go b/internal/generator/deployment_test.go index 42ce86c..e7e92d8 100644 --- a/internal/generator/deployment_test.go +++ b/internal/generator/deployment_test.go @@ -9,8 +9,10 @@ import ( "katenary.io/internal/generator/labels" yaml3 "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" "sigs.k8s.io/yaml" ) @@ -643,3 +645,197 @@ services: t.Errorf("Expected command to be 'bar baz', got %s", strings.Join(command, " ")) } } + +func TestRestrictedRBACGeneration(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + depends_on: + - database + + database: + image: mariadb:10.5 + ports: + - 3306:3306 +` + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + rbacOutput := internalCompileTest(t, "-s", "templates/web/depends-on.rbac.yaml") + + docs := strings.Split(rbacOutput, "---\n") + + // Filter out empty documents and strip helm template comments + var filteredDocs []string + for _, doc := range docs { + if strings.TrimSpace(doc) != "" { + // Remove '# Source:' comment lines that helm template adds + lines := strings.Split(doc, "\n") + var contentLines []string + for _, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), "# Source:") { + contentLines = append(contentLines, line) + } + } + filteredDocs = append(filteredDocs, strings.Join(contentLines, "\n")) + } + } + + if len(filteredDocs) != 3 { + t.Fatalf("Expected 3 YAML documents in RBAC file, got %d (filtered from %d)", len(filteredDocs), len(docs)) + } + + var sa corev1.ServiceAccount + if err := yaml.Unmarshal([]byte(strings.TrimSpace(filteredDocs[0])), &sa); err != nil { + t.Errorf("Failed to unmarshal ServiceAccount: %v", err) + } + if sa.Kind != "ServiceAccount" { + t.Errorf("Expected Kind=ServiceAccount, got %s", sa.Kind) + } + if !strings.Contains(sa.Name, "web") { + t.Errorf("Expected ServiceAccount name to contain 'web', got %s", sa.Name) + } + + var role rbacv1.Role + if err := yaml.Unmarshal([]byte(strings.TrimSpace(filteredDocs[1])), &role); err != nil { + t.Errorf("Failed to unmarshal Role: %v", err) + } + if role.Kind != "Role" { + t.Errorf("Expected Kind=Role, got %s", role.Kind) + } + if len(role.Rules) != 1 { + t.Errorf("Expected 1 rule in Role, got %d", len(role.Rules)) + } + + rule := role.Rules[0] + if !contains(rule.APIGroups, "") { + t.Error("Expected APIGroup to include core API ('')") + } + if !contains(rule.Resources, "endpoints") { + t.Errorf("Expected Resource to include 'endpoints', got %v", rule.Resources) + } + + for _, res := range rule.Resources { + if res == "*" { + t.Error("Role should not have wildcard (*) resource permissions") + } + } + for _, verb := range rule.Verbs { + if verb == "*" { + t.Error("Role should not have wildcard (*) verb permissions") + } + } + + var rb rbacv1.RoleBinding + if err := yaml.Unmarshal([]byte(strings.TrimSpace(filteredDocs[2])), &rb); err != nil { + t.Errorf("Failed to unmarshal RoleBinding: %v", err) + } + if rb.Kind != "RoleBinding" { + t.Errorf("Expected Kind=RoleBinding, got %s", rb.Kind) + } + if len(rb.Subjects) != 1 { + t.Errorf("Expected 1 subject in RoleBinding, got %d", len(rb.Subjects)) + } + if rb.Subjects[0].Kind != "ServiceAccount" { + t.Errorf("Expected Subject Kind=ServiceAccount, got %s", rb.Subjects[0].Kind) + } + + // Helm template renders the name, so check if it contains "web" + if !strings.Contains(rb.RoleRef.Name, "web") { + t.Errorf("Expected RoleRef Name to contain 'web', got %s", rb.RoleRef.Name) + } + if rb.RoleRef.Kind != "Role" { + t.Errorf("Expected RoleRef Kind=Role, got %s", rb.RoleRef.Kind) + } +} + +func TestDeploymentReferencesServiceAccount(t *testing.T) { + composeFile := ` +services: + web: + image: nginx:1.29 + ports: + - 80:80 + depends_on: + - database + + database: + image: mariadb:10.5 + ports: + - 3306:3306 +` + tmpDir := setup(composeFile) + defer teardown(tmpDir) + + currentDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(currentDir) + + output := internalCompileTest(t, "-s", "templates/web/deployment.yaml") + + var dt appsv1.Deployment + if err := yaml.Unmarshal([]byte(output), &dt); err != nil { + t.Errorf("Failed to unmarshal Deployment: %v", err) + } + + serviceAccountName := dt.Spec.Template.Spec.ServiceAccountName + if !strings.Contains(serviceAccountName, "web") { + t.Errorf("Expected ServiceAccountName to contain 'web', got %s", serviceAccountName) + } + + if len(dt.Spec.Template.Spec.InitContainers) == 0 { + t.Fatal("Expected at least one init container for depends_on") + } + + initContainer := dt.Spec.Template.Spec.InitContainers[0] + if initContainer.Name != "wait-for-database" { + t.Errorf("Expected init container name 'wait-for-database', got %s", initContainer.Name) + } + + fullCommand := strings.Join(initContainer.Command, " ") + if !strings.Contains(fullCommand, "wget") { + t.Error("Expected init container to use wget for K8s API calls") + } + if !strings.Contains(fullCommand, "/api/v1/namespaces/") { + t.Error("Expected init container to call /api/v1/namespaces/ endpoint") + } + if !strings.Contains(fullCommand, "/endpoints/") { + t.Error("Expected init container to access /endpoints/ resource") + } + + hasNamespace := false + for _, env := range initContainer.Env { + if env.Name == "NAMESPACE" && env.ValueFrom != nil && env.ValueFrom.FieldRef != nil { + if env.ValueFrom.FieldRef.FieldPath == "metadata.namespace" { + hasNamespace = true + break + } + } + } + if !hasNamespace { + t.Error("Expected NAMESPACE env var with metadata.namespace fieldRef") + } + + _, err := os.Stat("./chart/templates/web/depends-on.rbac.yaml") + if os.IsNotExist(err) { + t.Error("RBAC file depends-on.rbac.yaml should exist for service using depends_on with K8s API") + } else if err != nil { + t.Errorf("Unexpected error checking RBAC file: %v", err) + } +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index daba5f5..0951b30 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -22,7 +22,7 @@ import ( // The Generate function will create the HelmChart object this way: // // - Detect the service port name or leave the port number if not found. -// - Create a deployment for each service that are not ingnore. +// - Create a deployment for each service that are not ingore. // - Create a service and ingresses for each service that has ports and/or declared ingresses. // - Create a PVC or Configmap volumes for each volume. // - Create init containers for each service which has dependencies to other services. @@ -134,6 +134,12 @@ func Generate(project *types.Project) (*HelmChart, error) { } } } + + // set ServiceAccountName for deployments that need it + for _, d := range deployments { + d.SetServiceAccountName() + } + for _, name := range drops { delete(deployments, name) } @@ -142,6 +148,11 @@ func Generate(project *types.Project) (*HelmChart, error) { chart.setEnvironmentValuesFrom(s, deployments) } + // generate RBAC resources for services that need K8s API access (non-legacy depends_on) + if err := chart.generateRBAC(deployments); err != nil { + logger.Fatalf("error generating RBAC: %s", err) + } + // generate configmaps with environment variables if err := chart.generateConfigMapsAndSecrets(project); err != nil { logger.Fatalf("error generating configmaps and secrets: %s", err) @@ -440,6 +451,58 @@ func samePodVolume(service types.ServiceConfig, v types.ServiceVolumeConfig, dep return false } +// generateRBAC creates RBAC resources (ServiceAccount, Role, RoleBinding) for services that need K8s API access. +// A service needs RBAC if it has non-legacy depends_on relationships. +func (chart *HelmChart) generateRBAC(deployments map[string]*Deployment) error { + serviceMap := make(map[string]bool) + + for _, d := range deployments { + if !d.needsServiceAccount { + continue + } + + sa := NewServiceAccount(*d.service, chart.Name) + role := NewRestrictedRole(*d.service, chart.Name) + rb := NewRestrictedRoleBinding(*d.service, chart.Name) + + var buf bytes.Buffer + + saYaml, err := yaml.Marshal(sa.ServiceAccount) + if err != nil { + return fmt.Errorf("error marshaling ServiceAccount for %s: %w", d.service.Name, err) + } + buf.Write(saYaml) + buf.WriteString("---\n") + + roleYaml, err := yaml.Marshal(role.Role) + if err != nil { + return fmt.Errorf("error marshaling Role for %s: %w", d.service.Name, err) + } + buf.Write(roleYaml) + buf.WriteString("---\n") + + rbYaml, err := yaml.Marshal(rb.RoleBinding) + if err != nil { + return fmt.Errorf("error marshaling RoleBinding for %s: %w", d.service.Name, err) + } + buf.Write(rbYaml) + + filename := d.service.Name + "/depends-on.rbac.yaml" + chart.Templates[filename] = &ChartTemplate{ + Content: buf.Bytes(), + Servicename: d.service.Name, + } + + serviceMap[d.service.Name] = true + } + + for svcName := range serviceMap { + logger.Log(logger.IconPackage, "Creating RBAC", svcName) + } + + return nil +} + func fixContainerNames(project *types.Project) { // fix container names to be unique for i, service := range project.Services { diff --git a/internal/generator/rbac.go b/internal/generator/rbac.go index b888ec0..6c15abd 100644 --- a/internal/generator/rbac.go +++ b/internal/generator/rbac.go @@ -128,6 +128,79 @@ func (r *Role) Yaml() ([]byte, error) { } } +// NewServiceAccount creates a new ServiceAccount from a compose service. +func NewServiceAccount(service types.ServiceConfig, appName string) *ServiceAccount { + return &ServiceAccount{ + ServiceAccount: &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + }, + service: &service, + } +} + +// NewRestrictedRole creates a Role with minimal permissions for init containers. +func NewRestrictedRole(service types.ServiceConfig, appName string) *Role { + return &Role{ + Role: &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: "rbac.authorization.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"endpoints"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + service: &service, + } +} + +// NewRestrictedRoleBinding creates a RoleBinding that binds the restricted role to the ServiceAccount. +func NewRestrictedRoleBinding(service types.ServiceConfig, appName string) *RoleBinding { + return &RoleBinding{ + RoleBinding: &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: "rbac.authorization.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: utils.TplName(service.Name, appName), + Labels: GetLabels(service.Name, appName), + Annotations: Annotations, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: utils.TplName(service.Name, appName), + Namespace: "{{ .Release.Namespace }}", + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: utils.TplName(service.Name, appName), + APIGroup: "rbac.authorization.k8s.io", + }, + }, + service: &service, + } +} + // ServiceAccount is a kubernetes ServiceAccount. type ServiceAccount struct { *corev1.ServiceAccount