diff --git a/bundle/uds-bundle.yaml b/bundle/uds-bundle.yaml index e7d6016d..0a02cf6b 100644 --- a/bundle/uds-bundle.yaml +++ b/bundle/uds-bundle.yaml @@ -145,3 +145,7 @@ packages: values: - path: settingsJob.application.enabled_git_access_protocol value: all + variables: + - name: BOT_ACCOUNTS + description: "Bot Accounts to Create" + path: "botAccounts" diff --git a/bundle/uds-config.yaml b/bundle/uds-config.yaml index 2e7280c2..1662154e 100644 --- a/bundle/uds-config.yaml +++ b/bundle/uds-config.yaml @@ -52,3 +52,15 @@ variables: memory: 625M registry_replicas: 1 shell_replicas: 1 + bot_accounts: + enabled: true + accounts: + - username: test-bot + scopes: + - api + - read_repository + - write_repository + secret: + name: gitlab-test-bot + namespace: test-bot + keyName: TOKEN diff --git a/charts/config/templates/gitlab-license-secret.yaml b/charts/config/templates/gitlab-license-secret.yaml new file mode 100644 index 00000000..9537740d --- /dev/null +++ b/charts/config/templates/gitlab-license-secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gitlab-license + namespace: {{ .Release.Namespace }} +type: "Opaque" +stringData: + license: | + {{- .Values.license | nindent 4 }} + diff --git a/charts/config/values.yaml b/charts/config/values.yaml index 6641154f..8ada6dd4 100644 --- a/charts/config/values.yaml +++ b/charts/config/values.yaml @@ -1,5 +1,8 @@ domain: "###ZARF_VAR_DOMAIN###" +# contents of a gitlab license, if available +license: "" + ssh: enabled: false port: 2222 diff --git a/charts/settings/templates/bot-account-role.yaml b/charts/settings/templates/bot-account-role.yaml new file mode 100644 index 00000000..e5794854 --- /dev/null +++ b/charts/settings/templates/bot-account-role.yaml @@ -0,0 +1,72 @@ +{{- if .Values.botAccounts.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gitlab-botaccounts-sa + namespace: {{ .Release.Namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: gitlab-botaccounts-role + namespace: {{ .Release.Namespace }} +rules: + # Only allow exec into the toolbox pod + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["list"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get"] + resourceNames: + - gitlab-toolbox +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: gitlab-botaccounts-rolebinding + namespace: {{ .Release.Namespace }} +subjects: + - kind: ServiceAccount + name: gitlab-botaccounts-sa + namespace: {{ .Release.Namespace }} +roleRef: + kind: Role + name: gitlab-botaccounts-role + apiGroup: rbac.authorization.k8s.io +{{- range .Values.botAccounts.accounts }} +--- +# New Role for creating secrets in the '{{ .secret.namespace }}' namespace +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: gitlab-botaccounts-{{ .username }}-role + namespace: {{ .secret.namespace }} +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create"] + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["{{ .secret.name }}"] + verbs: ["get"] +--- +# RoleBinding to allow the ServiceAccount to manage secrets in '{{ .secret.namespace }}' namespace +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: gitlab-botaccounts-{{ .username }}-rolebinding + namespace: {{ .secret.namespace }} +subjects: + - kind: ServiceAccount + name: gitlab-botaccounts-sa + namespace: {{ $.Release.Namespace }} # Reference to the ServiceAccount in the original namespace +roleRef: + kind: Role + name: gitlab-botaccounts-{{ .username }}-role + apiGroup: rbac.authorization.k8s.io +{{- end }} +{{- end }} diff --git a/charts/settings/templates/bot-accounts-job.yaml b/charts/settings/templates/bot-accounts-job.yaml new file mode 100644 index 00000000..4ebfb7eb --- /dev/null +++ b/charts/settings/templates/bot-accounts-job.yaml @@ -0,0 +1,216 @@ +{{- if .Values.botAccounts.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: gitlab-bot-accounts-job + namespace: {{ .Release.Namespace }} +spec: + ttlSecondsAfterFinished: 600 + template: + metadata: + labels: + app: gitlab + spec: + serviceAccountName: gitlab-botaccounts-sa + containers: + - name: gitlab-settings-botaccounts + image: "{{ .Values.global.kubectl.image.repository }}:{{ .Values.global.kubectl.image.tag }}" + command: ["/bin/bash", "-c"] + args: + - | + gitlab_host="http://gitlab-webservice-default.gitlab.svc.cluster.local:8181" + + # Global variable to store the generated PAT for each account + generated_pat="" + + # Function for logging messages with timestamp in UTC in parentheses + log_message() { + echo "$(date -u +'%Y-%m-%d %H:%M:%S') (UTC) $1" + } + + create_kubernetes_secret() { + local secretName=$1 + local secretNamespace=$2 + local secretKeyName=$3 + local generated_pat=$4 + + # Step 4: Create Kubernetes secret with the generated PAT + kubectl create secret generic "$secretName" \ + --namespace="$secretNamespace" \ + --from-literal="$secretKeyName=$generated_pat" + + if [ $? -eq 0 ]; then + log_message "Kubernetes secret '$secretName' created successfully in namespace '$secretNamespace'." + else + log_message "Failed to create Kubernetes secret '$secretName' in namespace '$secretNamespace'." + return 1 + fi + } + + create_gitlab_account() { + # Input parameters + local service_account="$1" + local username="$2" + local scope_list="$3" # Scopes as a comma-separated list + local email="$username@gitlab.{{ .Values.domain }}" + local name=$username + + log_message "Creating gitlab user account with inputs:" + log_message " service_account=$service_account" + log_message " username=$username" + log_message " scope_list=$scope_list" + + local user_exists + user_exists=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" "${gitlab_host}/api/v4/users?username=$username") + + if echo "$user_exists" | grep -q '"id":'; then + log_message "User already exists" + log_message "User details: $user_exists" + return 1 + fi + + if [ "$service_account" == "true" ]; then + user_response=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" \ + --data "username=$username&name=$name" \ + --request POST "${gitlab_host}/api/v4/service_accounts") + else + # Generate a random password + local password + password=$(openssl rand -base64 16) + + # Create the user if it doesn't exist + user_response=$(curl --silent --request POST "${gitlab_host}/api/v4/users" \ + --header "PRIVATE-TOKEN: $TOKEN" \ + --header "Content-Type: application/json" \ + --data "{ + \"email\": \"$email\", + \"username\": \"$username\", + \"name\": \"$name\", + \"password\": \"$password\", + \"skip_confirmation\": true + }") + fi + + # Check if user creation was successful + if [ $? -ne 0 ]; then + log_message "Error: Failed to create the user. $user_response" + return 1 + fi + + # Extract the new user's ID from the response + user_id=$(echo "$user_response" | yq e '.id' -) + + # Check if the user ID is valid + if [ "$user_id" == "null" ] || [ -z "$user_id" ]; then + log_message "Error: Failed to retrieve the user ID. $user_id" + return 1 + fi + + log_message "User created with ID: $user_id" + + # Convert the comma-delimited list into separate --data fields for the request + local scope_data="" + IFS=',' read -ra scopes <<< "$scope_list" + for scope in "${scopes[@]}"; do + scope_data+="&scopes[]=$scope" + done + + # Create a Personal Access Token (PAT) for the new user with the specified scopes + pat_response=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" \ + --data "name=UDS Generated PAT$scope_data" \ + --request POST "${gitlab_host}/api/v4/users/$user_id/personal_access_tokens") + + # Check if token creation was successful + if [ $? -ne 0 ]; then + log_message "Error: Failed to create the Personal Access Token. $pat_response" + return 1 + fi + + # Extract the generated token from the response + generated_pat=$(echo "$pat_response" | yq e '.token' -) + + # Check if token is returned + if [ "$generated_pat" == "null" ] || [ -z "$generated_pat" ]; then + log_message "Error: Failed to retrieve the generated token. $generated_pat" + return 1 + fi + } + + log_message "Bot accounts job started." + + # Generate and capture a GitLab token from the GitLab Toolbox Rails Console for the root user + TOKEN=$(kubectl exec -n gitlab deployment/gitlab-toolbox -- \ + gitlab-rails runner -e production \ + "token = User.find_by_username('root').personal_access_tokens.create(scopes: ['api', 'admin_mode'], name: 'Bot Accounts API Token', expires_at: 1.days.from_now); token.save!; puts token.token" | tail -n 1) + + response=$(curl --silent --header "PRIVATE-TOKEN: $TOKEN" "${gitlab_host}/api/v4/license") + + # Check if the request was successful + if [ $? -ne 0 ]; then + log_message "Error: Failed to make request to GitLab API. $response" + exit 1 + fi + + plan=$(echo "$response" | yq e '.plan' -) + service_accounts="false" #init this to false, it will only be true if plan is ultimate or premium + + # Check if plan is found and proceed based on the plan type + if [ -n "$plan" ] && [ "$plan" != "null" ]; then + log_message "GitLab Plan: $plan. Creating GitLab service accounts." + service_accounts="true" + else + log_message "No license plan information available. Creating Gitlab user accounts." + fi + + # Track created, failed and existing accounts + created_accounts="" + failed_accounts="" + existing_accounts="" + + {{- range .Values.botAccounts.accounts }} + log_message "Creating account [{{ .username }}]..." + + # Check if the secret exists + kubectl get secret "{{ .secret.name }}" --namespace="{{ .secret.namespace }}" + + if [ $? -eq 0 ]; then + # Secret exists + log_message "Secret '{{ .secret.name }}' in namespace '{{ .secret.namespace }}' already exists. Skipping account '{{ .username }}'." + existing_accounts+=" {{ .username }}" + else + # Call the function to create the service account and set the PAT + create_gitlab_account "$service_accounts" "{{ .username }}" "{{ .scopes | join "," }}" + if [ $? -ne 0 ]; then + failed_accounts+=" {{ .username }}" + else + create_kubernetes_secret "{{ .secret.name }}" "{{ .secret.namespace }}" "{{ .secret.keyName }}" "$generated_pat" + if [ $? -ne 0 ]; then + log_message "Failed to create Kubernetes secret for account '{{ .username }}'." + failed_accounts+=" {{ .username }}" + else + created_accounts+=" {{ .username }}" + fi + fi + fi + {{- end }} + + # Revoke the token after use + kubectl exec -n gitlab deployment/gitlab-toolbox -- \ + gitlab-rails runner -e production \ + "token = PersonalAccessToken.find_by_token('$TOKEN'); token.revoke!" + + if [ -n "$existing_accounts" ]; then + log_message "The following bot accounts were skipped because they already exist:$existing_accounts" + fi + + if [ -n "$created_accounts" ]; then + log_message "The following bot accounts were created successfully:$created_accounts" + fi + + # After attempting all accounts, check if any failed + if [ -n "$failed_accounts" ]; then + log_message "The following bot accounts failed to create:$failed_accounts" + exit 1 # Fail the job if any account creation failed + fi + restartPolicy: Never +{{- end }} diff --git a/charts/settings/templates/bot-secret-namespaces.yaml b/charts/settings/templates/bot-secret-namespaces.yaml new file mode 100644 index 00000000..bd3180e5 --- /dev/null +++ b/charts/settings/templates/bot-secret-namespaces.yaml @@ -0,0 +1,9 @@ +{{- if .Values.botAccounts.enabled }} +{{- range .Values.botAccounts.accounts }} +kind: Namespace +apiVersion: v1 +metadata: + name: {{ .secret.namespace }} +--- +{{- end }} +{{- end }} diff --git a/charts/settings/values.yaml b/charts/settings/values.yaml index 4add9651..0c06ca49 100644 --- a/charts/settings/values.yaml +++ b/charts/settings/values.yaml @@ -1,9 +1,24 @@ +domain: "###ZARF_VAR_DOMAIN###" + global: kubectl: image: repository: registry.gitlab.com/gitlab-org/build/cng/kubectl tag: v17.2.4 +botAccounts: + enabled: false + # accounts: + # - username: renovatebot + # scopes: + # - api + # - read_repository + # - write_repository + # secret: + # name: gitlab-renovatebot + # namespace: renovate + # keyName: TOKEN + settingsJob: enabled: true schedule: "0 2 * * *" # Run at 2:00 AM every day diff --git a/docs/configuration.md b/docs/configuration.md index 45ddbe3f..bce8e285 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,12 @@ GitLab in this package is configured through the upstream [GitLab chart](https://docs.gitlab.com/charts/) as well as a UDS configuration chart that supports the following: +## GitLab License + +#### `uds-gitlab-config` chart: + +- `license` - Set this to the contents of a GitLab license file to enable GitLab Premium or Ultimate. + ## Networking Network policies are controlled via the `uds-gitlab-config` chart in accordance with the [common patterns for networking within UDS Software Factory](https://github.com/defenseunicorns/uds-software-factory/blob/main/docs/networking.md). GitLab interacts with GitLab runners, object storage, Redis and Postgresql externally and supports the following keys: @@ -133,3 +139,29 @@ It is recommended to inspect these settings and further lock them down for your > [!TIP] > If you wish to disable the settings Job and CronJob and keep GitLab's default application settings you can do so with the `settingsJob.enabled` value. You can also adjust the CronJob schedule (when it will reset the application settings) with the `settingsJob.schedule` value. + +## Configuring Bot Accounts + +#### `uds-gitlab-config` chart: + +- `botAccounts.enabled` - set this to true to enable bot accounts. +- `botAccounts.accounts` - set this to a list of bot accounts to create. If specified, each account will be created in GitLab with the given `username` and `scopes`. A GitLab Personal Access Token (PAT) will be created for the account and stored in the secret specified by `secret.name`, `secret.namespace`, and `secret.keyName`. Any namespaces specified in `botAccounts` secrets will be created automatically. + +Example: + +```yaml + - username: renovatebot + scopes: + - api + - read_repository + - write_repository + secret: + name: gitlab-renovate + namespace: renovate + keyName: TOKEN +``` + +This will configure a bot account named `renovatebot` and create a PAT with scopes `api`, `read_repository`, and `write_repository` for the account. The value of the PAT will be stored in the key `TOKEN` in a secret `gitlab-renovate` in the `renovate` namespace. + +> [!NOTE] +> If the GitLab instance is configured with a license for Premium or Ultimate, [Gitlab Service Accounts](https://docs.gitlab.com/ee/user/profile/service_accounts.html) will be created. Otherwise, standard user accounts will be created. diff --git a/tasks/test.yaml b/tasks/test.yaml index 211f2c45..a3f8c551 100644 --- a/tasks/test.yaml +++ b/tasks/test.yaml @@ -5,8 +5,9 @@ variables: tasks: - name: all actions: - - task: test:ingress - - task: test:ui + - task: ingress + - task: ui + - task: bot-account - name: ingress actions: @@ -60,6 +61,23 @@ tasks: - cmd: rm -rf "${PROJECT_NAME}" dir: tests/data + - name: bot-account + description: Bot Account Access Tests + actions: + - cmd: kubectl get secret gitlab-test-bot -n test-bot -o jsonpath="{.data.TOKEN}" | base64 --decode + setVariables: + - name: GITLAB_TOKEN + - cmd: | + response=$(curl --write-out "%{http_code}" --silent --output /dev/null --header "PRIVATE-TOKEN: $GITLAB_TOKEN" https://gitlab.uds.dev/api/v4/user) + + # Check if the status code is 200 (OK) + if [ "$response" -eq 200 ]; then + echo "GitLab API token is valid." + else + echo "Error: GitLab API token is invalid or cannot access the API." + exit 1 + fi + - name: create-doug-pat description: Create personal access token (PAT) for "doug" account actions: @@ -77,3 +95,23 @@ tasks: actions: - description: Get the root password for GitLab (useful for local dev) cmd: ./uds zarf tools kubectl get secret -n gitlab gitlab-gitlab-initial-root-password -o jsonpath={.data.password} | base64 -d + + # TODO (@ewyles) - might be useful in uds-common as a generic thing in the future + - name: bot-account-script + actions: + - description: Manually run the bot account script from the helm template locally + cmd: | + helm template charts/settings --show-only templates/bot-accounts-job.yaml | \ + yq -r '.spec.template.spec.containers[0].args[0]' | \ + sed 's|http://gitlab-webservice-default.gitlab.svc.cluster.local:8181|https://gitlab.uds.dev|g' | \ + sh + + - name: debug-bot-account-script + actions: + - description: Manually run the bot account script from the helm template locally + cmd: | + helm template charts/settings --show-only templates/bot-accounts-job.yaml | \ + yq -r '.spec.template.spec.containers[0].args[0]' | \ + sed 's|http://gitlab-webservice-default.gitlab.svc.cluster.local:8181|https://gitlab.uds.dev|g' | \ + { echo "set -x"; cat; } | \ + sh diff --git a/values/common-values.yaml b/values/common-values.yaml index 16b189c8..4c01fe21 100644 --- a/values/common-values.yaml +++ b/values/common-values.yaml @@ -1,4 +1,8 @@ global: + gitlab: + license: + secret: gitlab-license + image: pullPolicy: IfNotPresent