feat(operator): gate readiness on installation completion - #3464
feat(operator): gate readiness on installation completion#3464aweingarten wants to merge 7 commits into
Conversation
`helm install --wait` returned as soon as the apl-operator Deployment was available, which the previous readinessProbe reported within ~30s: it ran `pgrep -f apl-operator`, i.e. "the process exists". The helmfile pipeline then installs the platform for another 10-15 minutes. Bootstrap automation therefore had nothing to gate on and either proceeded into a half-installed platform or polled Argo/pod state and guessed. The operator now writes a `/tmp/ready` marker at the single point where the installation phase has resolved to `completed` — fresh install, recovery install, or a restart on an already-installed cluster — and the readinessProbe tests for that marker. `helm install --wait` and `kubectl wait --for=condition=Available deployment/apl-operator` become truthful convergence gates. Design notes: - Readiness LATCHES. It is not cleared while a later apply runs: the reconcile loop applies every ~5 minutes in steady state, and flapping the Deployment's Available condition would make it useless as a gate. Per-apply status stays in the apl-operator-state ConfigMap. - It FAILS CLOSED. A marker that cannot be written, or an installation that keeps retrying, leaves the pod NotReady — `--wait` times out loudly rather than reporting a convergence that did not happen. - progressDeadlineSeconds is raised to 3600, otherwise `kubectl rollout status` reports ProgressDeadlineExceeded at the 600s default while a perfectly healthy first install is still running. Since the gate makes `--wait` block for the real install duration, it is switchable: `operator.readiness.gateOnInstallationComplete=false` restores the previous process-liveness behaviour. The helmfile-managed charts/apl-operator deployment, which had no readinessProbe at all, gets the same gate. EXECUTION_FLOW.md documents the resulting contract, including the already-present apl-installation-status / apl-operator-state ConfigMaps for phase-level introspection.
The chart comparison itself succeeds on a fork PR; the job then dies at the last line with `gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable` (exit 4). GitHub does not expose secrets to workflows triggered by `pull_request` from a fork, so `secrets.BOT_TOKEN` is empty and `gh pr comment` cannot authenticate — a contributor outside the org has no way to make this check pass. Publish the comparison to the job summary unconditionally (capped at 900KB, under the 1MB summary limit), and skip the commenting step with an explicit ::notice:: when no token is present. Behaviour for in-repo pull requests is unchanged: BOT_TOKEN is set, so the PR comment is still created and edited in place — they just also get the summary.
|
Hi @aweingarten, thanks for your contribution! I will start reviewing and testing your PR soon. Since this touches the installation process, the review will take a bit more time than usual. |
CasLubbers
left a comment
There was a problem hiding this comment.
So this got me thinking what does it actually mean that the apl-operator is 'ready'. With the current place of markOperatorReady (markInstallationComplete) it only installed the core application through helmfile. If the operator would stop working from this point the platform would not work.
So it would be better to move the execution of this function to: https://github.com/linode/apl-core/blob/main/src/operator/apl-operator.ts#L99. There it would mark the operator ready after it did its first apply run. So all the argocd application exists. From there the platform will always try to heal itself through argocd.
Additionally the function is idempotent. So its fine to call it multiple times.
| {{- $version := .Values.otomi.version | default .Chart.AppVersion }} | ||
| {{- $skipDeployment := .Values.installation.skipOperatorDeployment }} | ||
| {{- $readiness := .Values.operator.readiness | default dict }} | ||
| {{- $gateOnInstall := ne $readiness.gateOnInstallationComplete false }} |
There was a problem hiding this comment.
This flag is not necessary. If we have the marker in code which will always run why not always use it?
| {{- $kms := .Values.kms | default dict }} | ||
| {{- $version := .Values.otomi.version | default .Chart.AppVersion }} | ||
| {{- $skipDeployment := .Values.installation.skipOperatorDeployment }} | ||
| {{- $readiness := .Values.operator.readiness | default dict }} |
There was a problem hiding this comment.
There is already a default in the values.yaml
| {{- $readiness := .Values.operator.readiness | default dict }} | |
| {{- $readiness := .Values.operator.readiness }} |
| # with the readinessProbe gated on convergence the rollout stays Progressing | ||
| # for that whole window. Without this, `kubectl rollout status` reports | ||
| # ProgressDeadlineExceeded on a perfectly healthy first install. | ||
| progressDeadlineSeconds: {{ $readiness.progressDeadlineSeconds | default 3600 }} |
There was a problem hiding this comment.
There is already a default in the values.yaml
| progressDeadlineSeconds: {{ $readiness.progressDeadlineSeconds | default 3600 }} | |
| progressDeadlineSeconds: {{ $readiness.progressDeadlineSeconds }} |
| labels: {{- include "apl-operator.labels" . | nindent 4 }} | ||
| spec: | ||
| replicas: 1 | ||
| # Installing the platform takes considerably longer than the 600s default, and |
There was a problem hiding this comment.
This comment is cluttering. One line on why we increased the limit would be better.
| # actually converged. This is what makes `helm install --wait` and | ||
| # `kubectl wait --for=condition=Available` usable as bootstrap gates. | ||
| # NOTE: a first install takes 10-15 minutes — size --timeout accordingly. | ||
| command: ["/bin/sh", "-c", "test -f /tmp/ready"] |
There was a problem hiding this comment.
Lets remove the if else block and keep this command. Additionally we can remove the comment. It's pretty clear what it does.
|
|
||
| ## Readiness and Convergence Contract | ||
|
|
||
| Bootstrap automation needs a machine-checkable answer to "is the platform installed |
There was a problem hiding this comment.
This is not entirely true. When apl-operator is done installing the platform is not completly ready. We still have ArgoCD that is applying stuff. It only means that the operator is finished and started is reconcile loop.
| * restart and re-created as soon as the operator re-confirms the installation | ||
| * status from the apl-installation-status ConfigMap. | ||
| */ | ||
| export const READINESS_FILE = '/tmp/ready' |
There was a problem hiding this comment.
The name speaks for itself, we can remove the above comment
| * Available condition. Per-apply status lives in the apl-operator-state | ||
| * ConfigMap instead. | ||
| */ | ||
| export function markInstallationComplete(filePath: string = READINESS_FILE): void { |
There was a problem hiding this comment.
I would rename this function so it states what it actually does:
| export function markInstallationComplete(filePath: string = READINESS_FILE): void { | |
| export function markOperatorReady(filePath: string = READINESS_FILE): void { |
| writeFileSync(filePath, new Date().toISOString()) | ||
| d.info(`Installation complete, wrote readiness marker ${filePath}`) | ||
| } catch (error) { | ||
| // Deliberately non-fatal: a missing marker keeps the pod NotReady, which is |
There was a problem hiding this comment.
This comment can be removed. It does not tell much.
| */ | ||
| export const READINESS_FILE = '/tmp/ready' | ||
|
|
||
| /** |
There was a problem hiding this comment.
The comment can be removed if we rename the function. Then from the function name it is clear what this function does. That is the clean code we want.
|
I still need to find a way how I can easily deploy and test your fork. |
There was a problem hiding this comment.
Pull request overview
This PR makes apl-operator readiness reflect platform installation completion (helmfile pipeline convergence) instead of mere process liveness, so helm install --wait and kubectl wait --for=condition=Available can be used as reliable bootstrap gates.
Changes:
- Add a
/tmp/readymarker written once installation reachescompleted, and call it from the post-install convergence point in the operator startup flow. - Gate operator
readinessProbeon the marker file (both the bootstrapchart/aplDeployment and thecharts/apl-operatorDeployment), and extend rollout budget for the bootstrap Deployment. - Document the readiness/convergence contract and improve CI workflow output behavior for fork PRs.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/operator/main.ts | Marks installation completion at the single post-install convergence point. |
| src/operator/k8s.ts | Adds READINESS_FILE and markInstallationComplete() to write /tmp/ready safely. |
| src/operator/k8s.test.ts | Adds unit coverage for readiness marker behavior and error handling. |
| src/operator/EXECUTION_FLOW.md | Documents readiness gate semantics and ConfigMap-based introspection contract. |
| charts/apl-operator/values.yaml | Introduces operator.readiness.gateOnInstallationComplete defaulting to true. |
| charts/apl-operator/templates/deployment.yaml | Adds readinessProbe gated on /tmp/ready for the helmfile-managed operator Deployment. |
| chart/apl/values.yaml | Adds readiness gating + rollout budget knobs for the bootstrap operator Deployment. |
| chart/apl/templates/NOTES.txt | Surfaces the new “wait for Available with a longer timeout” guidance. |
| chart/apl/templates/deployment.yaml | Switches readiness from pgrep to /tmp/ready (configurable), and raises progressDeadlineSeconds. |
| .github/workflows/svcaplbot-run-dyff.yml | Always publishes compare output to job summary; adds fork-safe behavior for missing token. |
| # Always publish the diff to the job summary. For pull requests from a | ||
| # fork, secrets — and therefore BOT_TOKEN — are not available, so this | ||
| # is the only channel that can carry the comparison. | ||
| head -c 900000 "$comment_file" >> "$GITHUB_STEP_SUMMARY" |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
chart/apl/values.yaml:143
- The comment for operator.readiness.progressDeadlineSeconds says it’s “only relevant when the readiness gate is on”, but progressDeadlineSeconds affects Deployment rollout reporting regardless of readinessProbe configuration. If the intent is “primarily needed when the gate is on”, the wording should be adjusted to avoid misleading operators who disable the gate.
# Rollout budget for the install window. Only relevant when the readiness
# gate is on — `kubectl rollout status` fails once it is exceeded.
progressDeadlineSeconds: 3600
src/operator/k8s.ts:52
- The JSDoc claims the /tmp emptyDir marker is “cleared on every restart”, but emptyDir volumes persist for the lifetime of the Pod (container restarts don’t clear them). This reads as if a container crash/restart would force re-marking, which isn’t necessarily true; please clarify that the marker is cleared when the Pod is recreated (e.g., reschedule/rollout).
* The marker lives on the pod's /tmp emptyDir, so it is cleared on every
* restart and re-created as soon as the operator re-confirms the installation
* status from the apl-installation-status ConfigMap.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/operator/k8s.ts:52
- The comment says the
/tmpemptyDir marker is "cleared on every restart", butemptyDirpersists for the lifetime of the pod and will survive container restarts within the same pod. If the intent is "cleared when the pod is recreated", the wording should be updated to avoid overstating the guarantee.
* The marker lives on the pod's /tmp emptyDir, so it is cleared on every
* restart and re-created as soon as the operator re-confirms the installation
* status from the apl-installation-status ConfigMap.
charts/apl-operator/templates/deployment.yaml:80
- The readiness gate can keep this Deployment NotReady for 10–15 minutes on a first install, but this chart does not raise
spec.progressDeadlineSeconds. With the default 600s deadline, Kubernetes will mark the rolloutProgressDeadlineExceededwhile installation is still legitimately running, and Helm/kubectl wait/rollout commands may fail early even though the operator is healthy. Consider adding a higherprogressDeadlineSeconds(or making it configurable) whengateOnInstallationCompleteis enabled, similar tochart/apl.
{{- if ne (.Values.operator.readiness | default dict).gateOnInstallationComplete false }}
# /tmp/ready is written by the operator once the platform installation
# reaches the 'completed' state — the Deployment reports Available only
# after the helmfile pipeline has converged, not merely once the process
# is up. Readiness latches, so steady-state reconciles do not flap it.
📌 Summary
Closes #3419 — gives apl-operator a readiness signal that reflects platform installation, so
helm install --waitandkubectl wait --for=condition=Availablestop returning before the platform has converged.On the
auth.<domainSuffix>/readysuggestion@j-zimnowoda thanks — that endpoint (oauth2-proxy behind the ingress) is a genuinely useful external smoke check, and I've documented it in this PR as the complement to the in-cluster gate. It isn't a substitute for one, for four reasons:
helm install --waitstill returns in ~30s, because the operator'sreadinessProbewaspgrep -f 'apl-operator'— "the process exists". Every adopter still has to bolt a bespoke external poll loop onto their bootstrap.curl -k, which weakens what the 200 proves.auth/readystays 200 throughout. There's no way to know the operator applied your change — theAplStatus-shaped ask in the issue.So: good liveness check for the ingress/auth chain, not a convergence signal. This PR adds the latter using state the operator already tracks.
What changed
The operator writes a
/tmp/readymarker at the single point where the installation phase has resolved tocompleted— fresh install, recovery install, or a restart on an already-installed cluster — and thereadinessProbetests for that marker:src/operator/k8s.ts—markInstallationComplete()alongside the existingupdateHeartbeatFile(); same/tmpemptyDir pattern, so the marker clears on restart and is re-written once the operator re-confirms status.src/operator/main.ts— one call, after the install/recovery/already-installed branches converge.chart/apl—readinessProbeswitched frompgreptotest -f /tmp/ready;progressDeadlineSecondsraised to 3600.charts/apl-operator— same gate (it had noreadinessProbeat all).EXECUTION_FLOW.md— documents the contract, including the already existingapl-installation-status/apl-operator-stateConfigMaps, which answer "did the operator apply my commit?" viacommitHash+status. That covered most of the issue's ask already; it just wasn't documented as a contract.Three deliberate properties
Availableon each pass would make the condition useless as a gate. Per-apply status stays inapl-operator-state.--waittimes out loudly instead of returning early.progressDeadlineSeconds: 3600. Otherwisekubectl rollout statusreportsProgressDeadlineExceededat the 600s default while a perfectly healthy first install is still running.🔍 Reviewer Notes
The default is the one thing worth arguing about. With
operator.readiness.gateOnInstallationComplete: true(default here),helm install --waitblocks for the real 10-15 minute install instead of ~30 seconds. Anyone using--waitwith helm's 5m default timeout will now see it time out — and with--atomic, roll back. That's the honest behaviour and the point of the issue, but it is a behaviour change, so it's switchable in one value andNOTES.txtprints the--timeoutguidance. Happy to flip the default tofalse(opt-in) if you'd rather not change what existing--waitcallers see; say the word and I'll push the one-line change.Two smaller notes:
chart/aplfor install,charts/apl-operatorvia helmfile) got the gate so their pod specs don't disagree about readiness across the handover.AplStatusCR with conditions andlastSuccessfulReconcileis still the better long-term answer, and would letkubectl wait --for=condition=Convergedwork per-revision. This PR deliberately doesn't introduce a CRD — it makes the existing signal truthful. Glad to follow up with the CR if you want it.🧹 Checklist
src/operator/k8s.test.ts(marker written with timestamp, idempotent re-mark on restart, never throws when unwritable so the pod stays NotReady, default path matches the probe). Full operator suite: 89 passed / 1 skipped.Also verified locally:
tsc --noEmitclean,eslintclean (33 pre-existing warnings, 0 errors),helm lint chart/aplpasses, and both charts render correctly with the gate on and off.lint:hfwas not run — nohelmfilebinary in my environment; no helmfile templates are touched.