Skip to content

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

scripts/validate-install.sh --env scripts/install.env

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:

scripts/validate-install.sh --dry-run --env scripts/install.env

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:

  • =False on the wildcard: step 9, "Issue the certificates". The cause is almost always upstream, at step 8, "Materialise the Cloudflare token in cert-manager" (the cloudflare-dns-token Secret does not exist in cert-manager), or at step 6.4, "Write the bootstrap secret" (the token was never written into Vault). kubectl describe certificate on the named certificate details the failing DNS-01 challenge.
  • aucun certificat retourne: either certs/ 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", without render-config.sh having been replayed, or another resource already occupied the VIP. Compare with the TV_VIP value 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 ExternalSecret in SecretSyncedError: the ClusterSecretStore is not ready, or Vault is resealed. kubectl get clustersecretstore vault-store must equal READY=True. Step 7, "Close the loop: the ClusterSecretStore", 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 in KC_HOSTNAME, and that value does not carry the domain derived from the sheet. Two paths lead there: the recipe runs with a sheet whose TV_DOMAIN is 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 reponse or curl injoignable: the auth host 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:

  • injoignable on 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 the cloudflared pods are running and that the UUID of the tunnel was indeed carried over into its configuration.
  • injoignable on a single host: the DNS record of that host, or its tunnel route, is missing. Same step.
  • an HTTP code 502 or 503: the public path works, it is the application behind it that does not answer. kubectl get pods -n web -n internal then -n api -n auth immediately distinguishes ImagePullBackOff (step 12, "Images and registry secrets") from CrashLoopBackOff (step 11, "Data layer", and step 13, "Applications and exposure": data secrets and endpoints).
  • a 404 code on auth while hub and portal answer: 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 KO remains, an operating interface (Vault, Grafana, Longhorn) is reachable from the internet.
  • resolveur public injoignable: the operator's workstation does not reach 1.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. dig absent from the PATH produces 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 200 on hub proves 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 monitoring stack, nor at its dashboards, nor at log collection: see Observability, keeping in mind that it is the monitoring stack that counts, not the legacy observability stack.
  • 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 CrashLoopBackOff that is not on the path of the three public hosts passes the recipe without being seen. Complete with kubectl get pods on 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 ExternalSecret resynchronises 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:

scripts/validate-install.sh --dry-run --env scripts/install.env