The GitLabSecrets reconciler

internal/controller/gitlabsecrets reconciles the apps.gitlab.com/v2alpha1 GitLabSecrets resource into the Secrets the GitLab chart would otherwise generate with its shared-secrets hook Job. For the requirements, see the GitLab secrets spec. For the design, see ADR 35.

The reconciler is not implemented yet. The definition is implemented, as described in The definition, and generation in internal/secretgen, as described in Generation. This page also records the behavior of the Job it matches.

The definition

api/v2alpha1/gitlabsecrets_types.go declares the resource. task manifests regenerates config/crd/bases/apps.gitlab.com_gitlabsecrets.yaml, and task generate the deepcopy code. task install_v2alpha1_crds and the end-to-end harness apply the definition with the other v2alpha1 definitions.

Server-side apply rejects a field the definition does not declare, as does a create or update with fieldValidation=Strict. Examples are spec.policy and authority.expiry, which earlier chart drafts rendered. A request without fieldValidation stores the resource without the field and returns a warning.

internal/controller/gitlabsecrets/definition_test.go creates each chart render in internal/controller/gitlabsecrets/testdata/chart/ on envtest. It also checks the rejected values and the bounds. task slow-unit-tests runs it. To run it on its own:

shell
KUBEBUILDER_ASSETS=$(./setup-envtest use -p path) go test ./internal/controller/gitlabsecrets/ -run TestDefinition

To regenerate the fixtures from a chart checkout, run the script, then the test:

shell
internal/controller/gitlabsecrets/testdata/chart/render.sh <chart-dir>

The script renders each values/<case>.yaml with values/base.yaml. To add a case, add its values file, and add what it covers to fixtureCovers in the test. The README.md next to the fixtures records the chart commit they come from.

Generation

internal/secretgen decides what to add to one Secret, with no Kubernetes client. Plan takes the entries of spec.secrets that name the Secret, the Secret if it exists, and its readyUnits. It returns the keys to add, the reports, the declared data-bearing units, the next readyUnits, and a note when the existing type differs from the declared one. The package comment shows how a controller uses it. spec.certificates is not implemented yet.

The tests compare every generated value with the shape of the Job’s output. The fixture is in internal/secretgen/testdata/job/:

  • values.yaml: chart values that render every generator type and option.
  • gitlabsecrets.yaml: the GitLabSecrets that the chart renders for them with shared-secrets.provider: controller.
  • shapes.json: the shapes of the Secrets the Job created for them, recorded by hack/secretshapes, with the chart commits, the Job image, and the date. It holds no secret material.

The shape test accepts only these differences from the recorded shapes:

  • An SSH host key carries no comment, in its .pub or in its private key.
  • secrets.yml ends with a newline.

The generated values also differ from the Job’s in ways a single recording does not show:

  • A random value always has length characters.
  • A generated secrets.yml value that YAML reads as another type, such as all digits, is quoted. The Job writes it bare, so Rails reads a number. An existing bare value stays bare.
  • A rewritten secrets.yml is indented by 2, drops --- and ..., joins the lines of folded scalars, and tags merge keys as !!merge <<:. Every value is kept, and a document of comments only keeps its comments.

Behavior of the Job

The values in the field came from the Job, so the Job is the reference. These results are from 2026-09-30, in the Job’s image cng/kubectl:v19.4.1 and in the arm64 cng/cfssl-self-sign:v19.2.1, whose script matches the CNG default branch.

SubjectBehavior
OpenSSL3.5.7. openssl genrsa and openssl req -keyout write PRIVATE KEY (PKCS#8).
x509openssl req -x509 writes X.509 v3, sha256WithRSAEncryption, subject and issuer CN=<commonName>, CA:TRUE (critical), and subject and authority key IDs.
sshHostKeysOpenSSH 10.0p2. ssh-keygen -A makes RSA 3072, ECDSA P-256, and Ed25519, as ssh_host_<type>_key and .pub. Private keys are OPENSSH PRIVATE KEY, with no cipher and no KDF, in lines of 70 characters. Public and private keys carry a <user>@<pod> comment. The Job mounts an emptyDir on /etc/ssh, because the image user cannot write there.
randomgen_random filters 4096 random bytes with tr, so a narrow character set can return fewer than length characters.
encoding: base64In chart 10.4.1, stores standard base64 with no wrapping and no trailing newline up to a length of 57, the same as base64-nowrap. Every base64 entry is 32. From 58, the create path fails and the patch path truncates.
wrap: jsonArrayEncodes first, then wraps.
Existing SecretAdds only a missing --from-literal key. Never adds a key of rsa, x509, sshHostKeys, or raw bytes, and never changes an existing host-key Secret.
secrets.ymlRewritten on every run, with the declared fields of one environment only. Regenerates an absent, null, or empty-string field, and keeps an empty list. Indents by 2, with a PEM in a | block and list items indented by 4. Has no trailing newline. A rerun writes the same bytes.
LabelsOverwrites gitlab.standardLabels and gitlab.commonLabels. Removes app.kubernetes.io/name when global.application.create is false.
Certificate setEvery run issues a new CA and wildcard certificate, then creates each of the three Secrets with || true. A run with one Secret missing fills it from the new CA, which the other two do not match.
Certificate structureCA: subject and issuer O=<namespace>, OU=<release>, CN=<caSubject>, CA:TRUE (critical), key usage certificate sign and CRL sign (critical). Wildcard: subject CN=<domain>, SANs <domain> and *.<domain>, CA:FALSE (critical), key usage digital signature, key encipherment, and certificate sign, extended key usage server authentication. The chain is ca.pem, then wildcard.pem.
Certificate keysRSA PRIVATE KEY (PKCS#1) or EC PRIVATE KEY (SEC 1).
Certificate sizesRSA from 2048 to 8192, odd sizes included. ECDSA 256, 384, and 521. RSA 1024 fails with RSA key is too weak, 8194 with RSA key size too large, and ECDSA 224 with invalid curve. ed25519 depends on the CNG version, as described below.
Certificate signatureRSA from 4096 SHA-512, from 3072 SHA-384, below that SHA-256. ECDSA P-256 SHA-256, P-384 SHA-384, P-521 SHA-512.
Certificate validityCA 43800 hours, wildcard 8760 hours. The script reads EXPIRE while the Job sets EXPIRY, so shared-secrets.selfsign.expiry never applies.

ed25519 follows the cfssl version, not the architecture. cng/cfssl-self-sign before v19.4.0 ships cfssl 1.6.1, which fails ed25519 with invalid algorithm, so charts 10.2 and 10.3 fail it. From v19.4.0, both architectures ship cfssl 1.6.5, which the arm64 image builds from source and reports as dev, so chart 10.4 issues Ed25519 certificates.

Those keys are RFC 8410 PKCS#8, byte-identical to Go’s x509.MarshalPKCS8PrivateKey, under the nonstandard label Ed25519 PRIVATE KEY instead of the PRIVATE KEY of RFC 7468 (csr/csr.go:263-272 in cfssl 1.6.5, unchanged in 1.7.0). NGINX with OpenSSL 3.5.7, Envoy with BoringSSL, and the Envoy Gateway validation reject the label, and load the key once it is relabeled. Go accepts both. Chrome and Firefox do not support an Ed25519 TLS server certificate, whatever the label, which is why the Operator refuses ed25519 certificates.

Reproduce the checks

Print headers and sizes only. The keys are throwaway, but do not paste them anywhere.

  1. Check OpenSSL and the SSH host keys in the Job’s image:

    shell
    docker run --rm --user 0 --entrypoint /bin/bash \
      registry.gitlab.com/gitlab-org/build/cng/kubectl:v19.4.1 -c '
        openssl version
        openssl genrsa 2048 2>/dev/null | head -1
        ssh-keygen -A
        for pub in /etc/ssh/ssh_host_*.pub; do ssh-keygen -lf "$pub" | cut -d" " -f1,4; done
        head -1 /etc/ssh/ssh_host_rsa_key'
  2. Issue a certificate set for each algorithm and size, then print the signature algorithm, the CA row, and the validity:

    shell
    for spec in rsa-1024 rsa-2048 rsa-3072 rsa-4096 rsa-8192 ecdsa-256 ecdsa-384 ecdsa-521 ed25519-0; do
      mkdir -m 777 -p "out/$spec"
      docker run --rm -v "$PWD/out/$spec:/output" -e ALGORITHM="${spec%-*}" -e KEY_SIZE="${spec#*-}" \
        -e CA_SUBJECT="GitLab Helm Chart" -e CERT_DOMAIN=example.com -e EXPIRY=3650d \
        registry.gitlab.com/gitlab-org/build/cng/cfssl-self-sign:v19.2.1 >/dev/null 2>&1
      if [ -f "out/$spec/ca.pem" ]; then
        echo "$spec: $(openssl x509 -in "out/$spec/wildcard.pem" -noout -text | grep -m1 'Signature Algorithm')"
      else
        echo "$spec: issues nothing"
      fi
    done
    openssl x509 -in out/rsa-4096/ca.pem -noout -subject -issuer -dates
    openssl x509 -in out/rsa-4096/ca.pem -noout -text | grep -A1 -E 'Constraints|Key Usage'
    openssl x509 -in out/rsa-4096/wildcard.pem -noout -dates
    rm -rf out
  3. Record the shape fixture of internal/secretgen. Copy the chart checkout that the fixture names, and run these commands from its root. Set OPERATOR to the root of this repository, and make a throwaway cluster the current context:

    shell
    fixture="$OPERATOR/internal/secretgen/testdata/job"
    work=$(mktemp -d)
    helm dependency build
    helm template gl . -n sg-fixture -f "$fixture/values.yaml" > "$work/render.yaml"
    helm template gl . -n sg-fixture -f "$fixture/values.yaml" --set shared-secrets.provider=controller |
      yq 'select(.kind == "GitLabSecrets")' > "$fixture/gitlabsecrets.yaml"
    yq 'select(.metadata.name == "gl-shared-secrets" or
      (.kind == "Job" and (.metadata.name | test("^gl-shared-secrets-[0-9a-f]+$"))))' \
      "$work/render.yaml" > "$work/job.yaml"
    kubectl create namespace sg-fixture
    kubectl -n sg-fixture apply -f "$work/job.yaml"
    kubectl -n sg-fixture wait --for=condition=complete job --all --timeout=300s
    kubectl -n sg-fixture get secrets -o json > "$work/secrets.json"
    kubectl delete namespace sg-fixture
    (cd "$OPERATOR" && go run ./hack/secretshapes -secrets "$work/secrets.json" \
      -manifest "$fixture/gitlabsecrets.yaml" -out "$fixture/shapes.json" \
      -source "chart=<chart version and commits>" \
      -source "render=helm <version>, release gl, namespace sg-fixture, values.yaml" \
      -source "job=<Job image>" -source "cluster=<cluster and platform>" -source "recorded=<date>" \
      -source "reproduce=doc/developer/gitlabsecrets.md, Reproduce the checks")
    rm -rf "$work"

    Then run go test ./internal/secretgen/.... A shape difference that is not listed in Generation fails the test.