diff --git a/api/v1/annotations.go b/api/v1/annotations.go new file mode 100644 index 00000000..0f06dd7a --- /dev/null +++ b/api/v1/annotations.go @@ -0,0 +1,7 @@ +package v1 + +const ( + // GitLabCITemplateAnnotation is an annotation on a Codebase CR that specifies the ConfigMap + // name to use as the GitLab CI template. When absent, the operator falls back to "gitlab-ci-default". + GitLabCITemplateAnnotation = "app.edp.epam.com/gitlab-ci-template" +) diff --git a/api/v1/labels.go b/api/v1/labels.go index 47230a49..948876d4 100644 --- a/api/v1/labels.go +++ b/api/v1/labels.go @@ -20,4 +20,9 @@ const ( // We can't use the branch name directly as a label value because it can contain special characters. // XXH64 is used to generate the hash. BranchHashLabel = "app.edp.epam.com/branch-hash" + + // CITemplateLabel is a label on ConfigMaps that marks them as CI templates. + // The portal uses this label to discover available templates for a dropdown. + // Values: "gitlab" (extensible to other CI systems in the future). + CITemplateLabel = "app.edp.epam.com/ci-template" ) diff --git a/controllers/codebase/service/chain/put_gitlab_ci_config.go b/controllers/codebase/service/chain/put_gitlab_ci_config.go index ac3d03dc..573d4dc7 100644 --- a/controllers/codebase/service/chain/put_gitlab_ci_config.go +++ b/controllers/codebase/service/chain/put_gitlab_ci_config.go @@ -76,6 +76,11 @@ func (h *PutGitLabCIConfig) ServeRequest(ctx context.Context, codebase *codebase return nil } +// gitlabCIAlreadyExists is a fast-path optimization that checks whether +// .gitlab-ci.yml already exists in the local working directory, avoiding the +// expensive clone+checkout round-trip when the file was written by a prior +// reconciliation run. The manager's InjectGitLabCIConfig has its own os.Stat +// guard as a safety invariant, so this check is purely an optimization. func (h *PutGitLabCIConfig) gitlabCIAlreadyExists(codebase *codebaseApi.Codebase) bool { wd := util.GetWorkDir(codebase.Name, codebase.Namespace) gitlabCIPath := filepath.Join(wd, gitlabci.GitLabCIFileName) diff --git a/controllers/codebase/service/chain/put_gitlab_ci_config_test.go b/controllers/codebase/service/chain/put_gitlab_ci_config_test.go index 8676c72a..90488337 100644 --- a/controllers/codebase/service/chain/put_gitlab_ci_config_test.go +++ b/controllers/codebase/service/chain/put_gitlab_ci_config_test.go @@ -24,6 +24,9 @@ import ( "github.com/epam/edp-codebase-operator/v2/pkg/util" ) +const testGitLabCIConfigData = "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n " + + "- component: $CI_SERVER_FQDN/kuberocketci/ci-java17-mvn/build@0.1.1" + func TestPutGitLabCIConfig_ServeRequest(t *testing.T) { const defaultNs = "default" @@ -134,12 +137,70 @@ func TestPutGitLabCIConfig_ServeRequest(t *testing.T) { gitlabGitServerSecret, &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-java-maven", + Name: gitlabci.GitLabCIDefaultTemplate, + Namespace: defaultNs, + }, + Data: map[string]string{ + ".gitlab-ci.yml": testGitLabCIConfigData, + }, + }, + }, + gitClient: func(t *testing.T) gitproviderv2.Git { + mock := gitmocks.NewMockGit(t) + + mock.On("Clone", testify.Anything, testify.Anything, testify.Anything). + Return(nil) + mock.On("GetCurrentBranchName", testify.Anything, testify.Anything). + Return("master", nil) + mock.On("Commit", testify.Anything, testify.Anything, "Add GitLab CI configuration"). + Return(nil) + mock.On("Push", testify.Anything, testify.Anything, gitproviderv2.RefSpecPushAllBranches). + Return(nil) + + return mock + }, + setup: func(t *testing.T, wd string) { + require.NoError(t, os.MkdirAll(wd, 0755)) + }, + wantErr: require.NoError, + wantStatus: func(t *testing.T, codebase *codebaseApi.Codebase) { + require.Equal(t, util.ProjectGitLabCIPushedStatus, codebase.Status.Git) + }, + }, + { + name: "successfully inject GitLab CI config with annotation", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "java-app", + Namespace: defaultNs, + Annotations: map[string]string{ + codebaseApi.GitLabCITemplateAnnotation: "my-custom-template", + }, + }, + Spec: codebaseApi.CodebaseSpec{ + Strategy: codebaseApi.Clone, + CiTool: util.CIGitLab, + GitServer: gitlabGitServer.Name, + GitUrlPath: "/owner/java-repo", + Repository: &codebaseApi.Repository{Url: "https://gitlab.com/owner/java-repo.git"}, + DefaultBranch: "master", + Lang: "java", + BuildTool: "maven", + }, + Status: codebaseApi.CodebaseStatus{ + Git: util.ProjectPushedStatus, + }, + }, + objects: []client.Object{ + gitlabGitServer, + gitlabGitServerSecret, + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-custom-template", Namespace: defaultNs, }, Data: map[string]string{ - ".gitlab-ci.yml": "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n " + - "- component: $CI_SERVER_FQDN/kuberocketci/ci-java17-mvn/build@0.1.1", + ".gitlab-ci.yml": "custom: {{.CodebaseName}}", }, }, }, @@ -398,12 +459,11 @@ func TestPutGitLabCIConfig_ServeRequest(t *testing.T) { gitlabGitServerSecret, &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-java-maven", + Name: gitlabci.GitLabCIDefaultTemplate, Namespace: defaultNs, }, Data: map[string]string{ - ".gitlab-ci.yml": "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n " + - "- component: $CI_SERVER_FQDN/kuberocketci/ci-java17-mvn/build@0.1.1", + ".gitlab-ci.yml": testGitLabCIConfigData, }, }, }, @@ -452,12 +512,11 @@ func TestPutGitLabCIConfig_ServeRequest(t *testing.T) { gitlabGitServerSecret, &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-java-maven", + Name: gitlabci.GitLabCIDefaultTemplate, Namespace: defaultNs, }, Data: map[string]string{ - ".gitlab-ci.yml": "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n " + - "- component: $CI_SERVER_FQDN/kuberocketci/ci-java17-mvn/build@0.1.1", + ".gitlab-ci.yml": testGitLabCIConfigData, }, }, }, diff --git a/deploy-templates/templates/gitlab/gitlab-ci-default.yaml b/deploy-templates/templates/gitlab/gitlab-ci-default.yaml index 3c33bba1..8e607bab 100644 --- a/deploy-templates/templates/gitlab/gitlab-ci-default.yaml +++ b/deploy-templates/templates/gitlab/gitlab-ci-default.yaml @@ -3,69 +3,104 @@ kind: ConfigMap metadata: name: gitlab-ci-default labels: + app.edp.epam.com/ci-template: gitlab {{- include "codebase-operator.labels" . | nindent 4 }} data: + # Read by krci-portal for template selection UI; not consumed by the operator. + description: | + Default KubeRocketCI GitLab CI orchestration template. + Uses the generic ci-template component library (alpine:latest). + For tech-specific pipelines, create a custom ConfigMap and select it + via the app.edp.epam.com/gitlab-ci-template annotation on the Codebase CR. .gitlab-ci.yml: | # ============================================================================ # GITLAB CI/CD COMPONENT LIBRARY - ORCHESTRATION FILE # ============================================================================ - # This file orchestrates the conditional inclusion of review and build pipelines - # based on the pipeline trigger source (merge request vs protected branch) + # This file orchestrates the conditional inclusion of review and build + # pipelines based on the pipeline trigger source. # - # AUTO-GENERATED: This configuration was automatically injected by KubeRocketCI + # AUTO-GENERATED by KubeRocketCI codebase-operator. # Project: {{.CodebaseName}} + # + # HOW IT WORKS: + # This file uses GitLab CI/CD Components — reusable pipeline building + # blocks hosted as GitLab projects. Each component exposes "review" + # and "build" entry points with parameterized inputs. + # Docs: https://docs.gitlab.com/ci/components/ + # + # TEMPLATE SELECTION: + # The operator resolves this template from ConfigMaps in the namespace: + # 1. If the Codebase CR has the annotation + # app.edp.epam.com/gitlab-ci-template=, that ConfigMap is used. + # 2. Otherwise, gitlab-ci-default (this file) is used as fallback. + # To use a tech-specific template, create a ConfigMap and set the + # annotation on the Codebase CR to its name. + # + # AVAILABLE COMPONENT LIBRARIES (https://gitlab.com/kuberocketci): + # ci-template — Base/generic (alpine:latest) + # ci-golang — Go (golang:1.24-bookworm) + # ci-java17-mvn — Java 17 Maven (maven:3.9-eclipse-temurin-17) + # ci-java17-gradle — Java 17 Gradle (gradle:8-jdk17) + # ci-nodejs-npm — Node.js / npm (node:20-alpine) + # ci-python-uv — Python / uv (python:3.13-slim) + # + # PIPELINE ARCHITECTURE: + # All component libraries follow a mandatory 7-stage flow: + # prepare → test → build → verify → package → publish → release + # + # Review pipeline (merge requests): prepare → test → build → verify + # Build pipeline (protected branch): prepare → test → build → package → publish + # Release (semantic tag): release stage only + # + # Quality gate chain: test → build → sonar → docker + # Parallel jobs: lint, type-check, helm-docs, helm-lint + # ============================================================================ # ============================================================================ - # WORKFLOW RULES - STANDARD ACROSS ALL TECH STACKS + # WORKFLOW RULES # ============================================================================ - # Controls when pipelines are triggered + # Controls when pipelines are triggered. Standard across all tech stacks. + # Docs: https://docs.gitlab.com/ci/yaml/#workflowrules workflow: rules: - # Run pipeline for merge request events - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - # Run pipeline for pushes to protected branches (main, master, etc.) - if: $CI_COMMIT_REF_PROTECTED == "true" - - # Run pipeline for semantic version tags (e.g., 1.0.0, 2.1.3) - if: $CI_COMMIT_TAG =~ /^\d+\.\d+\.\d+$/ # ============================================================================ # GLOBAL VARIABLES - CUSTOMIZE FOR YOUR PROJECT # ============================================================================ variables: - # Project identification CODEBASE_NAME: "{{.CodebaseName}}" - # Container image for build/test jobs - Generic Alpine Linux - # CUSTOMIZE: Update to your tech stack's container image - # Examples: - # Go: golang:1.24-bookworm - # Python: python:3.13-slim - # Node.js: node:20-alpine - # Java: maven:3.9-eclipse-temurin-17 - # .NET: mcr.microsoft.com/dotnet/sdk:8.0 + # Container image for build/test jobs. + # This default uses the generic ci-template component library. + # Replace with a tech-specific image when switching to a + # tech-specific component library (see AVAILABLE COMPONENT LIBRARIES above). CONTAINER_IMAGE: "alpine:latest" - # Container registry for Docker images - # CUSTOMIZE: Set your registry URL with tenant/organization + # Container registry for publishing Docker images. IMAGE_REGISTRY: "docker.io/myorg" - # SonarQube organization (optional) - # When set, project key becomes: ${SONAR_ORG}_${CODEBASE_NAME} - # When empty, project key equals: ${CODEBASE_NAME} + # SonarQube organization (optional). + # Project key: ${SONAR_ORG}_${CODEBASE_NAME} (or just ${CODEBASE_NAME}). SONAR_ORG: "myorg" - # Helm chart directory + # Helm chart directory. CHART_DIR: "deploy-templates" # ============================================================================ - # COMPONENT INCLUSION - CONDITIONAL PIPELINE EXECUTION + # COMPONENT INCLUSION # ============================================================================ + # Components are included conditionally based on the pipeline trigger. + # Replace "kuberocketci/ci-template" with a tech-specific library + # and update CONTAINER_IMAGE accordingly. For example, for Go: + # component: $CI_SERVER_FQDN/kuberocketci/ci-golang/review@0.1.0 + # go_image: golang:1.24-bookworm (instead of container_image) + # + # Docs: https://docs.gitlab.com/ci/components/#use-a-component include: - # REVIEW COMPONENT - Merge Request Validation Pipeline - # Runs: test → build → verify (NO publishing) - # Stages: prepare, test, build, verify + # REVIEW — merge request validation (no publishing) - component: $CI_SERVER_FQDN/kuberocketci/ci-template/review@0.1.0 inputs: stage_prepare: prepare @@ -76,12 +111,9 @@ data: container_image: ${CONTAINER_IMAGE} chart_dir: ${CHART_DIR} rules: - # Only run for merge request events - if: $CI_PIPELINE_SOURCE == "merge_request_event" - # BUILD COMPONENT - Main Branch Build and Publish Pipeline - # Runs: test → build → package → publish - # Stages: prepare, test, build, package, publish + # BUILD — protected-branch build, package, and publish - component: $CI_SERVER_FQDN/kuberocketci/ci-template/build@0.1.0 inputs: stage_prepare: prepare @@ -94,29 +126,25 @@ data: image_registry: ${IMAGE_REGISTRY} chart_dir: ${CHART_DIR} rules: - # Run for main/master branch OR any protected branch - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_COMMIT_REF_PROTECTED == "true" # ============================================================================ - # STANDARDIZED STAGE FLOW - MANDATORY 7-STAGE ARCHITECTURE + # STAGES # ============================================================================ - # All component libraries MUST follow this stage sequence stages: [prepare, test, build, verify, package, publish, release] - # ============================================================================ - # VISIBLE JOB (NEVER RUN) - FOR GITLAB UI VISIBILITY - # ============================================================================ - # This job makes the pipeline visible in GitLab UI but never actually runs + # GitLab requires at least one visible job definition in the orchestration + # file for lint validation. This job never runs. visible-job-lint-fix: stage: .pre rules: [when: never] - script: [echo "This is a visible job (but never run)"] + script: [echo "placeholder"] # ============================================================================ - # RELEASE JOB - SEMANTIC VERSION TAG RELEASE + # RELEASE # ============================================================================ - # Creates a GitLab release when a semantic version tag is pushed - # Requires: Git tag matching pattern ^\d+\.\d+\.\d+$ (e.g., 1.0.0, 2.1.3) + # Creates a GitLab release when a semantic version tag is pushed. + # Docs: https://docs.gitlab.com/ee/user/project/releases/release_cli.html create-release: stage: release image: registry.gitlab.com/gitlab-org/release-cli:latest @@ -125,38 +153,49 @@ data: script: echo "Creating release $CI_COMMIT_TAG" release: tag_name: $CI_COMMIT_TAG - description: "Release $CI_COMMIT_TAG of components repository $CI_PROJECT_PATH" + description: "Release $CI_COMMIT_TAG of $CI_PROJECT_PATH" # ============================================================================ - # REQUIRED CI/CD VARIABLES - CONFIGURE IN GITLAB SETTINGS + # REQUIRED CI/CD VARIABLES # ============================================================================ - # Navigate to: Settings > CI/CD > Variables - # Add the following variables as needed: + # Configure in GitLab: Settings > CI/CD > Variables + # Docs: https://docs.gitlab.com/ci/variables/#define-a-cicd-variable-in-the-ui # - # SONARQUBE INTEGRATION: - # - SONAR_HOST_URL: SonarQube/SonarCloud server URL - # - SONAR_TOKEN: SonarQube authentication token (protected) + # SONARQUBE: + # SONAR_HOST_URL — SonarQube/SonarCloud server URL + # SONAR_TOKEN — Authentication token (protected, masked) # # DOCKER REGISTRY: - # - DOCKERHUB_USERNAME: Docker Hub username - # - DOCKERHUB_PASSWORD: Docker Hub password or access token (protected, masked) + # DOCKERHUB_USERNAME — Registry username + # DOCKERHUB_PASSWORD — Registry password or token (protected, masked) # # GIT TAGGING: - # - GITLAB_ACCESS_TOKEN: GitLab personal/project access token (protected, masked) + # GITLAB_ACCESS_TOKEN — Token with write_repository scope (protected, masked) # - # GIT USER IDENTITY (optional): - # - GITLAB_USER_EMAIL: Email for Git commits/tags (default: ci-bot@example.com) - # - GITLAB_USER_NAME: Name for Git commits/tags (default: GitLab CI) + # OPTIONAL: + # GITLAB_USER_EMAIL — Email for CI commits (default: ci-bot@example.com) + # GITLAB_USER_NAME — Name for CI commits (default: GitLab CI) + # + # TECH-SPECIFIC (add as needed): + # NPM_TOKEN — npm registry token (Node.js) + # MAVEN_REPOSITORY_URL — Maven repo URL (Java Maven) + # GRADLE_REPOSITORY_URL — Gradle repo URL (Java Gradle) + # PYPI_TOKEN — PyPI token (Python) # ============================================================================ # CUSTOMIZATION GUIDE # ============================================================================ - # 1. Update CONTAINER_IMAGE to your tech stack's base image - # 2. Set IMAGE_REGISTRY to your container registry - # 3. Configure SONAR_ORG for SonarQube organization - # 4. Replace ci-template component references with tech-specific ones: - # - ci-golang for Go projects - # - ci-java17-mvn for Java Maven projects - # - ci-python for Python projects - # - ci-nodejs for Node.js projects - # 5. Add tech-specific CI/CD variables as needed + # 1. Replace ci-template component references with a tech-specific library: + # + # Component Library Container Image Docs + # ───────────────────────────────────────────────────────────────────── + # kuberocketci/ci-golang golang:1.24-bookworm https://gitlab.com/kuberocketci/ci-golang + # kuberocketci/ci-java17-mvn maven:3.9-eclipse-temurin-17 https://gitlab.com/kuberocketci/ci-java17-mvn + # kuberocketci/ci-java17-gradle gradle:8-jdk17 https://gitlab.com/kuberocketci/ci-java17-gradle + # kuberocketci/ci-nodejs-npm node:20-alpine https://gitlab.com/kuberocketci/ci-nodejs-npm + # kuberocketci/ci-python-uv python:3.13-slim https://gitlab.com/kuberocketci/ci-python-uv + # + # 2. Update CONTAINER_IMAGE (or the tech-specific input like go_image) + # 3. Set IMAGE_REGISTRY to your container registry + # 4. Configure SONAR_ORG for your SonarQube organization + # 5. Add required CI/CD variables in GitLab project settings diff --git a/pkg/gitlab/manager.go b/pkg/gitlab/manager.go index 4b450dd6..17b1ad39 100644 --- a/pkg/gitlab/manager.go +++ b/pkg/gitlab/manager.go @@ -49,30 +49,30 @@ func (m *manager) InjectGitLabCIConfig(ctx context.Context, codebase *codebaseAp return fmt.Errorf("failed to get GitLab CI template: %w", err) } - // Simple variable substitution - only codebase name + // Variable substitution — only CodebaseName is replaced; other placeholders stay literal. content := strings.ReplaceAll(template, "{{.CodebaseName}}", codebase.Name) // Write file - return os.WriteFile(gitlabCIPath, []byte(content), 0644) + if err := os.WriteFile(gitlabCIPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", GitLabCIFileName, err) + } + + return nil } -// getGitLabCITemplate retrieves GitLab CI template with fallback hierarchy. +// getGitLabCITemplate retrieves the GitLab CI template ConfigMap. +// If the Codebase has the GitLabCITemplateAnnotation, that ConfigMap is used (hard error if missing). +// Otherwise falls back to the "gitlab-ci-default" ConfigMap. func (m *manager) getGitLabCITemplate(ctx context.Context, codebase *codebaseApi.Codebase) (string, error) { - lang := strings.ToLower(codebase.Spec.Lang) - buildTool := strings.ToLower(codebase.Spec.BuildTool) - - // Try specific language-buildtool combination first - configMapName := fmt.Sprintf("gitlab-ci-%s-%s", lang, buildTool) + configMapName := GitLabCIDefaultTemplate - template, err := m.getTemplateFromConfigMap(ctx, configMapName, codebase.Namespace) - if err == nil { - return template, nil + if ann := codebase.GetAnnotations()[codebaseApi.GitLabCITemplateAnnotation]; ann != "" { + configMapName = ann } - // Final fallback to default template - template, err = m.getTemplateFromConfigMap(ctx, GitLabCIDefaultTemplate, codebase.Namespace) + template, err := m.getTemplateFromConfigMap(ctx, configMapName, codebase.Namespace) if err != nil { - return "", fmt.Errorf("no GitLab CI template found for %s-%s, lang-only, or default", lang, buildTool) + return "", fmt.Errorf("failed to get GitLab CI template from ConfigMap %q: %w", configMapName, err) } return template, nil diff --git a/pkg/gitlab/manager_test.go b/pkg/gitlab/manager_test.go index 6d99c1b9..d5d9b030 100644 --- a/pkg/gitlab/manager_test.go +++ b/pkg/gitlab/manager_test.go @@ -21,16 +21,96 @@ func TestManager_InjectGitLabCIConfig(t *testing.T) { t.Parallel() tests := []struct { - name string - codebase *codebaseApi.Codebase - configMaps []client.Object - expectedInFile string + name string + codebase *codebaseApi.Codebase + configMaps []client.Object + expectedContains []string + wantErr require.ErrorAssertionFunc }{ { - name: "java maven project with specific ConfigMap", + name: "annotation selects custom ConfigMap", codebase: &codebaseApi.Codebase{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-java-app", + Name: "my-go-app", + Namespace: "test-namespace", + Annotations: map[string]string{ + codebaseApi.GitLabCITemplateAnnotation: "my-go-template", + }, + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "Go", + BuildTool: "Go", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-go-template", + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "custom: {{.CodebaseName}}", + }, + }, + }, + expectedContains: []string{ + "custom: my-go-app", + }, + wantErr: require.NoError, + }, + { + name: "no annotation falls back to default", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-namespace", + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "python", + BuildTool: "pip", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: GitLabCIDefaultTemplate, + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "default: {{.CodebaseName}}", + }, + }, + }, + expectedContains: []string{ + "default: test-app", + }, + wantErr: require.NoError, + }, + { + name: "annotation pointing to missing ConfigMap returns error", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-namespace", + Annotations: map[string]string{ + codebaseApi.GitLabCITemplateAnnotation: "nonexistent", + }, + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "java", + BuildTool: "maven", + }, + }, + wantErr: func(t require.TestingT, err error, _ ...interface{}) { + require.Error(t, err) + require.Contains(t, err.Error(), `ConfigMap "nonexistent"`) + }, + }, + { + name: "ConfigMap exists but .gitlab-ci.yml key is absent", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", Namespace: "test-namespace", }, Spec: codebaseApi.CodebaseSpec{ @@ -41,22 +121,24 @@ func TestManager_InjectGitLabCIConfig(t *testing.T) { configMaps: []client.Object{ &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-java-maven", + Name: GitLabCIDefaultTemplate, Namespace: "test-namespace", }, Data: map[string]string{ - ".gitlab-ci.yml": "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n" + - " - component: $CI_SERVER_FQDN/kuberocketci/ci-java17-mvn/build@0.1.1", + "some-other-key": "irrelevant", }, }, }, - expectedInFile: "CODEBASE_NAME: \"test-java-app\"", + wantErr: func(t require.TestingT, err error, _ ...interface{}) { + require.Error(t, err) + require.Contains(t, err.Error(), "no .gitlab-ci.yml template found in ConfigMap") + }, }, { - name: "go project with specific template", + name: "ConfigMap exists but .gitlab-ci.yml value is empty", codebase: &codebaseApi.Codebase{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-go-app", + Name: "test-app", Namespace: "test-namespace", }, Spec: codebaseApi.CodebaseSpec{ @@ -67,16 +149,136 @@ func TestManager_InjectGitLabCIConfig(t *testing.T) { configMaps: []client.Object{ &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-go-go", + Name: GitLabCIDefaultTemplate, Namespace: "test-namespace", }, Data: map[string]string{ - ".gitlab-ci.yml": "variables:\n CODEBASE_NAME: \"{{.CodebaseName}}\"\ninclude:\n" + - " - component: $CI_SERVER_FQDN/kuberocketci/ci-golang/build@0.1.1", + ".gitlab-ci.yml": "", }, }, }, - expectedInFile: "CODEBASE_NAME: \"test-go-app\"", + wantErr: func(t require.TestingT, err error, _ ...interface{}) { + require.Error(t, err) + require.Contains(t, err.Error(), "no .gitlab-ci.yml template found in ConfigMap") + }, + }, + { + name: "empty annotation value falls back to default", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-namespace", + Annotations: map[string]string{ + codebaseApi.GitLabCITemplateAnnotation: "", + }, + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "python", + BuildTool: "pip", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: GitLabCIDefaultTemplate, + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "fallback: {{.CodebaseName}}", + }, + }, + }, + expectedContains: []string{ + "fallback: test-app", + }, + wantErr: require.NoError, + }, + { + name: "nil annotations map falls back to default", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-namespace", + Annotations: nil, + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "java", + BuildTool: "gradle", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: GitLabCIDefaultTemplate, + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "nil-ann: {{.CodebaseName}}", + }, + }, + }, + expectedContains: []string{ + "nil-ann: test-app", + }, + wantErr: require.NoError, + }, + { + name: "static template with no placeholders", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: "test-namespace", + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "go", + BuildTool: "go", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: GitLabCIDefaultTemplate, + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "stages: [build, test]", + }, + }, + }, + expectedContains: []string{ + "stages: [build, test]", + }, + wantErr: require.NoError, + }, + { + name: "only CodebaseName is substituted", + codebase: &codebaseApi.Codebase{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-app", + Namespace: "test-namespace", + }, + Spec: codebaseApi.CodebaseSpec{ + Lang: "Java", + BuildTool: "Maven", + }, + }, + configMaps: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: GitLabCIDefaultTemplate, + Namespace: "test-namespace", + }, + Data: map[string]string{ + ".gitlab-ci.yml": "name: {{.CodebaseName}} lang: {{.Lang}} build: {{.BuildTool}}", + }, + }, + }, + expectedContains: []string{ + "name: my-app", + "lang: {{.Lang}}", + "build: {{.BuildTool}}", + }, + wantErr: require.NoError, }, } @@ -84,32 +286,27 @@ func TestManager_InjectGitLabCIConfig(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Create temporary directory - tmpDir, err := os.MkdirTemp("", "gitlab-ci-test") - require.NoError(t, err) - - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() - // Create fake Kubernetes client with ConfigMaps scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.configMaps...).Build() - manager := NewManager(fakeClient) - ctx := context.Background() + mgr := NewManager(fakeClient) - // Test injection - err = manager.InjectGitLabCIConfig(ctx, tt.codebase, tmpDir) - require.NoError(t, err) + err := mgr.InjectGitLabCIConfig(context.Background(), tt.codebase, tmpDir) + tt.wantErr(t, err) + + if err != nil { + return + } - // Verify file was created - gitlabCIPath := filepath.Join(tmpDir, GitLabCIFileName) - content, err := os.ReadFile(gitlabCIPath) + content, err := os.ReadFile(filepath.Join(tmpDir, GitLabCIFileName)) require.NoError(t, err) - // Verify content contains expected substitutions - contentStr := string(content) - assert.Contains(t, contentStr, tt.expectedInFile) + for _, expected := range tt.expectedContains { + assert.Contains(t, string(content), expected) + } }) } } @@ -117,17 +314,12 @@ func TestManager_InjectGitLabCIConfig(t *testing.T) { func TestManager_InjectGitLabCIConfig_SkipsIfExists(t *testing.T) { t.Parallel() - // Create temporary directory - tmpDir, err := os.MkdirTemp("", "gitlab-ci-test") - require.NoError(t, err) - - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Create existing .gitlab-ci.yml gitlabCIPath := filepath.Join(tmpDir, GitLabCIFileName) existingContent := "existing content" - err = os.WriteFile(gitlabCIPath, []byte(existingContent), 0644) - require.NoError(t, err) + require.NoError(t, os.WriteFile(gitlabCIPath, []byte(existingContent), 0644)) codebase := &codebaseApi.Codebase{ ObjectMeta: metav1.ObjectMeta{ @@ -140,74 +332,16 @@ func TestManager_InjectGitLabCIConfig_SkipsIfExists(t *testing.T) { }, } - // Create fake client (ConfigMaps not needed for this test) scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - manager := NewManager(fakeClient) - ctx := context.Background() + mgr := NewManager(fakeClient) - // Test injection - err = manager.InjectGitLabCIConfig(ctx, codebase, tmpDir) + err := mgr.InjectGitLabCIConfig(context.Background(), codebase, tmpDir) require.NoError(t, err) - // Verify file was not overwritten content, err := os.ReadFile(gitlabCIPath) require.NoError(t, err) assert.Equal(t, existingContent, string(content)) } - -func TestManager_ConfigMapFallbackHierarchy(t *testing.T) { - t.Parallel() - - codebase := &codebaseApi.Codebase{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-app", - Namespace: "test-namespace", - }, - Spec: codebaseApi.CodebaseSpec{ - Lang: "python", - BuildTool: "pip", - }, - } - - // Create ConfigMaps: only default (no specific template) - configMaps := []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gitlab-ci-default", - Namespace: "test-namespace", - }, - Data: map[string]string{ - ".gitlab-ci.yml": "default-fallback: {{.CodebaseName}}", - }, - }, - } - - // Create fake client - scheme := runtime.NewScheme() - require.NoError(t, corev1.AddToScheme(scheme)) - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(configMaps...).Build() - - // Create temporary directory - tmpDir, err := os.MkdirTemp("", "gitlab-ci-fallback-test") - require.NoError(t, err) - - defer func() { _ = os.RemoveAll(tmpDir) }() - - manager := NewManager(fakeClient) - ctx := context.Background() - - // Test injection - should use default fallback since no specific template exists - err = manager.InjectGitLabCIConfig(ctx, codebase, tmpDir) - require.NoError(t, err) - - // Verify file was created with default fallback content - gitlabCIPath := filepath.Join(tmpDir, GitLabCIFileName) - content, err := os.ReadFile(gitlabCIPath) - require.NoError(t, err) - - contentStr := string(content) - assert.Contains(t, contentStr, "default-fallback: test-app") -}