feat(conformance): cover R-TLS-02, R-ENV-01, R-ENV-03 — 42/51 -> 45/51, and find a real gap

DECISIONS-LOG records Phase 7 being opened knowingly while the uncovered set
included R-ENV-01/02/03 — the very requirements prod provisioning serves. Two of
those three are now covered, plus R-TLS-02.

  tls-lifecycle-automated          R-TLS-02  L0 (in the merge gate)
  cert-lifecycle-automated         R-TLS-02  L2 (authored, NOT executed)
  dev-nodes-provisioned-as-code    R-ENV-01  L0
  prod-baremetal-not-dev-surface   R-ENV-03  L0

Coverage 42/51 (82.4%) -> 45/51 (88.2%), 6 gaps, 0 safety-critical. Remaining:
R-C2-01, R-C2-02, R-NET-04, R-PLAT-01, R-SEC-03, R-STATE-04. Verified through the
real runner: --layer l0 7/7 PASS, the three new ones 3/3 PASS, --strict-coverage
exit 0 with 0 label/annotation mismatches.

*** R-TLS-02 "REVOKED" IS NOT SATISFIED — A REAL GAP, NOT A TEST DEFECT. ***
The spec requires certificates be "issued, renewed, AND REVOKED without manual
steps", and docs/08 reads revocation as "removing/rotating a certificate is a
declarative change". That needs enableCertificateOwnerRef: true;
platform/cert-manager/helmrelease.yaml does not set it (chart default false).
Verified read-only on BOTH live clusters: gateway/wildcard-home-tls has ZERO
ownerReferences, so deleting the Certificate would leave the private key serving
indefinitely. The L2 test asserts the requirement AS WRITTEN and will fail until
this is decided. It was deliberately NOT softened — weakening a test to match the
implementation is how a suite stops meaning anything.

NOT FLIPPED UNATTENDED, and the reason is specific rather than caution: the
wildcard Certificate lives under ./platform, which runs prune: true. With owner
refs ON, any transient prune of that Certificate — exactly what happened to the
CDI StorageProfiles hours ago — would delete the SERVING TLS Secret for every
host, and reissuance is bounded by Let's Encrypt's duplicate-certificate limit
(5/week per identical name set), which could leave a cluster with no trusted cert
for a week. Security gap vs a plausible week-long TLS outage is an operator call.

NON-VACUITY DEMONSTRATED, which is the part that makes these worth having:
7/7 perturbations fail for R-TLS-02, 8/8 for R-ENV-01, 13/13 for R-ENV-03, and
each restored copy passes. Perturbations were applied to a throwaway copy; neither
this repo nor the read-only spec repo was modified.

TWO CHECKS INITIALLY PASSED WHEN PERTURBED and were rewritten — the exact
degrade-to-no-op failure this repo keeps hitting. The first draft asked "is this
file's DIRECTORY referenced by a kustomization", which a directory reference always
satisfies, and keyed Secrets by name alone, so external-dns's copy of
`cloudflare-api-token` satisfied cert-manager's reference. Now a recursive
kustomize resource-graph walk from each Flux path, and Secrets keyed by
(namespace, name). Both bite.

SECURITY NOTE, recorded because this project has already leaked a password once:
`ansible-inventory --list` PRINTS DECRYPTED VAULT VALUES — it emitted the prod
become-password to stdout during exploration, since the vault passphrase file is
mounted into the dev container. Both new checks use only --syntax-check and
--list-hosts, and say so in a comment. Confirmed no plaintext value reached disk:
vault.yml is still AES256-encrypted and the tree has no assigned value.

WHAT IS NOT PROVEN, per requirement: R-TLS-02 "revoked" (above), and issuance +
observed renewal await the unexecuted L2. R-ENV-01 cannot prove the running VMs
came from this module (needs OpenTofu state) — it proves the four lifecycle stages
exist as code and cover every dev node, and that the chain RESOLVES under real
ansible-playbook. R-ENV-03 cannot prove the HARDWARE (the K8s API exposes no
"am I virtual" fact; mini-PC/bare-metal is operator-attested) — it proves prod is
AUTHORED as 3 directly-reached nodes with no IaC creating them, sharing dev's
playbooks and roles, with no prod-only manifest, branch or role anywhere.

VERIFIED BY ME: coverage delta and the 3/3 L0 pass through the real runner; the
zero-ownerReferences finding on both clusters; that the new checks avoid
ansible-inventory; that no credential material reached disk. Full CI gate PASS.

Refs R-TLS-02, R-ENV-01, R-ENV-03, R-TEST-01..05.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-09-05 14:17:03 -05:00
1 parent 8f9b2e4270
commit fe45d0f5c5
7 files changed
+2060

No files matched your search

@@ -0,0 +1,391 @@
# =============================================================================
# L2 BEHAVIORAL — certificate lifecycle is automatic (R-TLS-02)
# [RUNNABLE / NOT YET EXECUTED — see STATUS]
# Catalog: docs/14 "Trusted TLS (R-TLS-01/02)"
# Spec: docs/00 R-TLS-02 — "Certificates MUST be automatically **issued, renewed,
# and revoked** without manual steps."
# docs/08 §"Renewal & revocation (R-TLS-02)" — "cert-manager renews well
# before expiry automatically; no human action." / "Revocation: removing/
# rotating a certificate is a declarative change; cert-manager handles
# issuance of replacements."
# docs/08 acceptance gate — "Certificates renew automatically ahead of expiry
# with no manual step (**observed across a renewal**)."
# Impl: platform/cert-manager/**, platform/gateway/wildcard-certificate.yaml.
# Sibling: tests/l0-static/tls-lifecycle-automated proves the "without manual steps"
# clause statically (in the merge gate). This test observes the three VERBS.
#
# ENVIRONMENT-AGNOSTIC (R-ENV-02): NOTHING about either environment is written down. The
# serving certificate is DISCOVERED — find the Gateway whose listener terminates TLS, follow
# its certificateRefs Secret, and read that Secret's `cert-manager.io/certificate-name`
# annotation to reach the Certificate. No hostname, no namespace list, no issuer name, no
# address: dev proves dev's cert and prod proves prod's from the same authored assertions.
#
# GIVEN cert-manager reconciled by Flux and a Gateway terminating TLS,
# WHEN the serving Certificate's provenance and renewal schedule are read, and a NEW
# Certificate is then requested, left to renew itself, and removed,
# THEN 1. the serving credential was produced by the CONTROLLER, not a human: its
# Secret carries cert-manager's certificate-name annotation, its
# CertificateRequest was submitted by cert-manager's own ServiceAccount and
# was APPROVED automatically, and its issuer is a Ready ACME ClusterIssuer;
# 2. an unattended RENEWAL IS SCHEDULED: status.renewalTime is in the future and
# strictly before status.notAfter, so no human is in the renewal path;
# 3. ISSUED — a newly declared Certificate becomes Ready and its Secret appears
# with certificate material, with no step between declaring and having it;
# 4. RENEWED — that certificate, given a renewal window that opens one minute
# after issuance, REISSUES ITSELF at least twice (status.revision >= 3) and
# the serving bytes actually change, with no human action. This is docs/08's
# "observed across a renewal", fast-forwarded;
# 5. REMOVED/REVOKED — deleting the Certificate withdraws its serving material.
#
# WHY A HERMETIC SELF-SIGNED ISSUER FOR STEPS 3-5, AND WHAT THAT COSTS
# -------------------------------------------------------------------
# Steps 3-5 need a certificate lifecycle that completes in minutes and repeats on every
# run. Driving that through the real ACME issuer would fire a public Let's Encrypt order
# (and a Cloudflare _acme-challenge write) per conformance run, burning issuance rate
# limits for evidence that is not about ACME: cert-manager's renewal scheduler and
# ownership logic are issuer-independent. So steps 3-5 use a `selfSigned` Issuer created
# in this test's OWN namespace, and the ACME/DNS-01 path is proven elsewhere — step 1-2
# read the REAL wildcard certificate the Gateway serves, and l2-behavioral/trusted-tls
# proves a publicly-trusted LE certificate is actually served (R-TLS-01/03).
# STATED PLAINLY: steps 3-5 do NOT prove the ACME order path renews; they prove the
# CONTROLLER's issue/renew/withdraw automation runs unattended in this cluster, while
# steps 1-2 prove the real serving certificate is on exactly that automated path.
#
# STATUS: RUNNABLE, NOT YET EXECUTED. Steps 1-2 are read-only. Steps 3-5 CREATE an
# Issuer/Certificate (and DELETE the Certificate they created) inside chainsaw's
# per-test namespace — the same class of mutation every other L2 test here performs,
# self-cleaning via namespace teardown, touching no platform object. WS-TEST authored
# them against live dev/prod read-only inspection under an explicit instruction not to
# mutate either cluster, so THE MUTATING STEPS HAVE NOT BEEN RUN. Nothing about the
# suite gates on them: ci/conformance.sh only runs L2/L3 when CLUSTER_AVAILABLE=true.
#
# EXPECTED FAILURE, DELIBERATELY NOT SOFTENED (R-TEST-06)
# ------------------------------------------------------
# Step 5 WILL FAIL as the platform is authored today. cert-manager only garbage-collects
# a Certificate's Secret when the controller runs with `enableCertificateOwnerRef: true`;
# platform/cert-manager/helmrelease.yaml does not set it (chart default false), and the
# live wildcard Secret on both clusters has NO ownerReferences (verified read-only,
# 2026-09-05). So a removed Certificate leaves its credential in place, and "revoked
# without manual steps" does not hold. That is a finding about the platform, not a
# defect in this test: the assertion stays as the requirement reads and is escalated to
# the PM. (ACME revocation proper is not implemented by cert-manager at all.)
#
# KNOWN, DELIBERATE GAPS (this test claims none of these):
# * Not proven: that the CA would refuse a revoked certificate — there is no
# revocation request to make, so nothing observable exists to assert.
# * Not proven: renewal of the REAL wildcard cert (its window opens 30 days before a
# 90-day expiry; a conformance run cannot wait, and forcing it would rotate the
# platform's serving credential). Step 2 asserts the schedule; step 4 asserts the
# mechanism that will act on it.
# * Not proven: certificate expiry ALERTING — docs/08 delegates that to R-OBS-04
# (l3-fault-injection/alerting).
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: cert-lifecycle-automated
labels:
conformance.home.k3s/R-TLS-02: "true"
annotations:
conformance.home.k3s/requirements: "R-TLS-02"
conformance.home.k3s/layer: "L2"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
Given cert-manager and a TLS-terminating Gateway, when the serving certificate's
provenance and renewal schedule are read and a new certificate is requested, left
to renew, and removed, then issuance, renewal and withdrawal all happen with no
human step — the serving cert was controller-issued and auto-approved, its
renewal is scheduled ahead of expiry, and a declared certificate issues and
reissues itself unattended.
spec:
timeouts:
apply: 60s
assert: 3m
exec: 10m
steps:
# ---------------------------------------------------------------- READ-ONLY HALF
- name: given-cert-manager-is-reconciled-and-healthy
description: >-
The controller that issues and renews must be running, and must have been
installed by Flux rather than by hand — otherwise "automatic" has no engine.
try:
- script:
content: |
set -u
# Discover cert-manager's Deployments by chart label, in whatever namespace
# the release lives (nothing about the environment is assumed).
sel='app.kubernetes.io/instance=cert-manager'
rows="$(kubectl get deploy -A -l "$sel" \
-o custom-columns='NS:.metadata.namespace,N:.metadata.name,RDY:.status.readyReplicas' \
--no-headers 2>/dev/null || true)"
echo "$rows"
[ -n "$rows" ] || { echo "FAIL: no cert-manager Deployment found ($sel)"; exit 1; }
n=0
echo "$rows" | while read -r ns name rdy; do
[ "${rdy:-0}" -ge 1 ] || { echo "FAIL: $ns/$name has no ready replica"; exit 1; }
done || exit 1
n="$(echo "$rows" | wc -l)"
# controller + webhook + cainjector: without the webhook a Certificate is
# rejected, without cainjector the webhook has no CA — all three or nothing.
[ "$n" -ge 3 ] || { echo "FAIL: expected >=3 cert-manager Deployments, saw $n"; exit 1; }
# GitOps-managed, not hand-installed: Flux labels what it applies.
ns="$(echo "$rows" | head -1 | awk '{print $1}')"
lbl="$(kubectl get deploy -n "$ns" -l "$sel" \
-o jsonpath='{.items[0].metadata.labels.helm\.toolkit\.fluxcd\.io/name}' 2>/dev/null || true)"
[ -n "$lbl" ] || { echo "FAIL: cert-manager is not owned by a Flux HelmRelease"; exit 1; }
echo "CERT_MANAGER_HEALTHY_OK ($n deployments, flux HelmRelease $lbl)"
check:
(contains($stdout, 'CERT_MANAGER_HEALTHY_OK')): true
- name: then-the-serving-certificate-was-issued-by-the-controller
description: >-
Follow the Gateway's terminating listener to the Secret it serves, and prove
that Secret's provenance is cert-manager — annotation, CertificateRequest
requester, automatic approval, and a Ready ACME issuer.
try:
- script:
content: |
set -u
# --- discover the serving Secret from the Gateway itself -------------------
gw="$(kubectl get gateway -A \
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' \
2>/dev/null | head -1)"
[ -n "$gw" ] || { echo "FAIL: no Gateway found — nothing terminates TLS"; exit 1; }
gns="${gw% *}"; gname="${gw#* }"
sec="$(kubectl -n "$gns" get gateway "$gname" \
-o jsonpath='{.spec.listeners[?(@.tls.mode=="Terminate")].tls.certificateRefs[0].name}' \
2>/dev/null | awk '{print $1}')"
[ -n "$sec" ] || { echo "FAIL: Gateway $gns/$gname terminates no TLS listener"; exit 1; }
echo "gateway=$gns/$gname serving-secret=$sec"
# --- provenance (1): only the controller writes this annotation ------------
cert="$(kubectl -n "$gns" get secret "$sec" \
-o jsonpath='{.metadata.annotations.cert-manager\.io/certificate-name}' 2>/dev/null || true)"
[ -n "$cert" ] || { echo "FAIL: Secret $gns/$sec carries no cert-manager.io/certificate-name annotation — it was not produced by cert-manager"; exit 1; }
crt="$(kubectl -n "$gns" get secret "$sec" -o jsonpath='{.data.tls\.crt}' 2>/dev/null || true)"
[ -n "$crt" ] || { echo "FAIL: Secret $gns/$sec holds no tls.crt"; exit 1; }
# --- provenance (2): the Certificate is Ready and has a revision -----------
ready="$(kubectl -n "$gns" get certificate "$cert" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
rev="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
echo "certificate=$gns/$cert ready=$ready revision=$rev"
[ "$ready" = "True" ] || { echo "FAIL: Certificate $cert is not Ready"; exit 1; }
[ -n "$rev" ] && [ "$rev" -ge 1 ] || { echo "FAIL: Certificate $cert has no issued revision"; exit 1; }
# --- provenance (3): the request was made BY THE CONTROLLER and auto-approved
# spec.username is the identity that created the CertificateRequest. A human
# operator would appear as a user/OIDC identity, not a ServiceAccount.
cr="$(kubectl -n "$gns" get certificaterequest \
-o jsonpath="{range .items[?(@.metadata.annotations.cert-manager\.io/certificate-name=='${cert}')]}{.metadata.name}{' '}{.spec.username}{'\n'}{end}" \
2>/dev/null | tail -1)"
[ -n "$cr" ] || { echo "FAIL: no CertificateRequest for $cert — cannot show who requested it"; exit 1; }
crname="${cr% *}"; requester="${cr#* }"
echo "certificaterequest=$crname requester=$requester"
case "$requester" in
system:serviceaccount:*) : ;;
*) echo "FAIL: CertificateRequest $crname was submitted by '$requester', not a controller ServiceAccount"; exit 1 ;;
esac
appr="$(kubectl -n "$gns" get certificaterequest "$crname" \
-o jsonpath='{.status.conditions[?(@.type=="Approved")].status}' 2>/dev/null || true)"
[ "$appr" = "True" ] || { echo "FAIL: CertificateRequest $crname is not Approved=True (approval would be a manual step)"; exit 1; }
# --- provenance (4): an automated ACME issuer, and it is Ready -------------
ikind="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.spec.issuerRef.kind}')"
iname="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.spec.issuerRef.name}')"
echo "issuer=$ikind/$iname"
if [ "$ikind" = "ClusterIssuer" ]; then
iready="$(kubectl get clusterissuer "$iname" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
iacme="$(kubectl get clusterissuer "$iname" -o jsonpath='{.spec.acme.server}' 2>/dev/null || true)"
else
iready="$(kubectl -n "$gns" get issuer "$iname" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
iacme="$(kubectl -n "$gns" get issuer "$iname" -o jsonpath='{.spec.acme.server}' 2>/dev/null || true)"
fi
[ "$iready" = "True" ] || { echo "FAIL: issuer $iname is not Ready — renewal would need a human to fix it"; exit 1; }
[ -n "$iacme" ] || { echo "FAIL: issuer $iname is not an ACME issuer"; exit 1; }
echo "SERVING_CERT_CONTROLLER_ISSUED_OK"
check:
(contains($stdout, 'SERVING_CERT_CONTROLLER_ISSUED_OK')): true
- name: then-an-unattended-renewal-is-already-scheduled
description: >-
cert-manager writes status.renewalTime only after computing when it will renew.
Asserting it is in the future and before expiry proves the renewal is scheduled
and that nothing is waiting on a human (docs/08 "renews well before expiry").
try:
- script:
content: |
set -u
gw="$(kubectl get gateway -A \
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | head -1)"
gns="${gw% *}"; gname="${gw#* }"
sec="$(kubectl -n "$gns" get gateway "$gname" \
-o jsonpath='{.spec.listeners[?(@.tls.mode=="Terminate")].tls.certificateRefs[0].name}' | awk '{print $1}')"
cert="$(kubectl -n "$gns" get secret "$sec" \
-o jsonpath='{.metadata.annotations.cert-manager\.io/certificate-name}')"
renew="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.renewalTime}')"
after="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.notAfter}')"
[ -n "$renew" ] || { echo "FAIL: Certificate $cert has no status.renewalTime — no renewal is scheduled"; exit 1; }
[ -n "$after" ] || { echo "FAIL: Certificate $cert has no status.notAfter"; exit 1; }
now_s="$(date -u +%s)"
r_s="$(date -u -d "$renew" +%s)"
a_s="$(date -u -d "$after" +%s)"
echo "now=$(date -u -d "@$now_s" +%FT%TZ) renewalTime=$renew notAfter=$after"
echo "renewal is in $(( (r_s - now_s) / 86400 ))d; expiry in $(( (a_s - now_s) / 86400 ))d"
[ "$r_s" -gt "$now_s" ] || { echo "FAIL: renewalTime is in the PAST — the scheduled renewal did not happen"; exit 1; }
[ "$r_s" -lt "$a_s" ] || { echo "FAIL: renewalTime is not before notAfter — renewal would come too late"; exit 1; }
echo "RENEWAL_SCHEDULED_OK"
check:
(contains($stdout, 'RENEWAL_SCHEDULED_OK')): true
# ------------------------------------------------- MUTATING HALF (hermetic namespace)
- name: when-a-certificate-is-declared-then-it-is-issued-with-no-human-step
description: >-
Declare an Issuer and a Certificate in this test's own namespace, with a renewal
window that opens one minute after issuance. Nothing else is done to them for the
rest of the test — every later transition is the controller acting on its own.
try:
- apply:
resource:
# selfSigned: no external CA, no rate limit, no DNS write. See the header for
# what this proves (controller automation) and what it does not (the ACME path).
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: conformance-lifecycle
spec:
selfSigned: {}
- apply:
resource:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: conformance-lifecycle
spec:
secretName: conformance-lifecycle-tls
# 1h is cert-manager's minimum duration; renewing 59m before expiry puts the
# renewal instant ~1 minute after each issuance, so an unattended renewal is
# observable inside a test run instead of 30 days from now.
duration: 1h
renewBefore: 3540s
commonName: conformance-lifecycle.invalid
dnsNames:
- conformance-lifecycle.invalid
privateKey:
rotationPolicy: Always
issuerRef:
kind: Issuer
name: conformance-lifecycle
- assert:
resource:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: conformance-lifecycle
status:
conditions:
- type: Ready
status: "True"
revision: 1
- script:
content: |
set -u
crt="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
-o jsonpath='{.data.tls\.crt}' 2>/dev/null || true)"
key="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
-o jsonpath='{.data.tls\.key}' 2>/dev/null || true)"
[ -n "$crt" ] && [ -n "$key" ] || { echo "FAIL: no certificate material was issued into conformance-lifecycle-tls"; exit 1; }
# Record the serving bytes so the renewal step can prove they CHANGED.
echo "$crt" | cksum > /tmp/conformance-cert-r1.cksum
echo "revision-1 cert fingerprint: $(cat /tmp/conformance-cert-r1.cksum)"
echo "CERT_ISSUED_OK"
check:
(contains($stdout, 'CERT_ISSUED_OK')): true
- name: then-it-renews-itself-before-expiry-with-no-human-step
description: >-
Nothing touches the Certificate here. Poll until the controller has reissued it
at least twice on its own and the serving bytes differ from revision 1 — docs/08's
"observed across a renewal", and every request auto-approved.
try:
- script:
content: |
set -u
want=3 # revision 1 = issuance; >=3 means TWO unattended renewals
for i in $(seq 1 30); do
rev="$(kubectl -n "$NAMESPACE" get certificate conformance-lifecycle \
-o jsonpath='{.status.revision}' 2>/dev/null || echo 0)"
echo "poll $i: revision=${rev:-0}"
[ "${rev:-0}" -ge "$want" ] && break
sleep 20
done
rev="$(kubectl -n "$NAMESPACE" get certificate conformance-lifecycle \
-o jsonpath='{.status.revision}' 2>/dev/null || echo 0)"
[ "${rev:-0}" -ge "$want" ] || { echo "FAIL: revision reached only ${rev:-0} — cert-manager did not renew unattended"; exit 1; }
# The serving material must actually have rotated, not just the revision count.
now="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
-o jsonpath='{.data.tls\.crt}' | cksum)"
was="$(cat /tmp/conformance-cert-r1.cksum)"
echo "r1=$was now=$now"
[ "$now" != "$was" ] || { echo "FAIL: the Secret still holds the revision-1 certificate — nothing was actually reissued"; exit 1; }
# Every renewal request was approved by the controller, never by a person.
bad=0
for cr in $(kubectl -n "$NAMESPACE" get certificaterequest -o name 2>/dev/null); do
st="$(kubectl -n "$NAMESPACE" get "$cr" \
-o jsonpath='{.status.conditions[?(@.type=="Approved")].status}' 2>/dev/null || true)"
who="$(kubectl -n "$NAMESPACE" get "$cr" -o jsonpath='{.spec.username}' 2>/dev/null || true)"
echo " $cr approved=$st requester=$who"
[ "$st" = "True" ] || bad=1
case "$who" in system:serviceaccount:*) : ;; *) bad=1 ;; esac
done
[ "$bad" = 0 ] || { echo "FAIL: a CertificateRequest was not auto-approved, or was not submitted by a controller"; exit 1; }
echo "CERT_RENEWED_UNATTENDED_OK (revision=$rev)"
check:
(contains($stdout, 'CERT_RENEWED_UNATTENDED_OK')): true
- name: then-removing-the-certificate-withdraws-the-serving-material
description: >-
docs/08 reads revocation as "removing/rotating a certificate is a declarative
change". The declarative change is the deletion; withdrawal of the credential
must follow with no manual cleanup. EXPECTED TO FAIL TODAY — cert-manager needs
`enableCertificateOwnerRef: true` for the Secret to be garbage-collected, and the
tracked HelmRelease does not set it. Kept as the requirement reads (R-TEST-06).
try:
- delete:
ref:
apiVersion: cert-manager.io/v1
kind: Certificate
name: conformance-lifecycle
- script:
content: |
set -u
for i in $(seq 1 15); do
if ! kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls >/dev/null 2>&1; then
echo "CERT_WITHDRAWN_OK (serving material gone after $((i * 4))s)"
exit 0
fi
sleep 4
done
owners="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
-o jsonpath='{.metadata.ownerReferences}' 2>/dev/null || true)"
echo "FAIL: the Certificate was deleted but Secret conformance-lifecycle-tls still"
echo " holds usable certificate material (ownerReferences=${owners:-none})."
echo " Removing a certificate therefore does NOT withdraw the credential, so"
echo " R-TLS-02's 'revoked ... without manual steps' does not hold. FIX:"
echo " set enableCertificateOwnerRef: true in platform/cert-manager values"
echo " (owner decision — WS-TEST does not weaken the assertion instead)."
exit 1
check:
(contains($stdout, 'CERT_WITHDRAWN_OK')): true
finally:
# Namespace teardown removes both objects anyway; explicit so a leftover Secret
# from the expected failure above is never left holding key material.
- script:
content: |
kubectl -n "$NAMESPACE" delete secret conformance-lifecycle-tls --ignore-not-found --wait=false || true
kubectl -n "$NAMESPACE" delete certificate conformance-lifecycle --ignore-not-found --wait=false || true
kubectl -n "$NAMESPACE" delete issuer conformance-lifecycle --ignore-not-found --wait=false || true
rm -f /tmp/conformance-cert-r1.cksum || true