Hedronite · Dev Lesson · Polyglot-Dev / Python · Sun 2026-08-16 · Trio #91

Python and the Scheduler Surface — a fit-checker that does not bind

List nodes. Read taints and allocatable. Print the no. Do not bind.

Lesson Class: Dev (Python + Kubernetes client)
Sprint: K8s track · day 25 · trio #91
Cloud Referent: AKS node objects; no ARM calls
Paired Ops: AKS system-pool taint and user-pool requests
Paired Cert: CKA Workloads and Scheduling 15%
Grounding: K8sUR3 pp.86-88 · CKA Q10 · not 08-10 watch, not 08-13 kopf
Taints
NoSchedule and NoExecute are hard nos. PreferNoSchedule is not.
Allocatable
Subtract running requests, including DaemonSets. Capacity is the SKU, not the leftover.
No bind
create_namespaced_pod_binding is out of scope. A second scheduler is a bug.
Picking a winner is scheduling. Scheduling is bind. Bind is out of scope.

<!-- hal:authoritative:yaml -->

§I — Frame

The 08-10 Dev lesson watched the event stream until a Pending Pod named its owner. The 08-13 Dev lesson stamped a RuntimeClass at create. Both tools act after, or at, admission. Neither one answers the question the day's Ops lesson just asked: would this spec land, on the nodes that exist right now, if I did not create it?

kube-scheduler answers that by binding. Binding is a write. A write on a production AKS cluster is how a "just checking" script becomes a Pod in kube-system because you pointed it at the wrong namespace.

Call the failure the bind you did not mean. The client listed nodes, decided node 3 was fine, and called create_namespaced_pod_binding. The scheduler was not in the path. The taint you ignored is now a running process on the system pool.

Today's tool lists nodes, reads taints and allocatable, compares them to a Pod spec, and prints the same no the scheduler would print. It does not bind. It does not create. It does not watch. It is a function over two objects.

§II — The two objects

A node, as the client sees it

from kubernetes import client, config

config.load_kube_config()
v1 = client.CoreV1Api()
nodes = v1.list_node().items

Each V1Node carries the two fields the Ops lesson named. spec.taints is a list of V1Taint (key, value, effect). status.allocatable is a dict of quantities (cpu, memory, pods). status.capacity is the raw SKU. The scheduler packs allocatable, not capacity. Azure CNI's max-pods shows up here as a smaller pods number than the SKU implies.

metadata.labels is the affinity half. AKS writes agentpool, kubernetes.azure.com/agentpool, and the SKU. A fit-checker that ignores labels will report a node the Deployment's nodeSelector already refused.

A pod spec, as input, not as an object in etcd

The checker takes a V1PodSpec you built in memory, or loaded from YAML with utils.create_from_yaml against a dry document. It does not need a live Pod. That is the point. The 08-10 triage tool required a Pending object so it could read events. This tool is the question you ask before the object exists.

pod = client.V1PodSpec(
    containers=[client.V1Container(
        name="app",
        resources=client.V1ResourceRequirements(
            requests={"cpu": "500m", "memory": "512Mi"},
        ),
    )],
    tolerations=[],
)

Requests default to zero if omitted. Zero fits every node and then gets evicted later. The checker should treat a missing request as a warning, not as a pass. K8sUR3 is blunt: the request is the minimum the scheduler reserves (pp. 86-88). A zero is a lie you told the packer.

§III — The predicate, in order

kube-scheduler runs a chain. You do not reimplement the chain. You reimplement the three predicates the Ops lesson made load-bearing on AKS.

1. Taints

A taint blocks the node unless the Pod names a matching toleration. Match is key, value, and effect. Q10's taint is PERMISSION=granted:NoSchedule. The system pool's is CriticalAddonsOnly=true:NoSchedule. A toleration with operator: Exists and no key tolerates everything. That is the add-on Pod's trick. It is not the app's.

def taints_ok(node, spec):
    taints = node.spec.taints or []
    tols = spec.tolerations or []
    for t in taints:
        if t.effect not in ("NoSchedule", "NoExecute"):
            continue
        if not any(_tol_matches(tol, t) for tol in tols):
            return False, f"untolerated taint {{{t.key}: {t.value}}}"
    return True, ""

Skip PreferNoSchedule in the hard predicate. The scheduler may still place there. Your checker is answering the exam question and the AKS system-pool question, both of which use NoSchedule.

2. Allocatable

Sum the container requests. Compare to node.status.allocatable. Do not subtract running Pods unless you also list them. A checker that ignores current use will pass a node the scheduler will refuse. List pods with field_selector=spec.nodeName=<name> and subtract their requests. DaemonSets count. That is why a 2-vCPU user node refuses a 2 CPU app.

def cpu_ok(node, spec, used_cpu):
    need = _sum_cpu(spec)
    have = _parse_cpu(node.status.allocatable.get("cpu", "0"))
    leftover = have - used_cpu
    if need > leftover:
        return False, "Insufficient cpu"
    return True, ""

Same shape for memory and for pods. The pods resource is the one Azure CNI max-pods changes.

3. Labels

If spec.node_selector is set, every key must match node.metadata.labels. Affinity is larger. For this tool, implement nodeSelector and required nodeAffinity. Preferred affinity is a score, not a no. Do not pretend you scored it.

§IV — What the function returns

Return a list of per-node verdicts, not a single boolean. The Ops describe event is a count: 0/6 nodes are available: 3 ... taint, 3 Insufficient cpu. Your output should be that sentence, computed.

def fit(spec, nodes, pods_by_node):
    reasons = []
    for n in nodes:
        ok, why = predicates(n, spec, pods_by_node.get(n.metadata.name, []))
        if not ok:
            reasons.append((n.metadata.name, why))
    if len(reasons) == len(nodes):
        return "NO", reasons
    return "YES", [n.metadata.name for n in nodes if n.metadata.name not in {r[0] for r in reasons}]

Print NO with the grouped reasons. Do not pick a winner. Picking a winner is scheduling. Scheduling is bind. Bind is out of scope.

§V — What this tool must not do

It must not bind. create_namespaced_pod_binding is the scheduler's write. A fit-checker that binds is a second scheduler with a worse predicate list. The 08-04 lesson already taught SelfSubjectAccessReview as a preflight. If you want to be sure the account cannot bind, review pods/binding and refuse to start if the verb is allowed and you did not pass --i-mean-it. Default is deny the call in code: do not import the binding model.

It must not watch. 08-10 owns the watch. This tool is a snapshot. A snapshot is wrong a second later. Say so in the output.

It must not mutate. 08-13 owns kopf. If you want a webhook that rejects a Pod that would not fit, that is a later rung. Today's function returns a string.

It must not talk to Azure. The taint on the system pool is already on the node object. ARM will not give you a better answer.

§VI — Worked AKS snapshot

Six nodes. Three system, tainted CriticalAddonsOnly. Three user, agentpool=user, 2 CPU allocatable, 500m already used by DaemonSets. Spec: 500m, no toleration, no selector.

The checker prints: system nodes fail taint, user nodes pass. YES on the three user names.

Same spec with cpu: 2. User nodes fail Insufficient cpu. System still fail taint. NO, 0/6, the Ops event in your own words.

Same spec with a toleration for CriticalAddonsOnly. System nodes now pass the taint predicate and fail or pass on CPU. That is the fence opening. The tool should print a warning when the only YES nodes are add-on nodes.

Q10 in Python is the first case with one node and one taint you applied yourself. Run the checker against Q10's cluster before and after the toleration. The printed reason should change from untolerated taint {PERMISSION: granted} to YES. If it does not, your match function is wrong, not the cluster.

§VII — What this rung adds

08-10 read events after the scheduler had already said no. Today you compute the no from the same fields the scheduler reads, without writing a bind. The Ops lesson is the AKS topology those fields describe. The Cert lesson is the CKA 15% that will ask you to name which field made the no.

Related

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-16 at Fajr catch-up · Trio #91 · sprint day 25 · K8s track
Ops · Dev · Cert trio shipped MD + HTML in-cycle