Skip to content

feat(omnigraph): add the maintenance window and the pre-deploy plan gate - #480

Merged
blarghmatey merged 8 commits into
omnigraph-013-entrypoint-ledger-v2from
omnigraph-013-maintenance-window
Oct 13, 2026
Merged

blarghmatey merged 8 commits into
omnigraph-013-entrypoint-ledger-v2from
omnigraph-013-maintenance-window

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

What are the relevant tickets?

N/A (witan tasks tk-ol-infrastructure-omnigraph-stack-for-0-12-clust-2dee94 and tk-cover-the-omnigraph-server-entrypoint-s-untested-f43c59, part of the omnigraph 0.13 upgrade, spec in #461 D4 and D5)

Description (What does it do?)

Stacked on #479. On omnigraph 0.13 optimize and cleanup are refused with state_lock_held while the server holds its serve lock, so the nightly sweeps need the server stopped. The entrypoint in #479 also applies schema and policy changes only after Recreate has stopped the old pod, so a change the apply refuses is first seen by a pod that cannot start.

This adds two commands to the omnigraph-server image, for the omnigraph stack in ol-infrastructure to run as a Job's command. They are here and not in ol-infrastructure because both need the entrypoint's Lease, pod check and plan check, and this repo's CI has the real binary.

docker/omnigraph_maintenance.py is the stop window from D5:

  1. Exit 0 if the Deployment carries ol.mit.edu/migration-armed or is already at 0 replicas.
  2. Take the unlock Lease (exit 1 if someone else holds it, which includes a server pod that restarted in the last minute).
  3. Scale the Deployment to 0, conditional on the resourceVersion it read, and wait until no live pod matches the selector.
  4. Force-unlock the serve lock. Any other lock stops the window.
  5. Per graph in cluster.yaml: optimize, force-unlock. On OMNIGRAPH_CLEANUP_ISO_WEEKDAY, a second pass: cleanup --older-than … --confirm --yes, force-unlock. A failed graph does not stop the rest. The cleanup pass starts one graph further along each ISO week, because Production's cleanup sweep took up to 1552s in the last 30 days (beside a live 0.11 server) and a window that runs out must not skip the same graphs every week.
  6. Restore the replica count and release the Lease.

Step 6 also runs on SIGTERM, on a failed step and when the window's own deadline passes. The restore is skipped when the Deployment is no longer at 0 or a migration was armed in the meantime (the write is conditional, so an arm between the read and the write is seen on the retry). If the window's own graph_operation lock could not be removed (Lease lost, or force-unlock failed), the Deployment stays at 0, as D5 describes.

docker/omnigraph_plan_gate.py renders group membership and runs cluster plan through the entrypoint's check_plan, and nothing else. It takes no Lease and never unlocks.

Changes to the entrypoint from #479:

  • run_omnigraph stops its child on any exception and takes a timeout. A SIGTERM handler raising in the main thread used to leave Popen.__exit__ waiting for the child to finish on its own.
  • KubernetesApi gains get_deployment and replace_scale, and UnlockLease gains release().
  • The plan half of converge is now checked_plan, which the gate calls.

Two differences from D5 as written:

  • It is Python in the image, not /bin/sh -c with traps in the CronJob.
  • The window identifies its own lock from cluster status (lock_operation == "graph_operation"), not from the lock_id= the CLI prints on stderr. The root is unlocked before each command, the Lease excludes other unlockers and no server pod is live, so a graph_operation lock found afterwards is that command's. Root-addressed cluster status --cluster <uri> does not report the operation, so the CronJob has to mount cluster.yaml and the window reads the graph list and storage root from it.

The second commit is the coverage Copilot asked for on #479: the real KubernetesApi against a local TLS server, main, a Lease lost between the two unlocks, the non-deployment branch of release_apply_lock, and confirm() racing the renew thread.

Like #479, this only works on 0.13 and merges with the pin bump.

How can this be tested?

  • Against the released 0.13.0 binary: test_omnigraph_maintenance.py, test_omnigraph_server_entrypoint.py and test_omnigraph_kubernetes_api.py, 99 passed, none skipped. Two of the new cases use the real binary on a file:// root: a whole window after a real server has left its serve lock (optimize and cleanup on three graphs, no lock left, the server boots again), and the gate reading a served root without touching its lock and failing on a schema change that adds a required field.
  • On the pinned 0.11.0 the same three files: 92 passed, 7 skipped (the real-binary cases).
  • I built the image with the 0.13.0 build args and ran it with docker run on a local volume: the gate passes on an empty root and beside a running server, the server boots, stops and boots again, and omnigraph_maintenance.py imports the entrypoint through the symlink (it gets as far as the missing OMNIGRAPH_SERVER_DEPLOYMENT).
  • Not tested: the window against a real Kubernetes API (the Deployment and scale calls run against a fake, and that a deployments/scale PUT honours the Deployment's resourceVersion is from the API conventions, not observed), a real SIGTERM delivered to the process (the test raises the handler's exception), and anything on S3. These get observed in the CI cutover.
  • I did not run the full just test-witan-council suite.

https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

On omnigraph 0.13 `optimize` and `cleanup` are refused with
`state_lock_held` while the server holds its `serve` lock, and the server
entrypoint applies schema and policy changes only after `Recreate` has
stopped the old pod. The nightly sweeps therefore need the server stopped,
and a change the apply refuses is first seen by a pod that cannot start.

This adds two commands to the omnigraph-server image for the `omnigraph`
stack to run as a Job's `command`:

- `omnigraph_maintenance.py` scales the server Deployment to 0, waits for
  its pods to exit, takes over the `serve` lock, runs `optimize` (and
  `cleanup` on its weekday) per graph in `cluster.yaml`, and restores the
  replica count. It restores on SIGTERM and on a failed step too. It does
  not restore when a migration was armed during the window, or when its
  own lock could not be removed.
- `omnigraph_plan_gate.py` runs the entrypoint's plan check and nothing
  else, so the same refusal fails the deploy while the old pod is serving.

Both import the entrypoint for the Lease, the pod check and the CLI
wrappers. `run_omnigraph` now stops its child on any exception and takes a
timeout, because a SIGTERM handler raising in the main thread used to leave
`Popen.__exit__` waiting for the child to finish on its own.

Spec: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D4, D5).

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a
wrong URL, a token read once, or a 409 not reported as one would leave the
force-unlock gate unusable with the suite green.

This runs the real client against a local TLS server signed by a throwaway
ServiceAccount CA, and adds cases for `main`, a Lease lost between the two
unlocks of one boot, the non-deployment branch of `release_apply_lock`, and
`confirm()` racing the renew thread.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
A review of the window found four ways it ended wrong:

- A scale-down that was applied but whose response was lost left the
  server at 0 with no restore, because the window only recorded the stop
  after the request returned.
- A restore that never succeeded still exited 0.
- The close removed any `graph_operation` lock, including one that was
  on the root before the window ran a command.
- The pod selector came from the environment, and one that matched no pod
  read as "the server has stopped", so its `serve` lock would have been
  removed while it was still shutting down.

The stop is now recorded before the request, the exit status reflects the
restore, the window only removes a lock after it has started a command,
and the selector is read from the Deployment.

SIGTERM now sets a flag that is checked between steps and once a second
while a command runs, where it used to raise in the main thread. A signal
during the close, during `Popen`, or while the Lease was being taken could
abort that step halfway. The window also waits up to 90s for a Lease a
just-booted server still holds, and `cluster status` and `force-unlock`
are bounded at 60s.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
…graph

Production's cleanup sweep took up to 1552s over the last 30 days, which is
longer than the window allows. With optimize and cleanup interleaved per
graph in `cluster.yaml` order, a window that ran out would skip the same
graphs at the end of the list every week.

Every graph is now optimized first, and the cleanup pass starts one graph
further along each ISO week.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
blarghmatey added a commit to mitodl/ol-infrastructure that referenced this pull request Oct 13, 2026
…6355)

* feat(omnigraph): add the 0.13 plan gate and the nightly stop window

At storage format 14 the stack creates no cluster-apply Job and suspends
the optimize and cleanup CronJobs, because both are refused beside a
serving 0.12+ server. That left a format-14 environment with no pre-deploy
check on a schema or policy change and no compaction or version cleanup.

This adds, gated on the served format like the lock handoff:

- a read-only `cluster plan` Job in the apply Job's slot, which the
  Deployment depends on, so a change the entrypoint's apply would refuse
  fails the update while the old pod is still serving;
- one `omnigraph-maintenance` CronJob in place of optimize and cleanup. It
  scales the server to 0 for a nightly window, under its own IRSA
  ServiceAccount with a Role limited to the unlock Lease, the pod list and
  the server Deployment's scale;
- the `ol.mit.edu/migration-armed` annotation on the Deployment, which the
  window reads so it does not scale the server back up under an armed
  migration;
- a council probe schedule that skips the window's hour.

Both commands are in the omnigraph-server image
(mitodl/agent-kit#480).

Environments on format 9 get a second ServiceAccount on the IRSA role and
no other change.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* fix(omnigraph): force the Deployment patch at format 14 and test the gating

The maintenance window writes `spec.replicas` through the scale
subresource, which makes its client a second field manager of that field.
Server-side apply then refuses a Pulumi update that changes the count, and
the update that does is the one arming a migration. The Deployment now
carries `pulumi.com/patchForce` wherever a window exists.

Nothing ran the format and migration branches of `create_data_tier`, so a
plan gate created during a migration, or one missing at format 14, would
have passed every test. `test_data_tier_wiring.py` runs the whole function
under Pulumi mocks for format 9, format 14 and an armed migration from
each.

Also: `omnigraph:cleanup_iso_weekday: 0` was read as unset and became
Sunday without reaching the range check, and the daily staleness alert's
description now says what `omnigraph-maintenance` is and where its
recovery steps are.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
* feat(witan): move to omnigraph 0.13 and storage format 14

The pin is on 0.11.0, which reads storage format 9. The entrypoint,
maintenance window and plan gate under this commit only work on 0.13, so
the image built from main has to carry the 0.13 binary in the same merge.

This moves the version, release tag, three asset digests and declared
format together in witan-core's installer and both Dockerfiles. Renovate's
bump (#474) moves only the version lines. The digests were hashed from
complete downloads and match the published .sha256 files and the release
API.

Declaring format 14 turns on RESPONSE_CONTRACT_REQUIRED, so a client on
this pin treats an unstamped response as a contract violation. In each
environment the data tier has to be on 0.13 before the witan clients are.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* doc(witan-core): state what the contract gate refuses on a 0.11 server

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* test(omnigraph): skip the serve-lock cases without omnigraph-server

Three real-binary cases start omnigraph-server. They skipped on the 0.11
pin, and at format 14 they run and fail with FileNotFoundError, because
CI and `witan setup` install only the `omnigraph` CLI from the tarball.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
@blarghmatey
blarghmatey merged commit 10318bb into omnigraph-013-entrypoint-ledger-v2 Oct 13, 2026
17 checks passed
@blarghmatey
blarghmatey deleted the omnigraph-013-maintenance-window branch October 13, 2026 20:59
blarghmatey added a commit that referenced this pull request Oct 13, 2026
…479)

* feat(omnigraph): hand the cluster lock over in the server entrypoint

omnigraph 0.13 has no `cluster import` or `cluster refresh`, and every
writer of a cluster root (the server, `cluster apply`, `optimize`,
`cleanup`) leaves an exclusive lock behind when it exits. The shell
entrypoint would fail on its first command, and a pod restart would be
refused with `state_lock_held` until someone force-unlocked by hand.

This replaces the shell entrypoint with a Python one that takes a
Kubernetes Lease, waits until no other live pod matches the Deployment's
selector, renders group membership, plans, releases the previous owner's
`serve` or `deployment` lock, applies, releases its own apply's lock and
execs the server. It exits 1 on a `graph_operation` lock, on an
outstanding deployment, and on a cluster.yaml that drops a graph outside
OMNIGRAPH_GRAPH_REMOVAL_ALLOWLIST, because 0.13 deletes a dropped
graph's storage. Losing the Lease stops the running child and unlocks
nothing.

The policy fixture graph `code_example` becomes `code-example`:
0.13's `cluster apply` accepts the underscore but omnigraph-server
refuses to start on it. policy/check.sh drops `cluster import`.

This only works on 0.13. On 0.11 `cluster apply` needs the ledger that
`import` created, so policy/check.sh fails there, and the real-binary
tests skip until the pin reports storage format 14. It has to merge
with the pin bump.

Design: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D3, D4),
#461

Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG

* fix(omnigraph): keep a metadata-only Lease change from failing a boot

Three things from review of the entrypoint rewrite.

A renewal that hit a 409 and found the Lease still naming this holder
returned failure, so a confirm() that landed on a relabelled Lease
refused the boot. It now retries once on the new resourceVersion.

A truncated Kubernetes API response raises http.client.HTTPException,
which is neither OSError nor ValueError. It escaped _request and ended
the renew thread, and the running apply was then stopped 20 seconds
later for no reason.

`--cluster=<value>` was not recognised, so a storage-URI root passed
that way would have converged the default directory.

The Dockerfile comment said python3-minimal lacks json and ssl. Both
import there; urllib.request and secrets are the ones that do not
(checked in debian:trixie-slim).

Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG

* fix(omnigraph): require an opt-in to unlock outside Kubernetes

Outside Kubernetes the entrypoint has no Lease and no pod list, and it
still force-unlocked whatever `serve` or `deployment` lock it found. A
`docker run` whose cluster.yaml points at a served storage root would
have removed the live server's lock and applied over it.

It now refuses to force-unlock there unless
OMNIGRAPH_UNLOCK_WITHOUT_PEERS=1 is set. `cluster apply` always leaves
a lock, so a local run needs the variable on an empty root too.

Claude-Session: https://claude.ai/code/session_011nyp32f2v7efTTBMqnmEuG

* test(omnigraph): cover the entrypoint's Kubernetes client and main()

Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a
wrong URL, a token read once, or a 409 not reported as one would leave the
force-unlock gate unusable with the suite green.

This runs the real client against a local TLS server signed by a throwaway
ServiceAccount CA, and adds cases for `main`, a Lease lost between the two
unlocks of one boot, the non-deployment branch of `release_apply_lock`, and
`confirm()` racing the renew thread.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* test(omnigraph): refuse TLS below 1.2 in the fake Kubernetes API

CodeQL flags the test server's SSLContext (py/insecure-protocol) because
PROTOCOL_TLS_SERVER leaves TLS 1.0 and 1.1 negotiable. Nothing connects to
it but the client under test, which does not need them.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* feat(omnigraph): add the maintenance window and the pre-deploy plan gate (#480)

* feat(omnigraph): add the maintenance window and the pre-deploy plan gate

On omnigraph 0.13 `optimize` and `cleanup` are refused with
`state_lock_held` while the server holds its `serve` lock, and the server
entrypoint applies schema and policy changes only after `Recreate` has
stopped the old pod. The nightly sweeps therefore need the server stopped,
and a change the apply refuses is first seen by a pod that cannot start.

This adds two commands to the omnigraph-server image for the `omnigraph`
stack to run as a Job's `command`:

- `omnigraph_maintenance.py` scales the server Deployment to 0, waits for
  its pods to exit, takes over the `serve` lock, runs `optimize` (and
  `cleanup` on its weekday) per graph in `cluster.yaml`, and restores the
  replica count. It restores on SIGTERM and on a failed step too. It does
  not restore when a migration was armed during the window, or when its
  own lock could not be removed.
- `omnigraph_plan_gate.py` runs the entrypoint's plan check and nothing
  else, so the same refusal fails the deploy while the old pod is serving.

Both import the entrypoint for the Lease, the pod check and the CLI
wrappers. `run_omnigraph` now stops its child on any exception and takes a
timeout, because a SIGTERM handler raising in the main thread used to leave
`Popen.__exit__` waiting for the child to finish on its own.

Spec: docs/internals/design/omnigraph-0-13-upgrade-spec.md (D4, D5).

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* test(omnigraph): cover the entrypoint's Kubernetes client and main()

Every Lease and pod-list test replaces `KubernetesApi` with a fake, so a
wrong URL, a token read once, or a 409 not reported as one would leave the
force-unlock gate unusable with the suite green.

This runs the real client against a local TLS server signed by a throwaway
ServiceAccount CA, and adds cases for `main`, a Lease lost between the two
unlocks of one boot, the non-deployment branch of `release_apply_lock`, and
`confirm()` racing the renew thread.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* fix(omnigraph): close the maintenance window's failure paths

A review of the window found four ways it ended wrong:

- A scale-down that was applied but whose response was lost left the
  server at 0 with no restore, because the window only recorded the stop
  after the request returned.
- A restore that never succeeded still exited 0.
- The close removed any `graph_operation` lock, including one that was
  on the root before the window ran a command.
- The pod selector came from the environment, and one that matched no pod
  read as "the server has stopped", so its `serve` lock would have been
  removed while it was still shutting down.

The stop is now recorded before the request, the exit status reflects the
restore, the window only removes a lock after it has started a command,
and the selector is read from the Deployment.

SIGTERM now sets a flag that is checked between steps and once a second
while a command runs, where it used to raise in the main thread. A signal
during the close, during `Popen`, or while the Lease was being taken could
abort that step halfway. The window also waits up to 90s for a Lease a
just-booted server still holds, and `cluster status` and `force-unlock`
are bounded at 60s.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* fix(omnigraph): rotate the cleanup pass so a short window starves no graph

Production's cleanup sweep took up to 1552s over the last 30 days, which is
longer than the window allows. With optimize and cleanup interleaved per
graph in `cluster.yaml` order, a window that ran out would skip the same
graphs at the end of the list every week.

Every graph is now optimized first, and the cleanup pass starts one graph
further along each ISO week.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* feat(witan): move to omnigraph 0.13 and storage format 14 (#482)

* feat(witan): move to omnigraph 0.13 and storage format 14

The pin is on 0.11.0, which reads storage format 9. The entrypoint,
maintenance window and plan gate under this commit only work on 0.13, so
the image built from main has to carry the 0.13 binary in the same merge.

This moves the version, release tag, three asset digests and declared
format together in witan-core's installer and both Dockerfiles. Renovate's
bump (#474) moves only the version lines. The digests were hashed from
complete downloads and match the published .sha256 files and the release
API.

Declaring format 14 turns on RESPONSE_CONTRACT_REQUIRED, so a client on
this pin treats an unstamped response as a contract violation. In each
environment the data tier has to be on 0.13 before the witan clients are.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* doc(witan-core): state what the contract gate refuses on a 0.11 server

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC

* test(omnigraph): skip the serve-lock cases without omnigraph-server

Three real-binary cases start omnigraph-server. They skipped on the 0.11
pin, and at format 14 they run and fail with FileNotFoundError, because
CI and `witan setup` install only the `omnigraph` CLI from the tarball.

Claude-Session: https://claude.ai/code/session_01W4TjT25fD1W4TS1uRUY1hC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant