#GitLab Connector Automatic Creation Tekton Task
This page is the parameter, workspace, and result reference for the
gitlab-connector-automatic-creation Tekton Task. For the operator
walkthrough — Pattern A, Pattern B, rotate, recreate, expired-fall-through,
and the operations runbook — see
Auto-Create GitLab Tenant Group and Connector with Tekton.
For the underlying model — how the admin Connector is consumed through
gitlabconfig, the three-path GAT lifecycle, and the tenant-side
surface — see
GitLab CLI Configuration and Auto-Creation Patterns.
#TOC
InstallationDescriptionParametersWorkspacesResultsStepsThree-Path GAT Lifecycle SummarySecurity NotesSee Also#Installation
Set the variables, then run the command below:
REGISTRY: the registry host that mirrorsregistry.alauda.cn:60070(the catalogglabandkubectltool images live under this prefix).TARGET_NS: the namespace where you want to install the Task. The catalog tool-image ConfigMaps that the Task references are already installed inkube-publicby the connectors-operator.
export REGISTRY=<your-registry>
export TARGET_NS=<target-namespace>
cat <<'EOF' | envsubst '${REGISTRY} ${TARGET_NS}' | kubectl apply -f -
apiVersion: tekton.dev/v1
kind: Task
metadata:
annotations:
style.tekton.dev/descriptors: |
- path: params.gitlabCliImage
x-descriptors:
- urn:alm:descriptor:label:en:GitLab CLI Image
- 'urn:alm:descriptor:description:en:Tool image used by every glab step (ensure-group, ensure-subgroups, ensure-gat). Defaults to the catalog tool-image-glab v1.82 image; override per TaskRun if you mirror the image into a private registry'
- 'urn:alm:descriptor:tooltip:en:Pick the catalog-provided glab image, or supply a registry-mirrored copy. The image must run as non-root (UID 65532) and contain the glab CLI'
- urn:alm:descriptor:com.tectonic.ui:select:image
- urn:alm:descriptor:expression:props.default:${REGISTRY}/devops/tektoncd/hub/glab:v1.82
- urn:alm:descriptor:expression:props.options:api:/kubernetes/${context.cluster}/api/v1/namespaces/kube-public/configmaps?labelSelector=catalog.tekton.dev%2Ftool-image-glab
- urn:alm:descriptor:props:select:allowCreate
- urn:alm:descriptor:expression:props.options:path:items
- urn:alm:descriptor:expression:props.options:label:path:data.name
- urn:alm:descriptor:expression:props.options:value:path:data.image
- urn:alm:descriptor:com.tectonic.ui:validation:required
- path: params.kubectlImage
x-descriptors:
- urn:alm:descriptor:label:en:kubectl Image
- 'urn:alm:descriptor:description:en:Tool image used by the apply-kubernetes-resources step. Defaults to the catalog tool-image-kubectl v1.33 image; override per TaskRun if you mirror the image into a private registry'
- 'urn:alm:descriptor:tooltip:en:Pick the catalog-provided kubectl image, or supply a registry-mirrored copy. The image must run as non-root (UID 65532) and contain the kubectl binary'
- urn:alm:descriptor:com.tectonic.ui:select:image
- urn:alm:descriptor:expression:props.default:${REGISTRY}/devops/tektoncd/hub/kubectl:v1.33
- urn:alm:descriptor:expression:props.options:api:/kubernetes/${context.cluster}/api/v1/namespaces/kube-public/configmaps?labelSelector=catalog.tekton.dev%2Ftool-image-kubectl
- urn:alm:descriptor:props:select:allowCreate
- urn:alm:descriptor:expression:props.options:path:items
- urn:alm:descriptor:expression:props.options:label:path:data.name
- urn:alm:descriptor:expression:props.options:value:path:data.image
- urn:alm:descriptor:com.tectonic.ui:validation:required
- path: params.imagePullPolicy
x-descriptors:
- urn:alm:descriptor:label:en:Image Pull Policy
- 'urn:alm:descriptor:description:en:Controls when Kubernetes pulls the tool images used by this Task'
- urn:alm:descriptor:com.tectonic.ui:select
- urn:alm:descriptor:com.tectonic.default:Always
- urn:alm:descriptor:props:select:allowCreate
- urn:alm:descriptor:props:select:options:Always
- urn:alm:descriptor:props:select:options:IfNotPresent
- urn:alm:descriptor:props:select:options:Never
- path: params.connector
x-descriptors:
- urn:alm:descriptor:label:en:Connector
- 'urn:alm:descriptor:description:en:Target GitLab Connector resource to create or update, in <namespace>/<name> format, for example connectors-management/project-a-gitlab. The namespace is also used for the connector Secret'
- 'urn:alm:descriptor:tooltip:en:Use <namespace>/<name>, for example connectors-management/project-a-gitlab. The namespace is also used for the connector Secret'
- urn:alm:descriptor:com.tectonic.ui:text
- urn:alm:descriptor:com.tectonic.ui:validation:required
- path: params.tenantGroup
x-descriptors:
- urn:alm:descriptor:label:en:Tenant Group
- 'urn:alm:descriptor:description:en:Tenant group path. Top-level for Pattern A (e.g. acme), or umbrella/tenant for Pattern B (e.g. acme-umbrella/acme-tenant). The Task creates the group if missing and reuses it if it already exists'
- 'urn:alm:descriptor:tooltip:en:Pattern A example: acme. Pattern B example: acme-umbrella/acme-tenant. See the GitLab Task how-to for the deployment patterns'
- urn:alm:descriptor:com.tectonic.ui:text
- urn:alm:descriptor:com.tectonic.ui:validation:required
- path: params.subgroups
x-descriptors:
- urn:alm:descriptor:label:en:Subgroups
- 'urn:alm:descriptor:description:en:Optional per-team subgroup names created idempotently under the tenant group. Add one subgroup name per item; leave empty for no subgroups'
- 'urn:alm:descriptor:tooltip:en:Add one subgroup name per item, e.g. backend, frontend, ml. The Task creates missing subgroups and reuses existing ones'
- urn:alm:descriptor:com.tectonic.ui:tagsInput
- path: params.accessLevel
x-descriptors:
- urn:alm:descriptor:label:en:Access Level
- 'urn:alm:descriptor:description:en:GitLab access level for the Group Access Token. Owner is required to create subgroups; lower levels restrict the GAT scope. Casing matches the GitLab UI convention; the Task accepts mixed case (the script lowercases internally)'
- 'urn:alm:descriptor:tooltip:en:Owner is required if you also need to create subgroups. Use Developer or Maintainer for read-mostly tenant integrations'
- urn:alm:descriptor:com.tectonic.ui:select
- urn:alm:descriptor:com.tectonic.default:Owner
- urn:alm:descriptor:props:select:options:Guest
- urn:alm:descriptor:props:select:options:Reporter
- urn:alm:descriptor:props:select:options:Developer
- urn:alm:descriptor:props:select:options:Maintainer
- urn:alm:descriptor:props:select:options:Owner
- path: params.scopes
x-descriptors:
- urn:alm:descriptor:label:en:Scopes
- 'urn:alm:descriptor:description:en:GitLab Group Access Token scope set. Defaults to ["api", "read_repository"] — api grants full GitLab REST access (project / group / token CRUD); read_repository grants git-clone-over-HTTPS for tenant CI workloads. Add one scope per item; common additions: write_repository (git push), read_registry, write_registry, read_api'
- 'urn:alm:descriptor:tooltip:en:Default api + read_repository covers most tenant CI use cases (REST API + clone). Add write_repository for push, read_registry / write_registry for the GitLab Container Registry. See GitLab docs for the full scope list'
- urn:alm:descriptor:com.tectonic.ui:tagsInput
- path: params.tokenDuration
x-descriptors:
- urn:alm:descriptor:label:en:Token Duration
- 'urn:alm:descriptor:description:en:Validity period of the GitLab Group Access Token in days; capped by the GitLab instance policy. Cron-friendly — every TaskRun computes expires_at as today + N days, so a CronJob can re-run this Task on a cadence shorter than the duration'
- 'urn:alm:descriptor:tooltip:en:Set the GAT validity period in days, e.g. 30. The cluster admin can cap this value at the GitLab instance level'
- urn:alm:descriptor:com.tectonic.ui:text
- path: params.secret
x-descriptors:
- urn:alm:descriptor:label:en:Connector Secret
- 'urn:alm:descriptor:description:en:Name of the Kubernetes Secret that stores the tenant GAT in the connector namespace. Leave empty to let the Task create or update a Kubernetes Secret named <connector-name>-secret automatically'
- 'urn:alm:descriptor:tooltip:en:Leave empty to let the Task create or update a Kubernetes Secret named <connector-name>-secret automatically'
- urn:alm:descriptor:com.tectonic.ui:text
- path: params.accessTokenName
x-descriptors:
- urn:alm:descriptor:label:en:Access Token Name
- 'urn:alm:descriptor:description:en:Name prefix used for the GitLab Group Access Token managed by this Task. The full GAT name is <accessTokenName>/<identity-suffix>; the suffix encodes access level + scopes + subgroup set so reruns can detect identity-affecting changes. Leave empty to derive connector-<connector-namespace>-<connector-name>'
- 'urn:alm:descriptor:tooltip:en:Leave empty to let the Task derive connector-<connector-namespace>-<connector-name>. Mirrors Harbor robotAccount semantics'
- urn:alm:descriptor:com.tectonic.ui:text
- path: params.gitlabUrl
x-descriptors:
- urn:alm:descriptor:label:en:GitLab URL
- 'urn:alm:descriptor:description:en:Base URL of the target GitLab instance. Used as the host for all admin glab API calls and recorded as the tenant address. Must be a fully-qualified http(s) URL; required'
- 'urn:alm:descriptor:tooltip:en:Example https://gitlab.example.com. The Task fails fast if this is empty or not an http(s) URL'
- urn:alm:descriptor:com.tectonic.ui:text
- urn:alm:descriptor:com.tectonic.ui:validation:required
- path: params.basicAuthSecrets
x-descriptors:
- urn:alm:descriptor:label:en:Basic-Auth Secret Targets
- 'urn:alm:descriptor:description:en:Optional list of kubernetes.io/basic-auth Secret references to create or update from the tenant GAT, one <namespace>/<name> per item. Each target Secret stores username=oauth2 and password=<tenant GAT>, suitable for any consumer that authenticates against GitLab via HTTP Basic. Sync is additive: removing an entry does not delete previously synced Secrets'
- 'urn:alm:descriptor:tooltip:en:Add one <namespace>/<name> per item. The Task uses SelfSubjectAccessReview to confirm write access to each namespace before applying'
- urn:alm:descriptor:com.tectonic.ui:tagsInput
- path: params.verbose
x-descriptors:
- urn:alm:descriptor:label:en:Verbose Logs
- 'urn:alm:descriptor:description:en:Enable shell tracing on non-secret steps. The admin PAT and tenant GAT are NEVER traced regardless of this flag'
- urn:alm:descriptor:com.tectonic.ui:booleanSwitch
- urn:alm:descriptor:props:booleanSwitch:true:true
- urn:alm:descriptor:props:booleanSwitch:false:false
style.tekton.dev/displayParams: connector,gitlabUrl,tenantGroup,accessLevel,scopes
tekton.dev/categories: CLI
tekton.dev/displayName: GitLab Connector Automatic Creation
tekton.dev/pipelines.minVersion: 0.56.0
tekton.dev/platforms: linux/amd64,linux/arm64
tekton.dev/tags: gitlab, connector, kubernetes, secret, group-access-token
labels:
app.kubernetes.io/version: "0.1"
name: gitlab-connector-automatic-creation
namespace: ${TARGET_NS}
spec:
description: 'Use glab and kubectl to create or reuse a tenant GitLab group + optional subgroups, mint a Group Access Token, and write the cluster-side tenant GitLab Connector + auth Secret in one TaskRun. Supports Pattern A (top-level group + can_create_group user PAT) and Pattern B (subgroup + umbrella GAT). Token refresh follows a three-path lifecycle: rotate / recreate / expired-fall-through.'
params:
- description: Target GitLab Connector resource to create or update, in <namespace>/<name> format, for example connectors-management/project-a-gitlab
name: connector
type: string
- description: Tenant group path; top-level (Pattern A) or `umbrella/tenant` (Pattern B)
name: tenantGroup
type: string
- default: []
description: Optional per-team subgroup names created idempotently under the tenant group
name: subgroups
type: array
- default: owner
description: GitLab access level for the GAT (guest|reporter|developer|maintainer|owner)
name: accessLevel
type: string
- default:
- api
- read_repository
description: 'GitLab GAT scope set; defaults to ["api", "read_repository"]. api grants full GitLab REST access (project / group / token CRUD); read_repository grants git-clone-over-HTTPS for tenant CI workloads. Common additions: write_repository (git push), read_registry, write_registry, read_api'
name: scopes
type: array
- default: "30"
description: Validity period of the GitLab Group Access Token in days; capped by the GitLab instance policy. Cron-friendly — every run computes expires_at as today + N days.
name: tokenDuration
type: string
- default: ""
description: Name of the Kubernetes Secret that stores the tenant GAT in the connector namespace; leave empty to let the Task create or update <connector-name>-secret automatically
name: secret
type: string
- default: ""
description: Name prefix used for the GitLab Group Access Token managed by this Task. The full GAT name is `<accessTokenName>/<identity-suffix>`; the suffix encodes access level + scopes + subgroup set so reruns can detect identity-affecting changes. Leave empty to derive `connector-<connector-namespace>-<connector-name>`. Mirrors Harbor's `robotAccount` parameter.
name: accessTokenName
type: string
- description: Required base URL of the target GitLab instance. Used as the host for all admin glab API calls and recorded as the tenant address. Must be a fully-qualified http(s) URL; the Task fails fast if empty or non-http(s).
name: gitlabUrl
type: string
- default: []
description: Optional list of kubernetes.io/basic-auth Secret references to create or update from the tenant GAT; add one `<namespace>/<name>` per item. Each synced Secret stores `username=oauth2` + `password=<tenant GAT>` and is consumable by any GitLab HTTP-basic client. Sync is additive — removing an entry does not delete previously synced Secrets.
name: basicAuthSecrets
type: array
- default: ${REGISTRY}/devops/tektoncd/hub/glab:v1.82
description: Tool image used by every glab step. Defaults to the catalog tool-image-glab v1.82 image.
name: gitlabCliImage
type: string
- default: ${REGISTRY}/devops/tektoncd/hub/kubectl:v1.33
description: Tool image used by the kubectl step. Defaults to the catalog tool-image-kubectl v1.33 image.
name: kubectlImage
type: string
- default: Always
description: Controls when Kubernetes pulls the tool images used by this Task
name: imagePullPolicy
type: string
- default: "false"
description: Enable shell tracing on non-secret steps; the admin PAT and tenant GAT are NEVER traced regardless of this flag
name: verbose
type: string
results:
- description: The materialised tenant group path
name: tenant-group
- description: JSON array of materialised subgroup paths
name: subgroups
type: array
- description: Identity-encoded GitLab GAT name minted/rotated by this run (`<prefix>/<identity-suffix>`). Useful for ops audit and `glab api` lookups; the numeric token id is recorded as a Secret annotation, not exposed as a result.
name: access-token-name
- description: Namespaced reference of the tenant Connector handled by the Task
name: connector-ref
- description: List of `<namespace>/<name>` references for the kubernetes.io/basic-auth Secrets actually synced from `params.basicAuthSecrets`, in the same order as the input
name: basic-auth-secret-refs
type: array
stepTemplate:
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
volumeMounts:
- mountPath: /workspace/state
name: state
- mountPath: /workspace/secrets
name: secrets
steps:
- computeResources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
env:
- name: TENANT_GROUP
value: $(params.tenantGroup)
- name: VERBOSE
value: $(params.verbose)
- name: GLAB_CONFIG_DIR
value: $(workspaces.gitlabconfig.path)
- name: GLAB_CHECK_UPDATE
value: "false"
- name: GLAB_SEND_TELEMETRY
value: "false"
- name: STATE_DIR
value: /workspace/state
image: $(params.gitlabCliImage)
imagePullPolicy: $(params.imagePullPolicy)
name: ensure-group
script: |
#!/usr/bin/env bash
# ensure-group.sh -- step 1 of gitlab-connector-automatic-creation/0.1
#
# Create-or-reuse the tenant GitLab group at the configured `tenantGroup`
# path via `glab api groups`. Honours both deployment patterns:
#
# Pattern A: tenantGroup is a top-level path (no slash). Admin
# identity is a user PAT with `can_create_group`.
# Pattern B: tenantGroup is `umbrella/tenant`. Admin identity is the
# umbrella's GAT with owner access.
#
# Inputs (env): TENANT_GROUP, VERBOSE, GLAB_CONFIG_DIR (CSI mount).
# Outputs : prints group id + path; writes the group id to
# /workspace/state/tenant-group-id (next step reads it).
#
# This script never echoes the admin PAT/GAT. `set +x` is the default;
# `verbose=true` env enables tracing on non-secret paths only.
set -euo pipefail
set +o xtrace 2>/dev/null || true
: "${TENANT_GROUP:?ensure-group: TENANT_GROUP is required}"
: "${VERBOSE:=false}"
: "${GLAB_CONFIG_DIR:=/workspace/gitlabconfig}"
: "${STATE_DIR:=/workspace/state}"
mkdir -p "${STATE_DIR}"
# Logs follow Harbor's harbor-connector-automatic-creation style:
# sentence-case narrative ("Created tenant group ..."), no per-call API
# hints, no bash xtrace. ERROR lines carry full context to stderr.
# Run a glab/curl command up to 3 times if its combined output looks like
# a transient DNS resolution failure. Reason: in CI's kind cluster the
# in-cluster coredns occasionally returns NXDOMAIN/SERVFAIL for valid
# hostnames during heavy back-to-back BDD runs; live GitLab instances can
# also exhibit transient DNS in production.
with_dns_retry() {
local out rc attempt=1
while :; do
set +e
out="$("$@" 2>&1)"
rc=$?
set -e
if [[ ${rc} -eq 0 ]] || [[ ${attempt} -ge 3 ]]; then break; fi
case "${out}" in
*"no such host"*|*"server misbehaving"*|*"i/o timeout"*|*"dial tcp: lookup"*|*"Could not resolve host"*|*"Temporary failure in name resolution"*)
echo "WARN: transient DNS failure (attempt ${attempt}/3), retrying in 3s" >&2
sleep 3
attempt=$((attempt + 1))
continue
;;
esac
break
done
printf '%s' "${out}"
return ${rc}
}
# Bootstrap glab credentials silently from the CSI mount + params.gitlabUrl.
# The CSI mount is read-only with mode 0444 (CSI default); glab itself
# rejects any config.yml that isn't 0600 ("failed to read configuration").
# Copy the config into a writable XDG home and re-point GLAB_CONFIG_DIR
# (which glab honours natively as the config-dir override) at that
# writable copy so glab no longer reaches into the read-only mount.
#
# GITLAB_HOST + GITLAB_TOKEN BOTH come from the `gitlabconfig` workspace's
# `config.yml`. The host is the **admin-Connector-rendered host** (the
# in-cluster proxy address when the workspace is a connectors-csi mount
# of an admin GitLab Connector; the public host when the workspace is a
# static Secret in BDD or operator-direct deployments). Critically, the
# host is NOT derived from `params.gitlabUrl` -- gitlabUrl is for the
# TENANT Connector's `spec.address` write only.
#
# Regression history: a prior DEVOPS-44155 revision attempted to use
# `params.gitlabUrl` as glab's host, which broke CSI mode in production:
# the CSI-issued token is a Kubernetes ServiceAccount JWT meaningful only
# to the connectors proxy; sending it directly to the public GitLab host
# returns 401 (GitLab doesn't recognise the JWT). The token MUST be paired
# with the host that knows how to validate it -- and config.yml already
# does that pairing for us.
prepare_glab() {
local glab_home="${HOME:-/tmp}/.config/glab-cli"
mkdir -p "${glab_home}"
if [[ ! -f "${GLAB_CONFIG_DIR}/config.yml" ]]; then
echo "ERROR: glab config not found at ${GLAB_CONFIG_DIR}/config.yml -- is the gitlabconfig CSI mount bound?" >&2
exit 1
fi
cp "${GLAB_CONFIG_DIR}/config.yml" "${glab_home}/config.yml"
chmod 600 "${glab_home}/config.yml"
export GLAB_CONFIG_DIR="${glab_home}"
# glab v1.82+ resolves the target host from --hostname / GITLAB_HOST
# / the current git remote / config-default, in that order. Our config
# has exactly one host entry, but glab does NOT auto-pick it -- it
# falls through to gitlab.com by default, then 401s. Pull the host +
# token out of the mounted config and export them so glab's env-var
# fallback always finds the right host.
#
# awk walks the `hosts:` block and yields the first sub-key (the
# admin host). This shape tolerates host:port (Ingress / NodePort
# proxies) -- a grep '[A-Za-z0-9._-]+:$' regex would silently miss
# `c-x.ns.svc:8443:` because ':' isn't in the char class, which is
# the same regression class this PR is repairing in a different guise.
GITLAB_HOST=$(awk '
/^hosts:[[:space:]]*$/ { in_hosts=1; next }
in_hosts && /^[^[:space:]]/ { exit }
in_hosts && /^ [^[:space:]].*:[[:space:]]*$/ {
sub(/^ /, "")
sub(/:[[:space:]]*$/, "")
print
exit
}
' "${glab_home}/config.yml")
GITLAB_TOKEN=$(awk '/^ token:[[:space:]]/ { print $2; exit }' "${glab_home}/config.yml")
if [[ -z "${GITLAB_HOST}" || -z "${GITLAB_TOKEN}" ]]; then
echo "ERROR: could not parse host/token from ${glab_home}/config.yml" >&2
exit 1
fi
export GITLAB_HOST GITLAB_TOKEN
}
# URL-encode a path so `umbrella/tenant` survives `GET /groups/:id`.
url_encode_path() {
local p="$1"
printf '%s' "${p}" | sed 's|/|%2F|g'
}
# Read api_protocol from the glab config so direct-curl calls (used for
# endpoints that glab encodes incompatibly, e.g. POST /members) target
# the correct scheme.
read_glab_protocol() {
local cfg="${GLAB_CONFIG_DIR}/config.yml"
GLAB_PROTOCOL="$(awk -F'[: ]+' '/^[[:space:]]*api_protocol:[[:space:]]/{print $3; exit}' "${cfg}")"
if [[ -z "${GLAB_PROTOCOL}" ]]; then
GLAB_PROTOCOL="https"
fi
}
# Detect the admin token's user identity and whether it's a Group
# Access Token (Pattern B umbrella GAT) vs a regular user PAT.
#
# GitLab's `/api/v4/personal_access_tokens/self` returns the active
# token's metadata (works for PATs and GATs alike); the `user_id` field
# points at the underlying user record. Group bots have `bot: true` and
# a `username` of the shape `group_<id>_bot_<suffix>`.
#
# Sets globals on success:
# ADMIN_USER_ID numeric id of the user the token belongs to
# ADMIN_IS_GAT_BOT true|false
# ADMIN_BOT_USER_ID same as ADMIN_USER_ID when ADMIN_IS_GAT_BOT=true; else empty
detect_admin_identity() {
read_glab_protocol
local self_url user_url self_resp self_code user_resp user_code
self_url="${GLAB_PROTOCOL}://${GITLAB_HOST}/api/v4/personal_access_tokens/self"
set +x
self_code="$(with_dns_retry curl -skS -o /tmp/identity_self -w '%{http_code}' \
-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" "${self_url}" || true)"
self_resp="$(cat /tmp/identity_self 2>/dev/null || true)"
rm -f /tmp/identity_self
ADMIN_USER_ID=""
ADMIN_IS_GAT_BOT=false
ADMIN_BOT_USER_ID=""
if [[ "${self_code}" != "200" ]]; then
# Older GitLab releases (<14.5) lack /self -- ownership verification
# is skipped (Pattern A then proceeds without conflict-detection
# against pre-existing groups owned by other users).
return 0
fi
local user_id
user_id="$(printf '%s' "${self_resp}" | jq -r '.user_id // empty')"
if [[ -z "${user_id}" ]]; then
return 0
fi
ADMIN_USER_ID="${user_id}"
user_url="${GLAB_PROTOCOL}://${GITLAB_HOST}/api/v4/users/${user_id}"
user_code="$(with_dns_retry curl -skS -o /tmp/identity_user -w '%{http_code}' \
-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" "${user_url}" || true)"
user_resp="$(cat /tmp/identity_user 2>/dev/null || true)"
rm -f /tmp/identity_user
if [[ "${user_code}" != "200" ]]; then
return 0
fi
local is_bot username
is_bot="$(printf '%s' "${user_resp}" | jq -r '.bot // false')"
username="$(printf '%s' "${user_resp}" | jq -r '.username // empty')"
if [[ "${is_bot}" == "true" ]] && [[ "${username}" =~ ^group_[0-9]+_bot_ ]]; then
ADMIN_IS_GAT_BOT=true
ADMIN_BOT_USER_ID="${user_id}"
fi
}
# When the tenant group already exists, verify the admin actually has
# Owner access (direct OR inherited) before reusing it. Without this
# check, an unrelated user's group at the same path would be silently
# adopted -- catastrophic for the connector contract.
verify_admin_ownership() {
local group_id="$1"
if [[ -z "${ADMIN_USER_ID:-}" ]]; then
# /personal_access_tokens/self unavailable -- can't verify. Log and
# let the script proceed (older GitLab releases).
echo "WARN: Admin user id unknown; skipping ownership verification on group ${group_id}" >&2
return 0
fi
set +x
local url resp http_code
url="${GLAB_PROTOCOL}://${GITLAB_HOST}/api/v4/groups/${group_id}/members/all/${ADMIN_USER_ID}"
http_code="$(with_dns_retry curl -skS -o /tmp/own_resp -w '%{http_code}' \
-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" "${url}" || true)"
resp="$(cat /tmp/own_resp 2>/dev/null || true)"
rm -f /tmp/own_resp
if [[ "${http_code}" == "404" ]]; then
echo "ERROR: group path conflict; existing owner does not match admin (admin user_id=${ADMIN_USER_ID} is not a member of the existing group at ${TENANT_GROUP})" >&2
exit 1
fi
if [[ "${http_code}" != "200" ]]; then
echo "ERROR: failed to verify admin ownership of group ${group_id}: HTTP ${http_code} ${resp}" >&2
exit 1
fi
local access_level
access_level="$(printf '%s' "${resp}" | jq -r '.access_level // 0')"
if [[ "${access_level}" -lt 50 ]]; then
echo "ERROR: group path conflict; existing owner does not match admin (admin has access_level=${access_level}, requires Owner=50)" >&2
exit 1
fi
}
# NOTE: an earlier attempt added an `ensure_bot_owner_membership`
# helper to add the umbrella GAT's bot user as a *direct* Owner of
# freshly-created tenant subgroups (so that ensure-gat could mint a
# subgroup GAT). GitLab rejects this with
# HTTP 400 {"message":{"user_id":["project bots cannot be added to other groups / projects"]}}
# Group share (`POST /groups/<sub>/share`) granted access but did NOT
# unblock the GAT-mint endpoint either. Pattern B's design needs to
# change before TC2/TC9 can be automated -- see the FIXME blocks on
# those scenarios in tektoncd.feature.
# Look up the group by full path. Returns the JSON object on stdout, or
# empty string + non-zero on 404.
lookup_group() {
local path_enc; path_enc="$(url_encode_path "$1")"
with_dns_retry glab api "groups/${path_enc}" 2>/dev/null || return 1
}
# Create a new group. Pattern A: no parent_id. Pattern B: parent_id of
# the umbrella, looked up by parent path.
#
# A "path has already been taken" rejection means a group already
# exists at this path, owned by someone we can't see (typically a
# different user's *private* group that lookup_group couldn't return).
# That is a path conflict we MUST surface clearly -- silently
# proceeding would mean adopting an unrelated org's tenant group.
create_group() {
local full_path="$1"
local name="${full_path##*/}"
local parent_path="${full_path%/*}"
local out rc
set +e
if [[ "${parent_path}" == "${full_path}" ]]; then
# No slash -- top-level group (Pattern A).
out="$(with_dns_retry glab api -X POST groups -f "name=${name}" -f "path=${name}" -f "visibility=private")"
rc=$?
else
# Subgroup -- need the parent's id (Pattern B).
set -e
local parent_json parent_id
parent_json="$(lookup_group "${parent_path}")" || {
echo "ERROR: parent group '${parent_path}' not found; admin identity must have owner on the parent path. See how-to: Pattern B setup." >&2
exit 1
}
parent_id="$(printf '%s' "${parent_json}" | jq -r '.id')"
set +e
out="$(with_dns_retry glab api -X POST groups -f "name=${name}" -f "path=${name}" -f "parent_id=${parent_id}" -f "visibility=private")"
rc=$?
fi
set -e
if [[ ${rc} -eq 0 ]]; then
return 0
fi
if [[ "${out}" == *"has already been taken"* ]] \
|| [[ "${out}" == *"already exists"* ]]; then
echo "ERROR: group path conflict; existing owner does not match admin (path '${full_path}' is taken by another identity, or you lack permission to read it)" >&2
exit 1
fi
echo "ERROR: failed to create group '${full_path}': ${out}" >&2
exit 1
}
main() {
prepare_glab
detect_admin_identity
local existing id full
if existing="$(lookup_group "${TENANT_GROUP}")"; then
id="$(printf '%s' "${existing}" | jq -r '.id')"
full="$(printf '%s' "${existing}" | jq -r '.full_path')"
# Refuse to adopt someone else's group at this path.
verify_admin_ownership "${id}"
echo "Reusing existing tenant group id=${id} path=${full}"
else
echo "Ensuring tenant group ${TENANT_GROUP}"
create_group "${TENANT_GROUP}"
# Re-fetch to materialise the id we just created.
existing="$(lookup_group "${TENANT_GROUP}")" || {
echo "ERROR: tenant group '${TENANT_GROUP}' not found after create -- did the API call silently fail?" >&2
exit 1
}
id="$(printf '%s' "${existing}" | jq -r '.id')"
full="$(printf '%s' "${existing}" | jq -r '.full_path')"
echo "Created tenant group id=${id} path=${full}"
fi
printf '%s' "${id}" > "${STATE_DIR}/tenant-group-id"
printf '%s' "${full}" > "${STATE_DIR}/tenant-group-path"
echo "✅ Tenant group ready"
}
main "$@"
- args:
- $(params.subgroups[*])
computeResources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
env:
- name: VERBOSE
value: $(params.verbose)
- name: GLAB_CONFIG_DIR
value: $(workspaces.gitlabconfig.path)
- name: GLAB_CHECK_UPDATE
value: "false"
- name: GLAB_SEND_TELEMETRY
value: "false"
- name: STATE_DIR
value: /workspace/state
image: $(params.gitlabCliImage)
imagePullPolicy: $(params.imagePullPolicy)
name: ensure-subgroups
script: |
#!/usr/bin/env bash
# ensure-subgroups.sh -- step 2 of gitlab-connector-automatic-creation/0.1
#
# Iterates the SUBGROUPS array (newline-delimited stdin or repeated
# --subgroup args) and creates each idempotently as a child of the
# tenant group. Idempotent on rerun -- a 409 from `glab api` is treated
# as success.
#
# Inputs (env): TENANT_GROUP_ID, VERBOSE, GLAB_CONFIG_DIR.
# Inputs (argv): each remaining argument is a subgroup name (single
# path segment, no slashes -- subgroups under the tenant group only).
# Outputs : prints created/reused subgroup paths; writes a JSON
# array of materialised subgroup paths to
# /workspace/state/subgroups.json.
set -euo pipefail
set +o xtrace 2>/dev/null || true
: "${VERBOSE:=false}"
: "${GLAB_CONFIG_DIR:=/workspace/gitlabconfig}"
: "${STATE_DIR:=/workspace/state}"
mkdir -p "${STATE_DIR}"
# Logs follow Harbor's narrative style. See ensure-group.sh top comment.
# See ensure-group.sh:with_dns_retry for rationale.
with_dns_retry() {
local out rc attempt=1
while :; do
set +e
out="$("$@" 2>&1)"
rc=$?
set -e
if [[ ${rc} -eq 0 ]] || [[ ${attempt} -ge 3 ]]; then break; fi
case "${out}" in
*"no such host"*|*"server misbehaving"*|*"i/o timeout"*|*"dial tcp: lookup"*|*"Could not resolve host"*|*"Temporary failure in name resolution"*)
echo "WARN: transient DNS failure (attempt ${attempt}/3), retrying in 3s" >&2
sleep 3
attempt=$((attempt + 1))
continue
;;
esac
break
done
printf '%s' "${out}"
return ${rc}
}
prepare_glab() {
# See ensure-group.sh:prepare_glab — host + token both come from the
# gitlabconfig workspace's config.yml so the token issuer matches the
# host (proxy URL + SAJWT in CSI mode; public URL + real PAT in
# static-Secret mode). DO NOT derive host from params.gitlabUrl --
# that was the DEVOPS-44155 regression that broke CSI mode (proxy-only
# SAJWT sent directly to public GitLab -> 401).
local glab_home="${HOME:-/tmp}/.config/glab-cli"
mkdir -p "${glab_home}"
if [[ ! -f "${GLAB_CONFIG_DIR}/config.yml" ]]; then
echo "ERROR: glab config not found at ${GLAB_CONFIG_DIR}/config.yml" >&2
exit 1
fi
cp "${GLAB_CONFIG_DIR}/config.yml" "${glab_home}/config.yml"
chmod 600 "${glab_home}/config.yml"
export GLAB_CONFIG_DIR="${glab_home}"
# See ensure-group.sh:prepare_glab for the awk shape -- yields the
# first sub-key under `hosts:` (the admin host, port-tolerant) and the
# matching token. Replicated here verbatim to keep all three step
# scripts self-contained for `kubectl debug` workflows.
GITLAB_HOST=$(awk '
/^hosts:[[:space:]]*$/ { in_hosts=1; next }
in_hosts && /^[^[:space:]]/ { exit }
in_hosts && /^ [^[:space:]].*:[[:space:]]*$/ {
sub(/^ /, "")
sub(/:[[:space:]]*$/, "")
print
exit
}
' "${glab_home}/config.yml")
GITLAB_TOKEN=$(awk '/^ token:[[:space:]]/ { print $2; exit }' "${glab_home}/config.yml")
if [[ -z "${GITLAB_HOST}" || -z "${GITLAB_TOKEN}" ]]; then
echo "ERROR: could not parse host/token from ${glab_home}/config.yml" >&2
exit 1
fi
export GITLAB_HOST GITLAB_TOKEN
}
# Resolve tenant-group id from state if not passed via env.
resolve_tenant_id() {
if [[ -n "${TENANT_GROUP_ID:-}" ]]; then
return
fi
if [[ -f "${STATE_DIR}/tenant-group-id" ]]; then
TENANT_GROUP_ID="$(<"${STATE_DIR}/tenant-group-id")"
else
echo "ERROR: TENANT_GROUP_ID not set and ${STATE_DIR}/tenant-group-id not present" >&2
exit 1
fi
}
# DNS-1123-compatible subgroup name validation. GitLab itself enforces
# stricter rules; this is a fast preflight.
require_subgroup_name() {
local name="$1"
if [[ ! "${name}" =~ ^[a-z0-9]([-a-z0-9_.]*[a-z0-9])?$ ]]; then
echo "ERROR: subgroup name '${name}' must be a valid GitLab path segment (lowercase, no slashes)" >&2
exit 1
fi
}
ensure_subgroup() {
local name="$1"
local out rc
set +e
out="$(with_dns_retry glab api -X POST groups \
-f "name=${name}" \
-f "path=${name}" \
-f "parent_id=${TENANT_GROUP_ID}" \
-f "visibility=private")"
rc=$?
set -e
if [[ $rc -eq 0 ]]; then
echo "Created subgroup: ${name}"
return 0
fi
if [[ "${out}" == *"has already been taken"* ]] \
|| [[ "${out}" == *"409"* ]] \
|| [[ "${out}" == *"already exists"* ]]; then
echo "Subgroup already exists, reusing: ${name}"
return 0
fi
echo "ERROR: failed to create subgroup '${name}': ${out}" >&2
exit 1
}
main() {
prepare_glab
resolve_tenant_id
if [[ "$#" -eq 0 ]]; then
echo "No subgroups requested"
printf '[]' > "${STATE_DIR}/subgroups.json"
echo "✅ Subgroups reconciled"
return 0
fi
local materialised=()
for sg in "$@"; do
[[ -z "${sg}" ]] && continue
require_subgroup_name "${sg}"
ensure_subgroup "${sg}"
materialised+=("${sg}")
done
# Emit the materialised list as a JSON array for the Task result.
if [[ ${#materialised[@]} -eq 0 ]]; then
printf '[]' > "${STATE_DIR}/subgroups.json"
else
# jq -nc keeps the encoding compact and strict.
printf '%s\n' "${materialised[@]}" | jq -Rcn '[inputs]' > "${STATE_DIR}/subgroups.json"
fi
echo "✅ Subgroups reconciled (${#materialised[@]})"
}
main "$@"
- args:
- $(params.scopes[*])
computeResources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
env:
- name: CONNECTOR
value: $(params.connector)
- name: ACCESS_LEVEL
value: $(params.accessLevel)
- name: TOKEN_DURATION_DAYS
value: $(params.tokenDuration)
- name: ACCESS_TOKEN_NAME
value: $(params.accessTokenName)
- name: VERBOSE
value: $(params.verbose)
- name: GLAB_CONFIG_DIR
value: $(workspaces.gitlabconfig.path)
- name: GLAB_CHECK_UPDATE
value: "false"
- name: GLAB_SEND_TELEMETRY
value: "false"
- name: STATE_DIR
value: /workspace/state
- name: SECRETS_DIR
value: /workspace/secrets
- name: SUBGROUPS_JSON_FILE
value: /workspace/state/subgroups.json
image: $(params.gitlabCliImage)
imagePullPolicy: $(params.imagePullPolicy)
name: ensure-gat
script: |
#!/usr/bin/env bash
# ensure-gat reads the tenant-group id written by step 1.
TENANT_GROUP_ID="$(cat /workspace/state/tenant-group-id)"
export TENANT_GROUP_ID
# Reassemble scopes from positional args. The Task contract treats
# scopes as an array param; passing them via args avoids Tekton's
# array-in-env validation rejection.
SCOPES="$*"
export SCOPES
#!/usr/bin/env bash
# ensure-gat.sh -- step 3 of gitlab-connector-automatic-creation/0.1
#
# Three-path Group Access Token lifecycle (per design.md):
#
# 1. List existing GATs at TENANT_GROUP_ID via `glab api`.
# 2. Find the GAT whose name encodes this Task's identity.
# a. matched + not expired -> ROTATE in place via
# POST /groups/:id/access_tokens/:token_id/rotate
# b. matched + expired -> fall through to RECREATE
# (revoke old + mint fresh) -- self-heal
# c. exists with different
# identity -> RECREATE (revoke + mint fresh)
# d. no match -> CREATE fresh
#
# Identity = { ACCESS_LEVEL, SCOPES, SUBGROUPS[] }. Other inputs
# (TOKEN_DURATION_DAYS, SECRET name) are NOT identity-affecting.
#
# The GAT name is `connector-<conn_ns>-<conn_name>/<identity>`. The
# `connector-<conn_ns>-<conn_name>` prefix mirrors Harbor's
# `connector-<ns>-<name>` robot-account naming convention -- so the
# Task does not need a separate `taskIdentityTag` param to find its
# own tokens across reruns.
#
# Inputs (env):
# TENANT_GROUP_ID -- numeric id from ensure-group.sh state file
# CONNECTOR -- <namespace>/<name> ref of the tenant
# Connector; used to derive the GAT name
# prefix.
# ACCESS_LEVEL -- guest|reporter|developer|maintainer|owner
# SCOPES -- space- or comma-separated GitLab scope set
# TOKEN_DURATION_DAYS -- string integer of days; computed expiry
# is `today UTC + N days`. Capped by GitLab
# instance policy.
# SUBGROUPS_JSON_FILE -- path to JSON array from ensure-subgroups.sh
# VERBOSE
# GLAB_CONFIG_DIR
# Outputs:
# writes the GAT secret to /workspace/secrets/token (tmpfs, mode 0600)
# writes the GAT id to /workspace/state/access-token-id
#
# IMPORTANT: this script handles the tenant-side GAT secret. We
# explicitly disable xtrace before any code path that touches the
# token; verbose tracing only wraps non-secret API calls.
set -euo pipefail
set +o xtrace 2>/dev/null || true
: "${TENANT_GROUP_ID:?ensure-gat: TENANT_GROUP_ID is required}"
: "${CONNECTOR:?ensure-gat: CONNECTOR is required (<namespace>/<name>)}"
: "${ACCESS_LEVEL:=owner}"
: "${SCOPES:=api}"
: "${TOKEN_DURATION_DAYS:=30}"
: "${ACCESS_TOKEN_NAME:=}"
: "${SUBGROUPS_JSON_FILE:=/workspace/state/subgroups.json}"
: "${VERBOSE:=false}"
: "${GLAB_CONFIG_DIR:=/workspace/gitlabconfig}"
: "${STATE_DIR:=/workspace/state}"
: "${SECRETS_DIR:=/workspace/secrets}"
# Validate ACCESS_LEVEL up front, in the script (no command substitution),
# so a bad value fails fast. access_level_int's `exit 2` is otherwise
# swallowed when called inside `$(...)` -- exit only kills the subshell,
# the parent script proceeds and mints a GAT with the bad value embedded
# in its identity hash.
case "${ACCESS_LEVEL,,}" in
guest|reporter|developer|maintainer|owner) ;;
*)
echo "ERROR: invalid accessLevel '${ACCESS_LEVEL}' (expected guest|reporter|developer|maintainer|owner)" >&2
exit 2
;;
esac
mkdir -p "${STATE_DIR}" "${SECRETS_DIR}"
# Token files written below get umask 077 explicitly; no dir-level chmod
# needed (and chmodding an emptyDir owned by a different fsGroup fails
# with EPERM on some clusters).
# Logs follow Harbor's narrative style. See ensure-group.sh top comment.
# RESULT: ... lines below are machine-readable contract markers consumed
# by tektoncd.feature/script.feature BDD log-pattern checks; keep verbatim.
# See ensure-group.sh:with_dns_retry for rationale.
with_dns_retry() {
local out rc attempt=1
while :; do
set +e
out="$("$@" 2>&1)"
rc=$?
set -e
if [[ ${rc} -eq 0 ]] || [[ ${attempt} -ge 3 ]]; then break; fi
case "${out}" in
*"no such host"*|*"server misbehaving"*|*"i/o timeout"*|*"dial tcp: lookup"*|*"Could not resolve host"*|*"Temporary failure in name resolution"*)
echo "WARN: transient DNS failure (attempt ${attempt}/3), retrying in 3s" >&2
sleep 3
attempt=$((attempt + 1))
continue
;;
esac
break
done
printf '%s' "${out}"
return ${rc}
}
prepare_glab() {
# See ensure-group.sh:prepare_glab — host + token both come from the
# gitlabconfig workspace's config.yml so the token issuer matches the
# host (proxy URL + SAJWT in CSI mode; public URL + real PAT in
# static-Secret mode). DO NOT derive host from params.gitlabUrl --
# that was the DEVOPS-44155 regression that broke CSI mode (proxy-only
# SAJWT sent directly to public GitLab -> 401).
local glab_home="${HOME:-/tmp}/.config/glab-cli"
mkdir -p "${glab_home}"
if [[ ! -f "${GLAB_CONFIG_DIR}/config.yml" ]]; then
echo "ERROR: glab config not found at ${GLAB_CONFIG_DIR}/config.yml" >&2
exit 1
fi
cp "${GLAB_CONFIG_DIR}/config.yml" "${glab_home}/config.yml"
chmod 600 "${glab_home}/config.yml"
export GLAB_CONFIG_DIR="${glab_home}"
# See ensure-group.sh:prepare_glab for the awk shape -- yields the
# first sub-key under `hosts:` (the admin host, port-tolerant) and the
# matching token. Replicated here verbatim to keep all three step
# scripts self-contained for `kubectl debug` workflows.
GITLAB_HOST=$(awk '
/^hosts:[[:space:]]*$/ { in_hosts=1; next }
in_hosts && /^[^[:space:]]/ { exit }
in_hosts && /^ [^[:space:]].*:[[:space:]]*$/ {
sub(/^ /, "")
sub(/:[[:space:]]*$/, "")
print
exit
}
' "${glab_home}/config.yml")
GITLAB_TOKEN=$(awk '/^ token:[[:space:]]/ { print $2; exit }' "${glab_home}/config.yml")
if [[ -z "${GITLAB_HOST}" || -z "${GITLAB_TOKEN}" ]]; then
echo "ERROR: could not parse host/token from ${glab_home}/config.yml" >&2
exit 1
fi
export GITLAB_HOST GITLAB_TOKEN
}
# GitLab REST `access_level` integer mapping.
access_level_int() {
case "${1,,}" in
guest) printf '10' ;;
reporter) printf '20' ;;
developer) printf '30' ;;
maintainer) printf '40' ;;
owner) printf '50' ;;
*) echo "ERROR: invalid accessLevel '$1' (expected guest|reporter|developer|maintainer|owner)" >&2; exit 2 ;;
esac
}
# Normalise SCOPES into a comma-free, sorted, space-joined list so that
# scope-set comparison is stable (identity check uses this).
normalise_scopes() {
printf '%s' "$1" | tr ',' ' ' | tr -s ' ' '\n' \
| awk 'NF' | sort -u | paste -sd ' ' -
}
# Compute the identity hash that gets embedded in the GAT name. We do
# NOT cryptographically hash; we just emit a short, stable, plain
# string that is easy to grep in GitLab's GAT list UI. Using a plain
# encoding of (accessLevel, scopes, subgroups) keeps the identity check
# trivially auditable.
compute_identity() {
local subs scopes
scopes="$(normalise_scopes "${SCOPES}")"
if [[ -f "${SUBGROUPS_JSON_FILE}" ]]; then
subs="$(jq -r '. | sort | join(",")' < "${SUBGROUPS_JSON_FILE}")"
else
subs=""
fi
printf 'al=%s|scopes=%s|subs=%s' "${ACCESS_LEVEL,,}" "${scopes// /,}" "${subs}"
}
# Derive the GAT name prefix. Driver may override via ACCESS_TOKEN_NAME
# (param `accessToken`); empty falls back to `connector-<ns>-<name>`.
# Mirrors Harbor's `robotAccount` parameter pattern.
make_token_prefix() {
if [[ -n "${ACCESS_TOKEN_NAME}" ]]; then
printf '%s' "${ACCESS_TOKEN_NAME}"
return
fi
local conn_ns conn_name
conn_ns="${CONNECTOR%%/*}"
conn_name="${CONNECTOR#*/}"
printf 'connector-%s-%s' "${conn_ns}" "${conn_name}"
}
# Token names embed prefix + identity so reruns can locate "this Task's"
# tokens without colliding with manually-created GATs.
make_token_name() {
printf '%s/%s' "$(make_token_prefix)" "$(compute_identity)"
}
# Compute expires_at = today UTC + TOKEN_DURATION_DAYS. Empty/zero days
# triggers an explicit error -- GitLab GATs require a non-empty expiry.
# Uses epoch arithmetic so it works against GNU date, BSD date, AND
# busybox date (the catalog glab image is alpine-based -- busybox).
compute_token_expiry() {
local days="${TOKEN_DURATION_DAYS}"
if [[ -z "${days}" || ! "${days}" =~ ^[0-9]+$ || "${days}" -le 0 ]]; then
echo "ERROR: tokenDuration must be a positive integer number of days (got '${TOKEN_DURATION_DAYS}'); GitLab GATs require a non-empty expiry" >&2
exit 2
fi
local now future
now="$(date -u +%s)"
future=$((now + days * 86400))
# `-d @<epoch>` is supported by GNU date AND busybox date.
date -u -d "@${future}" +%Y-%m-%d 2>/dev/null \
|| date -u -r "${future}" +%Y-%m-%d
}
# Reuse the host + token already exported by prepare_glab() (so we
# don't reparse the config two ways). Falls back to a config-file scan
# for git_protocol since prepare_glab doesn't track that.
read_glab_host_and_token() {
if [[ -z "${GITLAB_HOST:-}" || -z "${GITLAB_TOKEN:-}" ]]; then
echo "ERROR: GITLAB_HOST/GITLAB_TOKEN not exported -- prepare_glab must run first" >&2
exit 1
fi
GLAB_HOST="${GITLAB_HOST}"
GLAB_TOKEN="${GITLAB_TOKEN}"
local cfg="${GLAB_CONFIG_DIR}/config.yml"
# awk's FS=[: ]+ collapses runs of [: ] into one separator, so on the
# line " api_protocol: https" $1 is empty (pre-space), $2 is the
# key "api_protocol", $3 is the value "https".
GLAB_PROTOCOL="$(awk -F'[: ]+' '/^[[:space:]]*api_protocol:[[:space:]]/{print $3; exit}' "${cfg}")"
if [[ -z "${GLAB_PROTOCOL}" ]]; then
GLAB_PROTOCOL="https"
fi
}
list_gats() {
with_dns_retry glab api "groups/${TENANT_GROUP_ID}/access_tokens"
}
# Find the GAT (if any) whose name matches the Task's prefix. We match
# on the prefix rather than the full identity so that an
# identity-affecting change still yields a hit (returning the stale
# token id) which we then recreate. Skips revoked tokens because the
# GitLab API still lists them but rejects rotate/delete on them
# ("400 Bad request - Token already revoked").
#
# IMPORTANT: validate the response IS a JSON array before piping into
# `jq '[.[] | …]'`. When the admin token lacks the required scope or
# `owner` access on the tenant group (Pattern B mis-fixture), GitLab
# returns `{"message":"403 Forbidden"}` (a JSON object, not an array)
# and `glab api` exits non-zero. Without this guard, jq iterates the
# object's values and emits an opaque
# `Cannot index string with string "revoked"`
# message instead of the real API error.
find_matching_gat() {
local prefix raw rc api_msg
prefix="$(make_token_prefix)/"
set +e
raw="$(list_gats)"
rc=$?
set -e
if [[ ${rc} -ne 0 ]]; then
api_msg="$(printf '%s' "${raw}" | jq -r '.message // .error // empty' 2>/dev/null || true)"
[[ -z "${api_msg}" ]] && api_msg="${raw}"
echo "ERROR: GitLab refused to list access tokens at group id=${TENANT_GROUP_ID}: ${api_msg}" >&2
echo " admin Connector likely lacks 'api' scope or 'owner' access on the tenant group." >&2
exit 1
fi
if ! printf '%s' "${raw}" | jq -e 'type == "array"' >/dev/null 2>&1; then
api_msg="$(printf '%s' "${raw}" | jq -r '.message // .error // empty' 2>/dev/null || true)"
[[ -z "${api_msg}" ]] && api_msg="${raw}"
echo "ERROR: unexpected GitLab response when listing access tokens at group id=${TENANT_GROUP_ID}: ${api_msg}" >&2
echo " admin Connector likely lacks the permissions required to mint a Group Access Token at this group." >&2
exit 1
fi
printf '%s' "${raw}" | jq --arg prefix "${prefix}" \
'[.[] | select(.revoked == false and (.name | startswith($prefix)))] | first // null'
}
is_expired_now() {
# GitLab returns "expires_at": "YYYY-MM-DD" (UTC). A null expiry
# means non-expiring -- treat as not expired.
local expires_at="$1"
[[ -z "${expires_at}" || "${expires_at}" == "null" ]] && return 1
local today; today="$(date -u +%Y-%m-%d)"
# Lexical compare on YYYY-MM-DD is correct.
[[ "${expires_at}" < "${today}" ]]
}
mint_gat() {
local name="$1"
read_glab_host_and_token
# Compose form-encoded body; scopes[]= repeats once per scope.
local body
body="$(printf 'name=%s' "$(printf '%s' "${name}" | jq -sRr '@uri')")"
body+="$(printf '&access_level=%s' "$(access_level_int "${ACCESS_LEVEL}")")"
local scope; for scope in $(normalise_scopes "${SCOPES}"); do
body+="$(printf '&scopes[]=%s' "${scope}")"
done
body+="$(printf '&expires_at=%s' "$(compute_token_expiry)")"
# Trace OFF for the mint call (response carries the secret).
set +x
local url="${GLAB_PROTOCOL}://${GLAB_HOST}/api/v4/groups/${TENANT_GROUP_ID}/access_tokens"
local resp http_code
local curl_rc
set +e
resp="$(with_dns_retry curl -skS -o /tmp/mint_resp -w '%{http_code}' \
-X POST "${url}" \
-H "PRIVATE-TOKEN: ${GLAB_TOKEN}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "${body}")"
curl_rc=$?
set -e
if [[ ${curl_rc} -ne 0 ]] || [[ ! -s /tmp/mint_resp ]]; then
echo "ERROR: GitLab GAT mint request failed before HTTP response: rc=${curl_rc} curl_out=${resp}" >&2
rm -f /tmp/mint_resp
exit 1
fi
http_code="${resp}"
resp="$(cat /tmp/mint_resp)"
rm -f /tmp/mint_resp
if [[ "${http_code}" != "200" && "${http_code}" != "201" ]]; then
if [[ "${resp}" == *"quota"* ]] || [[ "${resp}" == *"limit"* ]]; then
echo "ERROR: GitLab refused to mint GAT (token quota likely exhausted at this group): HTTP ${http_code} ${resp}" >&2
echo " Recommend revoking unused tokens at this tenant group." >&2
exit 1
fi
if [[ "${resp}" == *"expires_at"* ]]; then
echo "ERROR: GitLab rejected token expiry; check the instance-wide max_token_expiry policy: HTTP ${http_code} ${resp}" >&2
exit 1
fi
echo "ERROR: GitLab refused to mint GAT: HTTP ${http_code} ${resp}" >&2
exit 1
fi
local id token
id="$(printf '%s' "${resp}" | jq -r '.id')"
token="$(printf '%s' "${resp}" | jq -r '.token')"
if [[ -z "${id}" || "${id}" == "null" || -z "${token}" || "${token}" == "null" ]]; then
echo "ERROR: GitLab response missing id/token after mint" >&2
exit 1
fi
printf '%s' "${id}" > "${STATE_DIR}/access-token-id"
umask 077
printf '%s' "${token}" > "${SECRETS_DIR}/token"
chmod 600 "${SECRETS_DIR}/token"
}
revoke_gat() {
local id="$1"
with_dns_retry glab api -X DELETE "groups/${TENANT_GROUP_ID}/access_tokens/${id}" >/dev/null
}
rotate_gat() {
local id="$1"
set +x
local resp
if ! resp="$(with_dns_retry glab api -X POST "groups/${TENANT_GROUP_ID}/access_tokens/${id}/rotate")"; then
echo "ERROR: GitLab refused to rotate GAT id=${id}: ${resp}" >&2
exit 1
fi
local new_id token
new_id="$(printf '%s' "${resp}" | jq -r '.id')"
token="$(printf '%s' "${resp}" | jq -r '.token')"
if [[ -z "${new_id}" || "${new_id}" == "null" || -z "${token}" || "${token}" == "null" ]]; then
echo "ERROR: GitLab rotate response missing id/token" >&2
exit 1
fi
printf '%s' "${new_id}" > "${STATE_DIR}/access-token-id"
umask 077
printf '%s' "${token}" > "${SECRETS_DIR}/token"
chmod 600 "${SECRETS_DIR}/token"
}
main() {
prepare_glab
local desired_name; desired_name="$(make_token_name)"
printf '%s' "${desired_name}" > "${STATE_DIR}/access-token-name"
local existing existing_id existing_name existing_expires
existing="$(find_matching_gat)"
if [[ "${existing}" == "null" || -z "${existing}" ]]; then
echo "No existing access token for this Task — minting new"
mint_gat "${desired_name}"
echo "Minted access token id=$(<"${STATE_DIR}/access-token-id") name=${desired_name}"
echo "RESULT: ensure-gat status=created reason=no-existing-gat"
echo "✅ Access token ready"
return 0
fi
existing_id="$(printf '%s' "${existing}" | jq -r '.id')"
existing_name="$(printf '%s' "${existing}" | jq -r '.name')"
existing_expires="$(printf '%s' "${existing}" | jq -r '.expires_at // empty')"
if is_expired_now "${existing_expires}"; then
echo "Existing access token id=${existing_id} expired on ${existing_expires} — recreating"
revoke_gat "${existing_id}"
mint_gat "${desired_name}"
echo "Recreated access token id=$(<"${STATE_DIR}/access-token-id") name=${desired_name}"
echo "RESULT: ensure-gat status=recreated reason=expired-fallthrough"
echo "✅ Access token ready"
return 0
fi
if [[ "${existing_name}" != "${desired_name}" ]]; then
echo "Existing access token name=${existing_name} differs from desired=${desired_name} — recreating (identity changed)"
revoke_gat "${existing_id}"
mint_gat "${desired_name}"
echo "Recreated access token id=$(<"${STATE_DIR}/access-token-id") name=${desired_name}"
echo "RESULT: ensure-gat status=recreated reason=identity-changed"
echo "✅ Access token ready"
return 0
fi
echo "Existing access token id=${existing_id} matches identity — rotating in place"
rotate_gat "${existing_id}"
echo "Rotated access token id=$(<"${STATE_DIR}/access-token-id") name=${desired_name}"
echo "RESULT: ensure-gat status=rotated reason=identity-match"
echo "✅ Access token ready"
}
main "$@"
- args:
- $(params.basicAuthSecrets[*])
computeResources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
env:
- name: CONNECTOR
value: $(params.connector)
- name: SECRET
value: $(params.secret)
- name: VERBOSE
value: $(params.verbose)
- name: GLAB_CONFIG_DIR
value: $(workspaces.gitlabconfig.path)
- name: GITLAB_URL
value: $(params.gitlabUrl)
- name: SECRETS_DIR
value: /workspace/secrets
- name: TOKEN_FILE
value: /workspace/secrets/token
- name: SUBGROUPS_JSON_FILE
value: /workspace/state/subgroups.json
- name: WORKSPACE_KUBECONFIG_BOUND
value: $(workspaces.kubeconfig.bound)
- name: WORKSPACE_KUBECONFIG_PATH
value: $(workspaces.kubeconfig.path)
- name: RESULT_TENANT_GROUP_PATH
value: $(results.tenant-group.path)
- name: RESULT_SUBGROUPS_PATH
value: $(results.subgroups.path)
- name: RESULT_ACCESS_TOKEN_NAME_PATH
value: $(results.access-token-name.path)
- name: RESULT_CONNECTOR_REF_PATH
value: $(results.connector-ref.path)
- name: RESULT_BASIC_AUTH_SECRET_REFS_PATH
value: $(results.basic-auth-secret-refs.path)
image: $(params.kubectlImage)
imagePullPolicy: $(params.imagePullPolicy)
name: apply-kubernetes-resources
script: |
#!/usr/bin/env bash
# apply-kubernetes-resources reads tenant-group path + GAT id from
# the in-memory state volume and the GAT itself from the tmpfs
# secrets volume. The tenant Connector's spec.address is the
# required params.gitlabUrl (no fallback). The admin glab host
# is read from the gitlabconfig workspace's config.yml -- NOT
# from params.gitlabUrl (DEVOPS-44155 proxy-path regression).
# No `kubectl get connector` lookup, no admin PAT in env.
TENANT_GROUP_PATH="$(cat /workspace/state/tenant-group-path)"
export TENANT_GROUP_PATH
ACCESS_TOKEN_ID="$(cat /workspace/state/access-token-id)"
export ACCESS_TOKEN_ID
ACCESS_TOKEN_NAME="$(cat /workspace/state/access-token-name)"
export ACCESS_TOKEN_NAME
#!/usr/bin/env bash
# apply-kubernetes-resources.sh -- step 4 of gitlab-connector-automatic-creation/0.1
#
# Reads the freshly-minted tenant GAT from /workspace/secrets/token and
# server-side-applies the tenant `Connector` (kind: Connector,
# apiVersion: connectors.alauda.io/v1alpha1) plus the auth Secret of
# class `connectors.cpaas.io/connector-class: gitlab-pat-auth`.
#
# Discovery model:
# - The tenant Connector's spec.address is the required `params.gitlabUrl`
# verbatim (the legacy `connector_address` fallback in the gitlabconfig
# workspace was removed in DEVOPS-44155).
# - The admin glab host is read STRICTLY from the gitlabconfig workspace's
# `config.yml` (CSI mounts in-cluster proxy; static-Secret mounts public
# host); params.gitlabUrl MUST NOT be used as a glab API host -- a CSI
# mount issues a K8s SAJWT that only the in-cluster proxy can validate,
# so sending it to a public host returns 401 across every step (this was
# the DEVOPS-44155 proxy-path regression).
# - No `kubectl get connector` lookup; no admin PAT/GAT in env.
# - Cluster apply uses KUBECONFIG from the optional `kubeconfig`
# workspace if bound (kubernetes-class Connector mount); falls back to
# the in-cluster service-account context otherwise.
#
# Inputs (env):
# CONNECTOR -- <namespace>/<name> ref of the tenant
# Connector to create or update.
# SECRET -- name of the auth Secret in the connector
# namespace; defaults to
# <connector-name>-secret. Bare name only;
# <namespace>/<name> refs are rejected so a
# typo cannot land the GAT outside the
# connector namespace (T4 mitigation).
# TENANT_GROUP_PATH -- the materialised tenant group path (used
# in a Connector annotation for traceability).
# ACCESS_TOKEN_ID -- the GitLab GAT id (recorded as a Secret
# annotation for revocation lookup).
# ACCESS_TOKEN_NAME -- the GitLab GAT name (`<prefix>/<identity>`),
# emitted as the access-token-name result.
# GLAB_CONFIG_DIR -- path of the gitlabconfig workspace.
# WORKSPACE_KUBECONFIG_BOUND -- "true" when the optional kubeconfig
# workspace is bound.
# WORKSPACE_KUBECONFIG_PATH -- path of the optional kubeconfig
# workspace; the file is named `kubeconfig`.
# VERBOSE -- "true" to log progress.
#
# Inputs (files):
# ${GLAB_CONFIG_DIR}/config.yml -- glab config; carries the admin host
# (the `hosts.<host>:` key) and PAT/SAJWT
# (under `hosts.<host>.token`). Read by the
# earlier ensure-*.sh steps; this script
# does not touch it directly. params.gitlabUrl
# is intentionally NOT used as a glab API
# host -- see Discovery model above.
# /workspace/secrets/token -- tmpfs file (medium=Memory) holding the
# GAT secret. Removed in trap-on-EXIT.
#
# IMPORTANT: tracing is disabled before the secret-bearing code paths.
set -euo pipefail
set +o xtrace 2>/dev/null || true
: "${CONNECTOR:?apply-kubernetes-resources: CONNECTOR is required (<namespace>/<name>)}"
: "${SECRET:=}"
: "${TENANT_GROUP_PATH:=}"
: "${ACCESS_TOKEN_ID:=}"
: "${ACCESS_TOKEN_NAME:=}"
: "${VERBOSE:=false}"
: "${GLAB_CONFIG_DIR:=/workspace/gitlabconfig}"
: "${SECRETS_DIR:=/workspace/secrets}"
: "${TOKEN_FILE:=${SECRETS_DIR}/token}"
: "${WORKSPACE_KUBECONFIG_BOUND:=false}"
: "${WORKSPACE_KUBECONFIG_PATH:=}"
: "${GITLAB_URL:=}"
# Always rm -f the tmpfs token file on exit -- success or fail.
cleanup() {
rm -f "${TOKEN_FILE}" 2>/dev/null || true
}
trap cleanup EXIT
require_ref() {
local name="$1" value="$2"
if [[ ! "$value" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; then
echo "ERROR: ${name} must be <namespace>/<name> with DNS-1123 names, got '${value}'" >&2
exit 1
fi
}
# Logs follow Harbor's narrative style. See ensure-group.sh top comment.
# Optional kubeconfig from a kubernetes-class Connector workspace; falls
# back to the in-cluster service-account context if the workspace is
# unbound. Mirrors Harbor's prepare_kubeconfig_environment.
prepare_kubeconfig() {
if [[ "${WORKSPACE_KUBECONFIG_BOUND}" != "true" ]]; then
unset KUBECONFIG || true
return
fi
if [[ -z "${WORKSPACE_KUBECONFIG_PATH}" || ! -f "${WORKSPACE_KUBECONFIG_PATH}/kubeconfig" ]]; then
echo "ERROR: kubeconfig workspace bound but ${WORKSPACE_KUBECONFIG_PATH}/kubeconfig is missing" >&2
exit 1
fi
export KUBECONFIG="${WORKSPACE_KUBECONFIG_PATH}/kubeconfig"
}
# Determine the tenant Connector's spec.address from the required
# params.gitlabUrl (DEVOPS-44155 dropped the gitlabconfig-workspace
# fallback). Empty, non-http(s), or missing-host is fail-fast -- a
# scheme-only "https://" must NOT slip through to spec.address.
load_admin_endpoint() {
if [[ -z "${GITLAB_URL}" ]]; then
echo "ERROR: params.gitlabUrl is required and must be a fully-qualified http(s) URL" >&2
exit 1
fi
if [[ ! "${GITLAB_URL}" =~ ^https?://[A-Za-z0-9._-]+(:[0-9]+)?(/.*)?$ ]]; then
echo "ERROR: params.gitlabUrl is not a valid URL: '${GITLAB_URL}'" >&2
exit 1
fi
ADMIN_CONNECTOR_ENDPOINT="${GITLAB_URL}"
export ADMIN_CONNECTOR_ENDPOINT
}
# ssar_assert -- run `kubectl auth can-i VERB RESOURCE -n NS` and exit 1
# if the answer is anything other than an unambiguous "yes". The
# important property: a kubectl/apiserver failure (unreachable, TLS
# handshake, bad KUBECONFIG) is NOT silently bucketed into "no RBAC" --
# we surface the raw stderr so the operator sees the real cause
# instead of a misleading "ensure the namespace exists and the Task
# ServiceAccount has RBAC" message. RBAC denial (the case the caller
# is gating on) is reported as the caller's `ctx` message.
#
# args: VERB RESOURCE NAMESPACE CTX
ssar_assert() {
local verb="$1" resource="$2" ns="$3" ctx="$4"
local out rc=0
out=$(kubectl auth can-i "${verb}" "${resource}" -n "${ns}" 2>&1) || rc=$?
# kubectl returns rc=0 + stdout="yes" when allowed.
if [[ ${rc} -eq 0 && "${out}" == "yes" ]]; then
return 0
fi
# rc!=0 and stdout starts with "no" -- the documented RBAC-denied shape.
# Also accept "no - ..." (kubectl appends a reason on some builds).
if [[ "${out}" == "no" || "${out}" == no\ * ]]; then
echo "ERROR: ${ctx} (SSAR: cannot ${verb} ${resource} in namespace '${ns}')" >&2
exit 1
fi
# Anything else is a kubectl / apiserver problem, not RBAC -- bubble it up
# verbatim so the operator can diagnose without confusing it with a missing
# binding.
echo "ERROR: ${ctx} -- kubectl auth can-i ${verb} ${resource} -n ${ns} failed: ${out}" >&2
exit 1
}
# Sync the tenant GAT into one or more <ns>/<name> Secret targets as
# kubernetes.io/basic-auth (username=oauth2, password=<gat>).
#
# Two-phase design (closes the partial-write window):
#
# Phase 1 -- pre-flight: format-validate every entry AND run SSAR for
# `create + patch on secrets` against every target namespace BEFORE
# any kubectl apply. If ANY entry fails the pre-flight, we fail-fast
# before mutating the cluster -- the resulting state is "no Secrets
# written" for the whole list. Server-side apply uses HTTP PATCH for
# both create and update cycles, so the verbs that actually gate
# `kubectl apply --server-side` are `create` (first-time POST when
# the object does not exist yet) and `patch` (subsequent idempotent
# applies). The `update` verb is NOT exercised by SSA -- gating on
# it would let an SSA-capable principal pass the SSAR and then
# surface a Forbidden at apply time, exactly the regression caught
# in DEVOPS-44155 round-2 manual testing case M11.
#
# Phase 2 -- apply: write Secrets in order. Once phase 1 has
# green-lit every entry, the only remaining failure modes are
# apiserver transients or admission-webhook rejections; those still
# short-circuit the loop and we record what was already synced in
# the result file so downstream consumers can see the partial state.
#
# Additive semantics: removing an entry from a later TaskRun does NOT
# delete the previously synced Secret -- this Task only writes;
# deletion is the consumer's responsibility.
sync_basic_auth_secrets() {
local token="$1"
shift
local refs=("$@")
local synced=()
# Always materialise the result file so downstream Pipeline steps
# have a deterministic artefact to inspect -- empty input yields []
# rather than a missing file.
_write_secret_refs_result() {
if [[ -z "${RESULT_BASIC_AUTH_SECRET_REFS_PATH:-}" ]]; then
return 0
fi
if [[ ${#synced[@]} -eq 0 ]]; then
printf '[]' > "${RESULT_BASIC_AUTH_SECRET_REFS_PATH}"
else
printf '%s\n' "${synced[@]}" \
| jq -R -s -c 'split("\n") | map(select(length > 0))' \
> "${RESULT_BASIC_AUTH_SECRET_REFS_PATH}"
fi
}
# Empty / unset basicAuthSecrets -- nothing to do beyond writing [].
if [[ ${#refs[@]} -eq 0 ]]; then
_write_secret_refs_result
return 0
fi
# --- Phase 1: pre-flight validation -----------------------------------
local i ref target_ns target_name
local -a parsed_refs=() parsed_ns=() parsed_name=()
for ref in "${refs[@]}"; do
if [[ -z "${ref}" ]]; then
continue
fi
if [[ ! "${ref}" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; then
echo "ERROR: basicAuthSecrets entry '${ref}' must be <namespace>/<name> with DNS-1123 names" >&2
exit 1
fi
target_ns="${ref%%/*}"
target_name="${ref#*/}"
parsed_refs+=("${ref}")
parsed_ns+=("${target_ns}")
parsed_name+=("${target_name}")
done
if [[ ${#parsed_refs[@]} -eq 0 ]]; then
_write_secret_refs_result
return 0
fi
# SSAR every (ns, verb) pair before applying ANY Secret. This is the
# gate that closes the partial-write window -- a downstream RBAC
# denial on entry N must not leave entries 1..N-1 already on the
# cluster.
for i in "${!parsed_refs[@]}"; do
ssar_assert create secrets "${parsed_ns[$i]}" \
"cannot create Secrets in namespace '${parsed_ns[$i]}' for basicAuthSecrets entry '${parsed_refs[$i]}'"
ssar_assert patch secrets "${parsed_ns[$i]}" \
"cannot patch Secrets in namespace '${parsed_ns[$i]}' for basicAuthSecrets entry '${parsed_refs[$i]}' (server-side apply requires patch RBAC)"
done
# --- Phase 2: apply ---------------------------------------------------
local secret_yaml
for i in "${!parsed_refs[@]}"; do
set +x
# The GAT-id annotation is set whenever ensure-gat.sh populated
# ACCESS_TOKEN_ID earlier in the same TaskRun -- which is the
# production path. The conditional only drops the annotation when
# the helper is being sourced from a unit-test fixture that did
# not exercise the GAT mint step (script.feature). Keeping the
# annotation on the cross-NS Secrets is critical for the
# revocation flow: an operator chasing a leaked credential needs
# to be able to grep all Secrets labelled `managed-by:
# gitlab-connector-automatic-creation` and recover the source GAT
# id from the annotation.
secret_yaml="$(jq -n \
--arg ns "${parsed_ns[$i]}" \
--arg name "${parsed_name[$i]}" \
--arg user "oauth2" \
--arg pass "${token}" \
--arg gat_id "${ACCESS_TOKEN_ID}" \
--arg connector "${CONNECTOR}" \
'{
apiVersion: "v1",
kind: "Secret",
metadata: {
name: $name,
namespace: $ns,
labels: {
"connectors.cpaas.io/managed-by": "gitlab-connector-automatic-creation",
"connectors.cpaas.io/source-connector-namespace": ($connector | split("/")[0]),
"connectors.cpaas.io/source-connector-name": ($connector | split("/")[1])
},
annotations: (if $gat_id == "" then {} else {"connectors.cpaas.io/gitlab-access-token-id": $gat_id} end)
},
type: "kubernetes.io/basic-auth",
stringData: { username: $user, password: $pass }
}')"
if ! printf '%s\n' "${secret_yaml}" \
| kubectl apply --server-side --field-manager=connectors-operator -f -; then
# Apply failed mid-loop. Record what was already synced before
# bailing so consumers can audit the partial state.
_write_secret_refs_result
echo "ERROR: kubectl apply for basic-auth Secret ${parsed_refs[$i]} failed" >&2
exit 1
fi
echo "Synced basic-auth Secret ${parsed_refs[$i]}"
synced+=("${parsed_refs[$i]}")
done
_write_secret_refs_result
}
main() {
require_ref CONNECTOR "${CONNECTOR}"
local conn_ns conn_name secret_name
conn_ns="${CONNECTOR%%/*}"
conn_name="${CONNECTOR#*/}"
# T4 mitigation: SECRET is a bare resource name in the connector
# namespace; reject any value that contains '/' so a typo cannot
# land the GAT outside the connector namespace.
if [[ "${SECRET}" == */* ]]; then
echo "ERROR: secret param '${SECRET}' must be a bare name (no namespace prefix); the Secret is always created in the connector namespace '${conn_ns}'" >&2
exit 1
fi
if [[ -z "${SECRET}" ]]; then
secret_name="${conn_name}-secret"
else
secret_name="${SECRET}"
fi
if [[ ! -f "${TOKEN_FILE}" ]]; then
echo "ERROR: tenant GAT not found at ${TOKEN_FILE} -- did step 3 (ensure-gat.sh) run?" >&2
exit 1
fi
prepare_kubeconfig
load_admin_endpoint
# T4 mitigation: confirm we have permission to write into the connector
# namespace before we apply anything. SelfSubjectAccessReview itself is
# callable by every authenticated user (no extra RBAC needed to RUN the
# check) -- what it surfaces is whether THIS Task's ServiceAccount has
# `secrets:create` granted in `conn_ns`. ssar_assert distinguishes
# genuine RBAC denial from kubectl/apiserver failures so a misconfigured
# KUBECONFIG does not masquerade as a missing RoleBinding.
ssar_assert create secrets "${conn_ns}" \
"cannot create secrets in namespace '${conn_ns}' (check that the namespace exists and that the Task's ServiceAccount has RBAC to write Secret + Connector there)"
# Read the token without leaking via set -x. Don't echo it.
set +x
local token
token="$(<"${TOKEN_FILE}")"
if [[ -z "${token}" ]]; then
echo "ERROR: tenant GAT file at ${TOKEN_FILE} is empty" >&2
exit 1
fi
echo "Applying Kubernetes resources for connector ${CONNECTOR} using secret ${secret_name}"
# Build the Secret manifest in-memory and pipe to kubectl apply.
# We use stringData so kubectl handles base64 encoding for us; we
# never base64-encode in shell to avoid leaving the token in argv.
local secret_yaml
secret_yaml="$(jq -n \
--arg ns "${conn_ns}" \
--arg name "${secret_name}" \
--arg token "${token}" \
--arg gat_id "${ACCESS_TOKEN_ID}" \
'{
apiVersion: "v1",
kind: "Secret",
metadata: {
name: $name,
namespace: $ns,
labels: {
"connectors.cpaas.io/connector-class": "gitlab-pat-auth"
},
annotations: ({} + (if $gat_id == "" then {} else {"connectors.cpaas.io/gitlab-access-token-id": $gat_id} end))
},
type: "connectors.cpaas.io/gitlab-pat-auth",
stringData: { token: $token }
}')"
if ! printf '%s\n' "${secret_yaml}" \
| kubectl apply --server-side --field-manager=connectors-operator -f -; then
echo "ERROR: kubectl apply Secret failed" >&2
exit 1
fi
local connector_yaml
connector_yaml="$(cat <<EOF
apiVersion: connectors.alauda.io/v1alpha1
kind: Connector
metadata:
name: ${conn_name}
namespace: ${conn_ns}
labels:
connectors.cpaas.io/gitlab: "true"
annotations:
connectors.cpaas.io/tenant-group-path: "${TENANT_GROUP_PATH}"
spec:
connectorClassName: gitlab
address: ${ADMIN_CONNECTOR_ENDPOINT}
auth:
name: patAuth
secretRef:
name: ${secret_name}
namespace: ${conn_ns}
EOF
)"
printf '%s\n' "${connector_yaml}" \
| kubectl apply --server-side --field-manager=connectors-operator -f -
# Sync the tenant GAT to additional <ns>/<name> basic-auth Secret
# targets if params.basicAuthSecrets was set (forwarded here via "$@").
sync_basic_auth_secrets "${token}" "$@"
# Emit Task results so downstream Pipeline steps can consume them.
if [[ -n "${RESULT_TENANT_GROUP_PATH:-}" ]]; then
printf '%s' "${TENANT_GROUP_PATH}" > "${RESULT_TENANT_GROUP_PATH}"
fi
if [[ -n "${RESULT_SUBGROUPS_PATH:-}" && -f "${SUBGROUPS_JSON_FILE:-/workspace/state/subgroups.json}" ]]; then
cat "${SUBGROUPS_JSON_FILE:-/workspace/state/subgroups.json}" > "${RESULT_SUBGROUPS_PATH}"
fi
if [[ -n "${RESULT_ACCESS_TOKEN_NAME_PATH:-}" ]]; then
printf '%s' "${ACCESS_TOKEN_NAME}" > "${RESULT_ACCESS_TOKEN_NAME_PATH}"
fi
if [[ -n "${RESULT_CONNECTOR_REF_PATH:-}" ]]; then
printf '%s' "${CONNECTOR}" > "${RESULT_CONNECTOR_REF_PATH}"
fi
echo "✅ Kubernetes apply completed"
}
# Standard "main module" guard: only run main() when this script is the
# entrypoint (e.g. `bash apply-kubernetes-resources.sh ...` in the Task
# pod). When the file is `source`d -- as the script.feature pod fixtures
# do to exercise individual helpers like sync_basic_auth_secrets() in
# isolation -- main() is intentionally NOT auto-invoked.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
volumes:
- emptyDir:
medium: Memory
name: state
- emptyDir:
medium: Memory
name: secrets
workspaces:
- description: Mountable via connectors-csi from an admin GitLab Connector; supplies the `config.yml` that glab requires.
name: gitlabconfig
- description: Optional kubeconfig CSI mount of a kubernetes-class Connector for cross-cluster apply; when bound the Task exports KUBECONFIG, otherwise kubectl falls back to the in-cluster service-account context. Mirrors Harbor's kube-config workspace.
name: kubeconfig
optional: true
EOFVerify the Task landed in the chosen namespace:
kubectl get task gitlab-connector-automatic-creation -n "${TARGET_NS}"The Task uses two catalog tool images:
glab— the GitLab CLI used by stepsensure-group,ensure-subgroups, andensure-gat.kubectl— used by the finalapply-kubernetes-resourcesstep.
The catalog tool-image ConfigMaps in kube-public (catalog.tekton.dev/tool-image-glab, catalog.tekton.dev/tool-image-kubectl) advertise the installed versions; the Task params gitlabCliImage and kubectlImage default to those catalog references and can be overridden per TaskRun if you need a different image (for example a registry-mirrored copy).
#Description
Use glab and kubectl to create or reuse a tenant GitLab group plus
optional subgroups, mint a Group Access Token, and write the cluster-side
tenant GitLab Connector + auth Secret in one TaskRun. Supports Pattern A
(top-level group + can_create_group user PAT) and Pattern B (subgroup
under a pre-existing umbrella + admin user PAT with Owner on the
umbrella). Token refresh follows a three-path lifecycle: rotate,
recreate, expired-fall-through.
#Parameters
| Name | Type | Default | Description |
|---|---|---|---|
connector | string | required | Target GitLab Connector resource to create or update, in <namespace>/<name> format, for example connectors-management/project-a-gitlab. |
tenantGroup | string | required | Tenant group path. Top-level (Pattern A) such as acme-corp, or umbrella/tenant (Pattern B). |
subgroups | array | [] | Optional per-team subgroup names created idempotently under the tenant group. Each entry is a single path segment (no slashes). |
accessLevel | string | owner | GitLab access level for the GAT. One of guest, reporter, developer, maintainer, owner. |
scopes | array | ["api"] | GitLab GAT scope set. See Validating scopes against your instance for instance-specific gating. |
tokenDuration | string | "30" | Validity period of the GAT in days. Capped by the GitLab instance policy. Cron-friendly — every run computes expires_at as today + N days. |
secret | string | "" | Bare name of the Kubernetes Secret that stores the tenant GAT in the connector namespace. Must not contain /. Leave empty to let the Task derive <connector-name>-secret. |
accessTokenName | string | "" | Name prefix used for the GitLab Group Access Token managed by this Task. The full GAT name is <accessTokenName>/<identity-suffix>; the suffix encodes access level + scopes + subgroup set so re-runs can detect identity-affecting changes. Leave empty to derive connector-<connector-namespace>-<connector-name>. Mirrors Harbor's robotAccount parameter. |
gitlabCliImage | string | catalog glab | Tool image used by every glab step. |
kubectlImage | string | catalog kubectl | Tool image used by the final kubectl step. |
imagePullPolicy | string | Always | Controls when Kubernetes pulls the tool images used by this Task. |
verbose | string | "false" | Enable shell tracing on non-secret steps. The admin PAT and tenant GAT are NEVER traced regardless of this flag. |
#Workspaces
| Name | Required | Purpose | Expected files |
|---|---|---|---|
gitlabconfig | yes | connectors-csi mount of the admin GitLab Connector. The Task consumes the admin credential through this mount and never reads the raw PAT/GAT into the Pod environment. | config.yml (consumed by glab; carries the host alias and a short-lived proxy token) and connector_address (the admin Connector's spec.address, used as the tenant Connector's spec.address). |
kubeconfig | no | Optional CSI mount of a kubernetes-class Connector for cross-cluster apply. When bound the Task exports KUBECONFIG, otherwise kubectl falls back to the in-cluster service-account context. | kubeconfig. |
The gitlabconfig workspace mirrors Harbor's harbor-config. The
kubeconfig workspace mirrors Harbor's kube-config.
#Results
| Name | Type | Description |
|---|---|---|
tenant-group | string | The materialised tenant group path, for example acme-corp or umbrella/acme-corp. |
subgroups | array | JSON array of materialised subgroup paths. |
access-token-name | string | Identity-encoded GitLab GAT name minted or rotated by this run, in <prefix>/<identity-suffix> form. Useful for ops audit and glab api lookups. The numeric token id is recorded as a Secret annotation rather than exposed as a Task result. |
connector-ref | string | Namespaced reference of the tenant Connector handled by the Task, in <namespace>/<name> format. |
#Steps
The Task runs four sequential steps. All steps share an in-memory
emptyDir volume mounted at /workspace/state for non-secret state and
a tmpfs (emptyDir.medium=Memory) volume mounted at /workspace/secrets
for the GAT. The tmpfs volume is removed on Task exit.
| Step | Image | What it does |
|---|---|---|
ensure-group | gitlabCliImage | Look up tenantGroup via glab api groups/:path. If absent, create it (Pattern A: top-level; Pattern B: with parent_id resolved from the umbrella). Writes the group id and full path to /workspace/state/. |
ensure-subgroups | gitlabCliImage | Iterate subgroups[] and create each idempotently as a child of the tenant group. A 409 from glab api is treated as success. Writes a JSON array of materialised subgroup paths. |
ensure-gat | gitlabCliImage | List existing GATs at the tenant group, find the one whose name matches this Task's prefix, and pick one of three paths: rotate-in-place, recreate (identity drift or expired-fall-through), or create fresh. Writes the new GAT id to /workspace/state/access-token-id and the secret value to /workspace/secrets/token. |
apply-kubernetes-resources | kubectlImage | Server-side apply the tenant Connector and Secret into the connector namespace. The Connector's spec.address comes from connector_address in the gitlabconfig mount. The Secret is type: connectors.cpaas.io/gitlab-pat-auth with stringData.token set from /workspace/secrets/token. Honours the optional kubeconfig workspace for cross-cluster apply. |
The Task stepTemplate enforces a non-root, capability-dropped security
context (runAsUser: 65532, runAsGroup: 65532,
allowPrivilegeEscalation: false, all capabilities dropped). The tool
images need to be runnable as a non-root user.
#Three-Path GAT Lifecycle Summary
| Existing GAT state | Identity match? | Action |
|---|---|---|
| None | n/a | Create a fresh GAT and write the Secret. |
| Healthy and identity matches | yes | Rotate in place via POST /groups/:id/access_tokens/:token_id/rotate. Old token invalidated atomically; Secret updated in place. |
Healthy but identity differs (scopes, accessLevel, or subgroups[] changed) | no | Recreate: revoke the existing GAT, mint a fresh one with the requested identity, rewrite the Secret. |
Expired (expires_at < now) | n/a | Expired-fall-through: best-effort revoke, mint a fresh GAT, rewrite the Secret. The TaskRun reports Succeeded. No manual operator intervention required. |
See Three-Path GAT Lifecycle for the model and Step-by-Step — Expired-Fall-Through (Self-Healing) for the recovery walkthrough.
#Security Notes
- The admin GitLab credential never enters the Pod environment. The
gitlabconfigmount carries a short-lived proxy token; the real admin PAT/GAT lives behind the Connectors proxy. - The tenant GAT is written to a tmpfs volume (
emptyDir.medium=Memory) with mode 0600 and removed on Task exit. - Shell tracing (
set -x) is never active around code paths that read the GAT, regardless ofverbose=true. - The
secretparameter is a bare resource name in the connector namespace; values containing/are rejected so a typo cannot land the GAT in a different namespace. apply-kubernetes-resourcescallskubectl auth can-i create secrets -n <connector-namespace>before applying anything, surfacing missing RBAC and typos inconnector=<ns>/<name>together.
#See Also
- Auto-Create GitLab Tenant Group and Connector with Tekton — operator walkthrough with Pattern A, Pattern B, rotate, recreate, expired-fall-through, the rotation CronJob pattern, and the operations runbook.
- GitLab CLI Configuration and Auto-Creation Patterns
— concept page covering the
gitlabconfigCSI mount, Pattern A vs Pattern B, and the three-path GAT lifecycle. - GitLab Connector — the underlying GitLab ConnectorClass and its tenant-side configurations.