Validation¶
This page is the conformance recipe that follows the installation. It replaces no verification of the Step by step: every step there already carries its own, on the spot, at the moment it produces its effect. What the recipe adds is a global re-reading of the finished installation, in a single command, through eight checks that observe observable effects and not the execution of a command.
Every failing check names the step of the step by step that produced it. That is the useful
question when a KO appears: not "what is broken now", but "which of the preceding steps
failed without saying so". The cross-references are given check by check further down.
The single command¶
The script modifies nothing, with one exception: check 4 creates a temporary pod in the
default namespace, described with that check. It writes no file and touches no manifest.
| Output | Meaning |
|---|---|
ok <message> on standard output |
the check observed the expected effect |
KO <message> on standard error |
the check observed something else, or could not conclude |
-- <message> (saute) on standard output |
this point could not be exercised; the KO that precedes it gives the cause |
VALIDATION : tous les controles passent., exit code 0 |
installation conformant within the scope of the recipe |
VALIDATION : N controle(s) en echec. on standard error, exit code 1 |
at least one check failed |
The script does not stop at the first KO. It runs through the eight checks, counts the
failures and only exits 1 at the end. A single run therefore gives the complete list of
defects, not just the first one. Do not rerun the recipe after each fix as long as the list has
not been processed in full.
On a conformant installation, every check carries at least one ok line: a single one for each
of checks 1 to 6, then one line per host for checks 7 and 8. Those two host lists are
derived from the delivered manifests, they are written neither into the script nor into this
page, and their length therefore follows the manifests without this page freezing it. The
fastest check of all is to verify that each of the eight numbered headings is followed by at
least one line: a check that displays neither ok nor KO did not run.
A -- line never appears on a conformant installation. It always accompanies a KO that gives
its cause and names a point the recipe could not exercise, the case of the internal hosts
when the public resolver is unreachable. No point therefore disappears silently from the
output.
To list the eight checks and their commands without executing anything, before the cluster even exists:
The output carries the values already substituted from the customer sheet, which makes it possible to check at a glance that the recipe really queries the customer's domain, VIP and realm:
1. noeuds prets
kubectl get nodes
2. certificats emis
kubectl get certificate -A
3. Traefik sur la VIP
kubectl get svc -n traefik-ingress
4. CoreDNS resout
kubectl run ... -- getent hosts hub.${TV_DOMAIN}
5. ExternalSecret synchronises
kubectl get externalsecret -A
6. issuer OIDC
curl https://auth.${TV_DOMAIN}/realms/${TV_KC_REALM}/.well-known/openid-configuration
7. hosts publics
curl -sSI -m 10 https://hub.${TV_DOMAIN}
8. hosts internes non publies
dig +short <host>.${TV_DOMAIN} @1.1.1.1
Before running the recipe¶
The recipe runs from the operator's workstation, not from the cluster. It therefore needs that workstation as much as it needs the cluster.
| Prerequisite | Checks concerned | What happens if it is missing |
|---|---|---|
kubectl in the PATH, kubeconfig pointed at the customer's cluster |
1 to 5 | KO kubectl absent du PATH, ... on each of the five checks |
curl in the PATH |
6 and 7 | KO curl absent du PATH, ... |
dig in the PATH |
8 | KO dig absent du PATH, impossible de verifier le cloisonnement public/interne, followed by one -- line per derived internal host |
The workstation publicly resolves hub, portal and auth under ${TV_DOMAIN} |
7 | the three hosts come back unreachable, while the platform may well be healthy |
The workstation reaches a public DNS resolver (1.1.1.1, outbound port 53) |
8 | KO resolveur public injoignable, ..., followed by one -- line per derived internal host: they are not tested at all |
No check is ever skipped: a missing tool or a closed network produces a KO, not a warning.
That is deliberate, a recipe that silently skips half of its checks would give a conformance it
has not verified. The -- lines of check 8 are no exception: they follow the KO, they do not
replace it, and they serve to name one by one the hosts that this KO left untested. The
counterpart is that a KO does not say by itself whether the defect is in the platform or in
the workstation: read the message, it always distinguishes the two.
An incomplete sheet is refused before the first check
The script derives its values from the customer sheet. It first checks that TV_DOMAIN,
TV_KC_REALM, TV_VIP, TV_TRAEFIK_CLUSTERIP and TV_NODE_COUNT are present there and
not empty. If one is missing, it exits 1 on
ERREUR : fiche incomplete, variable(s) absente(s) ou vide(s) : <noms>, before running the
slightest check and without writing a VALIDATION : line. No check runs halfway,
and the message gives in one go all the variables to complete
(Parameters). TV_NODE_COUNT is refused in addition if it is not a
positive integer, on ERREUR : TV_NODE_COUNT=<valeur> n'est pas un nombre de noeuds valide.
The verification is the same in --dry-run.
The sheet is read by the strict reader of the guide, never executed. A line that is neither a
comment, nor an empty line, nor a simple assignment makes the script exit 1 with the number
of the offending line, before any check.
The eight checks¶
They are numbered here as in the script and come out in this order.
1. Nodes ready¶
| Verifies | that every node of the cluster carries the Ready=True condition, and that the cluster counts as many of them as ${TV_NODE_COUNT} |
| Command | kubectl get nodes in jsonpath, one <nom>=<statut Ready> line per node |
| Success | no line differs from =True and the count equals ${TV_NODE_COUNT}, message ok les <n> noeuds sont Ready |
| Failure | noeuds non Ready : <nom>=False names the offending nodes; <n> noeud(s) Ready, <attendu> attendus par la fiche (TV_NODE_COUNT) when all of them are ready but the count does not come out right; aucun noeud retourne, resultat inexploitable means that kubectl answered without listing any node; kubectl injoignable, l'etat des noeuds n'a pas pu etre verifie : <sortie> carries the raw error from kubectl |
Cross-reference on failure: step 1 of the step by step, "Bootstrap the Talos cluster". A
NotReady node just after an installation most often points at install.disk or at the
extensions of the worker profile; a node absent from the list never received its machine
configuration.
The check counts the nodes, and the expected number comes from the sheet, not from the script:
TV_NODE_COUNT (Parameters). The reference architecture lays down
six, three control-plane and three workers, and that is the example value of the sheet; a
customer who sizes otherwise writes their own number and the check goes green on their own
cluster. One node fewer than that number, all Ready, produces a KO, and that is the intended
effect: a node that never received its machine configuration is visible in no other way in this
recipe.
2. Certificates issued¶
| Verifies | that no Certificate of the cluster is pending or failing |
| Command | kubectl get certificate -A in jsonpath, one <namespace>/<nom>=<statut Ready> line per certificate |
| Success | no line differs from =True, message ok tous les Certificate sont Ready |
| Failure | certificat(s) non Ready : <namespace>/<nom>=False; aucun certificat retourne, resultat inexploitable; kubectl injoignable, l'etat du certificat n'a pas pu etre verifie : <sortie> |
The title and the message speak of all the certificates because the check covers every
Certificate of every namespace, not only the wildcard. Since the internal CA was removed on
2026-09-10, only two remain, the wildcard and the trust-manager one, but the scope of the check
is deliberately wide: a certificate added tomorrow will be covered without anyone having to
think about it. Read the name the message gives before concluding.
Cross-reference on failure:
=Falseon the wildcard: step 9, "Issue the certificates". The cause is almost always upstream, at step 8, "Materialise the Cloudflare token incert-manager" (thecloudflare-dns-tokenSecret does not exist incert-manager), or at step 6.4, "Write the bootstrap secret" (the token was never written into Vault).kubectl describe certificateon the named certificate details the failing DNS-01 challenge.aucun certificat retourne: eithercerts/was never applied, or the cert-manager CRDs are absent, so the base layer of step 3, "Lay down the base layer", did not go through.
3. Traefik on the VIP¶
| Verifies | that the LoadBalancer service of the traefik-ingress namespace really carries ${TV_VIP} as its EXTERNAL-IP |
| Command | kubectl get svc -n traefik-ingress in jsonpath, one <nom>=<EXTERNAL-IP> line per service of type LoadBalancer |
| Success | one of the lines carries exactly the address of the sheet, message ok Traefik EXTERNAL-IP=<VIP> sur le service <nom> |
| Failure | aucun service LoadBalancer de traefik-ingress ne porte <VIP de la fiche>, trouve : <nom>=<adresse>; aucune EXTERNAL-IP retournee pour Traefik, resultat inexploitable when the address is still <pending>; kubectl injoignable, le service Traefik n'a pas pu etre verifie : <sortie> |
Cross-reference on failure:
aucune EXTERNAL-IP: step 5, "Publish the service VIP: kube-vip". The requested VIP falls outside${TV_VIP_RANGE}, or kube-vip did not start.aucun service LoadBalancer ... ne porte <VIP>: kube-vip did assign an address, but not that one, and the message gives those it found. Either the sheet changed after step 2, "Apply the customer sheet to the manifests", withoutrender-config.shhaving been replayed, or another resource already occupied the VIP. Compare with theTV_VIPvalue of the sheet (Parameters) before touching the cluster.
The command reads each LoadBalancer service of the namespace on its own line, and the check
compares line by line. If the namespace carries several of them, it concludes success as soon as
one line matches the VIP, and names the others in the message so that their presence stays
visible. On a conformant installation, that namespace has only one LoadBalancer service.
4. CoreDNS resolves¶
| Verifies | that from inside the cluster, hub.${TV_DOMAIN} resolves to the Traefik ClusterIP, and not to the public address |
| Command | kubectl run validate-install-dns -n default --rm -i --restart=Never --image=debian:stable-slim --quiet -- getent hosts hub.${TV_DOMAIN} |
| Success | the first address returned is exactly ${TV_TRAEFIK_CLUSTERIP}, message ok hub.${TV_DOMAIN} resout vers la ClusterIP Traefik |
| Failure | hub.${TV_DOMAIN} n'est resolu par aucune adresse depuis le cluster : getent sort en 2, CoreDNS ne connait pas ce nom, that is the defect this check exists to catch; hub.${TV_DOMAIN} resout vers <adresse>, attendu <ClusterIP de la fiche> when the resolution lands on something else; aucune reponse de getent, resultat inexploitable; echec de kubectl run, la resolution CoreDNS n'a pas pu etre verifiee : <sortie> for any other non-zero code, an unreachable kubectl or a pod that did not start, the raw output distinguishing the two |
This is the only check that writes into the cluster: it creates a pod named
validate-install-dns in the default namespace, whatever the current namespace of the
kubeconfig, then deletes it. Two practical consequences. The cluster must be able to pull the
debian:stable-slim image, which supposes outbound access to the public registry or a
configured mirror. And if a run is interrupted before the deletion, the next one fails on an
already existing pod: delete the leftover with
kubectl delete pod validate-install-dns -n default before rerunning.
The image is a glibc image, the same as the one prescribed at step 10, "The rest of the network
layer, ingress and internal DNS". getent is a libc command: an image that does not carry it
would make the check fail whatever the real health of CoreDNS.
Cross-reference on failure: step 10 of the step by step, "The rest of the network layer, ingress
and internal DNS". The CoreDNS rule was not applied, does not respect the answer auto format,
or has already been overwritten by Talos, a case explicitly documented at that step. A resolution
that returns the public address instead of the ClusterIP is the most common symptom, and it is
visible in no other way.
5. ExternalSecret synchronised¶
| Verifies | that every ExternalSecret of the cluster has effectively materialised its Secret from Vault |
| Command | kubectl get externalsecret -A in jsonpath, one <namespace>/<nom>=<raison de la condition Ready> line |
| Success | all the lines equal SecretSynced, message ok tous les ExternalSecret sont SecretSynced |
| Failure | ExternalSecret(s) non synchronises : <namespace>/<nom>=SecretSyncedError; aucun ExternalSecret retourne, resultat inexploitable; kubectl injoignable, l'etat des ExternalSecret n'a pas pu etre verifie : <sortie> |
The check covers the reason of the condition, not the existence of the resource: an
ExternalSecret created but never synchronised is a KO, which is indeed the intended effect.
Cross-reference on failure, from the most probable to the least probable:
aucun ExternalSecret retourne: External Secrets Operator is not installed, its CRDs are missing. Step 6.1, "External Secrets Operator".- only one or two
SecretSyncedError, on precise names: the corresponding Vault path is absent or misspelled.kubectl describe externalsecret <nom> -n <namespace>names the path,vault kv get -mount=secrets prod/<service>confirms whether it exists. Step 6.4, "Write the bootstrap secret", for the Cloudflare token; step 11, "Data layer", for the six endpoints of the data layer and, through the open question of that same step, for the Keycloak database. - all the
ExternalSecretinSecretSyncedError: theClusterSecretStoreis not ready, or Vault is resealed.kubectl get clustersecretstore vault-storemust equalREADY=True. Step 7, "Close the loop: theClusterSecretStore", and step 6.2, "Initialise and unseal Vault", if Vault has restarted since (a resealed Vault is signalled nowhere else in this recipe).
The matrix of the Vault paths and of the Secrets they produce is on the Configuration page.
6. OIDC issuer¶
| Verifies | that the OIDC discovery document of Keycloak announces exactly the expected issuer, https://auth.${TV_DOMAIN}/realms/${TV_KC_REALM} |
| Command | curl -sS -m 10 https://auth.${TV_DOMAIN}/realms/${TV_KC_REALM}/.well-known/openid-configuration, then extraction of the issuer field |
| Success | the field read is identical to the issuer derived from the sheet, message ok issuer=<url> |
| Failure | issuer=<url lue>, attendu <url dérivée> : Keycloak fige son issuer dans KC_HOSTNAME, valeur qui n'a pas ete rendue avec le domaine de la fiche; champ issuer absent de la reponse, resultat inexploitable; aucune reponse du document de decouverte, resultat inexploitable; curl injoignable, l'issuer n'a pas pu etre verifie : <sortie> |
Cross-reference on failure:
issuer=different from the expected value: Keycloak freezes its issuer inKC_HOSTNAME, and that value does not carry the domain derived from the sheet. Two paths lead there: the recipe runs with a sheet whoseTV_DOMAINis not the one that was applied to the manifests, or the Keycloak deployment was reapplied from an unrendered manifest, which still carries the original domain of the repository. Step 2 of the step by step, "Apply the customer sheet to the manifests", then reapply Keycloak as at step 13, "Applications and exposure".champ issuer absent: Keycloak answers, but not that document. The${TV_KC_REALM}realm was not created, or its name differs from the sheet. Step 14, "Keycloak and its realm".aucune reponseorcurl injoignable: theauthhost does not answer at all, which is the same defect that check 7 will raise on that host. Handle check 7 first.
An ok on this check only covers the public path
The check calls https://auth.${TV_DOMAIN} from the workstation, therefore through Traefik.
An ok says that the issuer announced on that path is the one derived from the sheet,
nothing more. It
does not say that the internal path, the one the .NET services take to validate their
tokens, announces the same value. The defect where the two paths announce two different
issuers therefore passes this check green, and shows up elsewhere: IDX10205 in the logs
and 401 on every API call. The only test that concludes compares the two paths, it is
described at entry
4 of the Troubleshooting.
7. Public hosts¶
| Verifies | that the three public hosts answer over HTTPS from the outside, with a valid certificate |
| Command | curl -sSI -m 10 on https://hub.${TV_DOMAIN}, https://portal.${TV_DOMAIN} and https://auth.${TV_DOMAIN}, HTTP code and redirection target |
| Success | code 200, or a 301, 302, 307 or 308 redirection whose target stays under ${TV_DOMAIN} and in https, on all three: ok <host> HTTP 200, or ok <host> HTTP <code>, redirection vers <cible>, sous <domaine> |
| Failure | <host> HTTP <code>, redirection vers <cible>, hors de <domaine> when the redirection leaves the domain; <host> HTTP <code>, redirection vers <cible>, hote <hôte> sous <domaine> mais ecrit autrement : la comparaison exige <hôte attendu>, en minuscules et sans point final when the target is under the domain but its host is written in another case or with a trailing dot; <host> HTTP <code>, redirection vers <cible>, sous <domaine> mais en <schéma> au lieu de https when the target stays under the domain but falls back to cleartext; <host> HTTP <code> sans cible de redirection, resultat inexploitable; <host> HTTP <code> for any other code; <host> injoignable via curl : <message de curl> when curl itself fails (DNS, TLS or the ten-second timeout exceeded) |
This check runs from the operator's workstation and therefore takes the complete public path: public DNS, Cloudflare tunnel, Traefik, wildcard certificate, application. It does not distinguish those links from one another.
Cross-reference on failure:
injoignableon all three hosts at once: the public DNS record or the tunnel. Step 13 of the step by step, "Applications and exposure". Check first that thecloudflaredpods are running and that the UUID of the tunnel was indeed carried over into its configuration.injoignableon a single host: the DNS record of that host, or its tunnel route, is missing. Same step.- an HTTP code
502or503: the public path works, it is the application behind it that does not answer.kubectl get pods -n web -n internalthen-n api -n authimmediately distinguishesImagePullBackOff(step 12, "Images and registry secrets") fromCrashLoopBackOff(step 11, "Data layer", and step 13, "Applications and exposure": data secrets and endpoints). - a
404code onauthwhilehubandportalanswer: Keycloak is running but its ingress route does not serve it. - a redirection under the domain but in
http: the target comes from the application or from its route, not from the public path itself. Step 13 of the step by step, "Applications and exposure". hote <hôte> sous <domaine> mais ecrit autrement: the target is indeed under${TV_DOMAIN}, but its host is not written as the comparison expects it, case or trailing dot. The check deliberately refuses to normalise: that exact comparison is what distinguishes the domain of the sheet from a domain that imitates it, and widening it would weaken the only safeguard of the check. Have the host written in lowercase and without a trailing dot by the application or by its route. Step 13 of the step by step, "Applications and exposure".
The check settles redirections by itself
Step 10, "The rest of the network layer, ingress and internal DNS", accepts 200, 302 or
307 on a served route; the check likewise accepts 200, 301, 302, 307 and 308. A
host that legitimately redirects, for example towards a login path, therefore does not
produce a KO, provided the target stays under ${TV_DOMAIN} and in https. The script
extracts the scheme and the host from the Location header: under the domain and in https,
it is an ok that names the target; outside the domain, it is a routing KO; under the
domain but in http, it is a scheme-downgrade KO, the "Verifies" line promises three hosts
that answer over HTTPS and a public host that answers in cleartext does not keep that
promise. No ok line therefore calls for an additional verification, and counting the ok
lines remains a valid shortcut.
The script carries in addition a cible sans hote branch, for a Location header that
would not carry a host. That is a defensive guard, not a case to expect: curl resolves
relative targets into absolute ones before exposing them in %{redirect_url}, so that
branch does not fire. No point looking for it in front of a KO.
The request is a HEAD (curl -sSI). A service that refuses the HEAD method would answer
405 while being healthy; none of the three public hosts is in that case on the reference
instance, but that is the first thing to rule out in front of a 405.
8. Internal hosts not published¶
| Verifies | that no internal host resolves from a public DNS resolver. The list is not written into the script: it is derived from the delivered manifests, the Ingress host: entries minus the hostname: entries the tunnel routes |
| Command | dig +short <host>.${TV_DOMAIN} @1.1.1.1, preceded by a control query on that same resolver |
| Success | no answer for any internal host, one ok <host> non resolu publiquement message per derived host |
| Failure | <host> resout publiquement en <adresse> for each exposed host; resolveur public injoignable, le cloisonnement public/interne n'a pas pu etre verifie if the control query does not answer, followed by one -- <host> non teste (saute) line per derived host |
The hosts checked are not enumerated here, and that is deliberate: naming them would make a
second list to maintain, which would go stale at the first Ingress added. The list is the one
the script derives on every run: the host: entries of every delivered Ingress, minus the
hostname: entries routed by core/cloudflare/cloudflared-config.yaml, each first label being
recomposed under the TV_DOMAIN of the sheet. An Ingress added to the delivery therefore enters
the check with no need to touch the script or this page. To read the list of a given delivery,
run through the output of check 8: it names one host per line, ok or --.
Since the internal TLD was abandoned, this check is the only automatic safeguard of the partition between what is public and what is not. It is not exhaustive for all that, its scope is that of its derivation: a host published otherwise than by an Ingress, or under a domain other than the one of the sheet, is seen by no check of this recipe. The rest rests only on operational discipline: publish only three DNS records and open only the three corresponding tunnel routes.
Cross-reference on failure:
- a host that resolves publicly: one DNS record too many was published, or one tunnel route too
many was opened. Step 13 of the step by step, the "Three public hosts, not one more"
admonition. Remove the record in the Cloudflare zone and the route in the tunnel configuration,
then rerun the recipe. As long as this
KOremains, an operating interface (Vault, Grafana, Longhorn) is reachable from the internet. resolveur public injoignable: the operator's workstation does not reach1.1.1.1, often because the customer's network filters outbound DNS. That is not a defect of the installation, but no internal host is then tested: the check stops on the control query and names the hosts one by one in--, so that none of them disappears from the output. Replay the recipe from a network that reaches a public resolver before pronouncing conformance, failing which the only safeguard of the partition has not been exercised.digabsent from thePATHproduces the same effect, and the same--lines.
What the recipe does not prove¶
Eight green checks mean that the platform is installed in conformance with what the guide describes. They do not mean that it is ready to carry production. The following remain outside the scope of this recipe:
- Load behaviour. No check sends more than one request per host. The k6 scenarios versioned
in
loadtest/are the means provided for that, and they run separately. - The functional application journeys. An
HTTP 200onhubproves that a page is served, not that a declaration gets created, that an event travels on the bus or that a medium is displayed. Those journeys are validated with the customer, from the business use cases. - The restoration of the backups. Neither the backups nor their restoration are exercised here; they belong to the Operations page.
- Monitoring. No check looks at the
monitoringstack, nor at its dashboards, nor at log collection: see Observability, keeping in mind that it is themonitoringstack that counts, not the legacyobservabilitystack. - The state of the application pods taken one by one. The seven APIs and the internal
portals are reached by no check. A service in
CrashLoopBackOffthat is not on the path of the three public hosts passes the recipe without being seen. Complete withkubectl get podson the four zones before pronouncing conformance. - Internal access over VPN. Check 8 verifies that the internal hosts are not public, never that they are reachable by the people who must access them. Test that by hand from a workstation connected to the VPN.
- The state of the Vault seal. A Vault resealed after the installation is visible here only
indirectly, through check 5, and only if an
ExternalSecretresynchronises in the meantime.
Keeping the page and the script aligned¶
This page describes scripts/validate-install.sh check by check, with its exact messages. The
two are modified together, in the same commit: a page that describes a check the script no
longer runs is worse than an absent page, because the operator believes it. After any
modification of the script, compare the two with: