Skip to content

[o2ims] Fix provisioning error handling, resource lookup and the northbound API - #1197

Draft
thc1006 wants to merge 15 commits into
nephio-project:mainfrom
thc1006:o2ims-provisioning-chain
Draft

thc1006 wants to merge 15 commits into
nephio-project:mainfrom
thc1006:o2ims-provisioning-chain

Conversation

@thc1006

@thc1006 thc1006 commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Closes #1174. Closes #1177. Closes #1179.

A provisioning request that could not be created was reported as progressing, one that did not exist was reported as existing, and the northbound API was never listening. They are the same path, so they are here together.

What was happening

get_package_variant and the three other API calls flattened every failure into one shape:

{"status": False, "reason": "..."}

create_package_variant passed that up, and cluster_creation_request then ran:

while status["status"]:

which does not execute, so the function returned the progressing it had initialised. A missing token, a 403 and a 500 all arrived that way. The measured path is a token file that is not there:

create() with TOKEN naming a file that does not exist  ->  progressing

check_o2ims_provisioning_request asked for the collection:

f"{KUBERNETES_BASE_URL}/apis/o2ims.provisioning.oran.org/v1alpha1/provisioningrequests"

so a legitimately empty list came back with no status key, took the "no status yet" branch, and was reported as this request progressing, whatever name was asked for. The requested name was never sent. It also carried an implicit PackageVariant lookup that returned a bool where manager.py:90 indexed a mapping.

threading.Thread(target=run_flask, daemon=True).start was evaluated at import and never called, so nothing listened and nothing said so.

What changes

One transport. Every API call carries a freshly read token, the trust settings #1170 resolved, a bounded timeout and no redirect. Failures are classified rather than collapsed: config, auth, not found, conflict, invalid, unavailable, protocol, transport, each carrying whether another attempt can help, and whether a write may have landed. A token that cannot be read is a configuration failure of its own and no request is sent.

The body is decoded once, after the status is known. Logging it first meant a 403 answered in HTML raised JSONDecodeError only when debug logging was on, and a Kubernetes Status error body was read as a Cluster's nested conditions.

Named lookups. ProvisioningRequest is read by name, cluster-scoped so no namespace in the path, and a collection answered to a named request is a protocol error rather than progress.

PackageVariant ownership. The name is chosen by the request, so an existing one of that name proved nothing. It is matched on the request uid and the cluster it targets, carried in annotations. metadata.label was a string where Kubernetes wants a labels map. A create whose answer was lost, or which lost a race, is settled by reading the resource back:

POST answer lost -> read back -> owned by this request  -> that is the resource
POST answer lost -> read back -> not there              -> the create did not land, retry

One observation per pass. The handlers were async def running synchronous HTTP and time.sleep, which blocks the event loop Kopf watches every resource with. Each pass now makes one observation and hands the retry back to Kopf. The budget counted sleeps, so the time the requests themselves took did not count and a restart was granted a fresh one; it is measured from the request's own creationTimestamp, which needs no schema change and survives a restart. The Ready condition is found by type: conditions[-1] meant the same conditions in a different order decided differently. The budget covers rendering too. It used to be checked only once rendering had finished, so a package that never rendered kept the request progressing for good.

Resource identifiers come from the objects they stand for. A fresh uuid4() on every observation traced to nothing and changed each time it was read; nothing traceable is an empty set rather than a placeholder.

Business status before the handler stops. A Kopf failure is recorded in an annotation, not in the resource, so a creation that failed permanently left the request's own status saying nothing. The cluster subhandler also raised on the previous step's result, so a cluster that failed was recorded as failed and then not raised for. kopf.PermanentError was constructed with event kwargs and never raised.

The northbound API is served. Bound in a startup handler, so a port already in use stops the operator instead of leaving it running with no API, and closed in a cleanup handler. Flask's development server is not meant to serve this, so waitress does. It listens on 127.0.0.1 unless NBI_HOST says otherwise: it has no authentication and acts with the operator's service account, and leaving the port undeclared does not keep it off the pod network. kubectl port-forward reaches it under the caller's own RBAC. If the serving thread ends by itself, the operator exits, so the pod is restarted even where no probe is set.

Both route paths ended in a space. A collection GET returned from inside its loop, so only the first request was ever answered, with a jsonify Response nested inside another dict that cannot be serialised. Provisioning state was read a level above where it is recorded, so every request read as PROGRESSING. Invalid input was answered 500. The resource set was the string test/sample. Requests are created with the SMO's request id as their identity rather than their display name, which is not unique, and an id that cannot be a Kubernetes object name is refused rather than quietly rewritten into one the caller cannot ask with again.

What the caller is told. Every failure the API server answered a create, list or read with became 502, which says the server broke even when the request was the thing at fault. An object it rejected carries through as 400, a conflict as 409, a missing object as 404 and rate limiting as 503; only the rest are reported as an upstream fault. The identity check accepts what the API server accepts: the real RFC 1123 subdomain, label by label, bounded at 253, so an id like a.-b is refused here rather than sent on and rejected there.

The shape the reference describes. ProvisioningRequestInfo requires provisioningRequestData, provisioningRequestReference, status and provisionedResourceSet. The reference was not answered at all; provisioningRequestId was answered a level above where it is declared; ProvisionedResourceSet asks for nodeClusterId and infrastructureResourceIds while the CR records the same two as oCloudNodeClusterId and oCloudInfrastructureResourceIds, and those were passed straight through, so neither required field was present under the name the API asks for; and a recorded state with no mapping was answered as UNKNOWN, which is not one of the five phases the enum allows.

Failures answer the shape too. ProblemDetails types status as a string and every failure answered an object there, so none of them was what this API documents; the message is the status now, and the time it happened travels alongside, which ProblemDetails allows. load_incluster_config raises ConfigException, which is not an ApiException, so no route caught it and all three answered Flask's HTML error page wherever the client cannot be configured — a northbound caller parsing JSON and getting a web page. That is a 503 naming the API server now, with an errorhandler behind it that answers the documented shape for anything unforeseen and tells an unauthenticated caller only that the request could not be served. It re-raises HTTPException, so werkzeug's 404 for an unknown route stays a 404.

The two specifications also disagree about name and description: the CRD types them as strings and rejects a null, the reference requires them of every answer. An empty string is what both take. Every route's answer is validated against the reference's own schemas, for a fulfilled request, one with no status yet, a collection with items and empty, a 201, and a request written straight to the API server rather than through this API.

Permissions. The ClusterRole had no create verb for provisioningrequests, so the northbound API could not create anything anyway. status and finalizers are separate rules now. The admission rule named apiGroups as admissionregistration.k8s.io/v1, which is not a group, so it granted nothing; no handler registers a webhook, so it is removed rather than corrected into a permission that would then be real and unused.

The probe. Kopf only serves it when --liveness is passed, and get_current_timestamp called datetime.datetime.now on a module imported as from datetime import datetime, so it raised AttributeError. The northbound probe connects to the API and raises PermanentError, which kopf answers with 500. Answering stopped inside a 200 told a kubelet nothing, and the serving thread can stay alive after its listening socket is gone.

Proof manifests

tests/conftest.py puts controllers/ on the path the way the image does, so the modules are imported the way they import each other and that layout is exercised. #1034 and #1047 were both about it.

$ python -m pytest --disable-warnings -q
273 passed

tests/test_utils.py                            154 passed
tests/test_northbound_restapi.py                66 passed
tests/test_provisioning_request_controller.py   29 passed
tests/test_manager.py                           24 passed

$ python -m flake8 controllers
$ echo $?
0

163 of those passed before this change; flake8 controllers reported 142 violations before it and none now.

tox.ini lists py311 as well as py312, and the presubmit image carries only 3.12, so the suite was run on 3.11 too: 273 passed there as well.

The northbound tests go through Flask's own routing and serialization, the operator tests through real kopf.Body, kopf.Spec, kopf.Patch, kopf.Memo and real kopf.PermanentError/TemporaryError, and the listener is bound on a real socket. A route string, a status code, a nested Response object and a thread that was constructed but not started are none of them visible to a direct call of a view function. Passing dictionaries where kopf passes a Spec is how a validation check that rejected every real request went unnoticed.

The read timeout is tested against a socket that accepts and then writes nothing, not against the keyword:

timeout=API_TIMEOUT   raises after the read bound, 1.19s
timeout=None          never returns; killed at 25s

The elapsed time is bounded below as well, because a connection refused outright would satisfy "did not hang" while exercising no timeout at all.

The real image, the real CMD, non-root:

$ podman run --rm o2ims-smoke:local id
uid=65535(o2ims) gid=65535(o2ims)

$ kopf run /usr/o2ims/src/manager.py --all-namespaces --liveness=...
Activity 'configure' succeeded.
northbound API listening on 127.0.0.1:15000
Activity 'start_northbound' succeeded.

GET /healthz                          -> 200 {"now": "...", "northbound": "accepting"}
GET the collection on 127.0.0.1       -> 200 {"items":[]}
GET the collection on the host's IP   -> connection refused
listening socket destroyed (ss -K)    -> GET /healthz 500, operator still running

$ # SIGTERM
the northbound API stopped            (exit 0, not taken for a crash)

Those are the two ways serving can fail. The other is the thread ending, and
that one is verified through kopf's own event loop rather than by asserting
that a signal was sent:

northbound API listening on 127.0.0.1:5077
PROBE: closing the server without going through cleanup
ERROR the northbound API stopped by itself; stopping the operator so that it
      is restarted
INFO  the northbound API stopped
INFO  Activity 'stop_northbound' succeeded.
PROBE: kopf.run returned after 6.2s

So the cleanup handlers run and the process returns, rather than being killed
where it stands. The unit test covers this half — the signal is sent — and
that run covers kopf's half, that it is acted on.

That run was against an envtest kube-apiserver, with a service account token mounted where a pod has it, on the host network, so the ports there are 15000 and 18080.

The test deployment runs as the user the image ships with rather than overriding the pod to root, declares the health port, and has probes pointing at that endpoint. Reaching it says kopf started; a 500 says the northbound API does not accept connections.

and the endpoints, answered from inside that image:

POST malformed json                 -> 400   (was 500)
POST no clusterName                 -> 400   (was 500)
POST an id a label may not start with -> 400 (was sent on, rejected there)
POST an id past the length limit    -> 400
GET the old trailing-space route    -> 404   (it is gone)

What this does not do

The companion PR is nephio-project/catalog#149, a draft until this is merged and built. Reading nephio/optional/o2ims/app/ at 28f531c:

  • rbac.yaml has the same gap this fixes in the test deployment: provisioningrequests carries [get, list, watch, update, patch] and no create, so the northbound API cannot create anything. The inert admissionregistration.k8s.io/v1 rule is there too.
  • there are no probes, so the --liveness endpoint this adds is served and never called. Update WatcherAgent to comply to NF CRDs in API repository #149 adds them.
  • terminationGracePeriodSeconds: 5 is what this operator now shuts down inside.
  • the image is pinned to 2097228064147640320, which predates all of this and does not pass --liveness. Update WatcherAgent to comply to NF CRDs in API repository #149 has to move it to this PR's build before it can merge.

Merging here does not update a running deployment. The northbound API stays on loopback until someone decides what authenticates the callers in front of it.

The node cluster id is the CAPI Cluster's own uid. The infrastructure resource ids are read off spec.infrastructureRef, which Cluster API does not fill in, so that list is empty in practice until this operator is given a real inventory mapping; empty, rather than a uuid4 that changed on every observation and traced to nothing.

An empty resource set is conformant: ProvisionedResourceSet requires the two fields, not values in them, so "" and [] satisfy it. I had said this needed a correction in the api repository first; validating the answers against the reference's own schemas says otherwise, and that claim is withdrawn.

PUT and DELETE on a named request are in the reference and are not implemented here.

#1192 and #1193 are untouched and still open. createToken still lists with the zero value, and the singleton is still published before Start fills it in.

The second commit is an adversarial pass over the first. It found a retryable failure reported as progressing under a message that said it had failed, a start time parsed with one format string that quietly handed out a fresh budget in any other shape, the status mapping and identity check above, a session closed before its answer was read, and a stop test that asserted only what stop_northbound sets either way rather than that the thread had ended.

Release triage is yours.

@nephio-prow

nephio-prow Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign johnbelamaric for approval by writing /assign @johnbelamaric in a comment. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@thc1006
thc1006 marked this pull request as draft September 16, 2026 19:55
…hbound API

A provisioning request that could not be created was reported as progressing,
one that did not exist was reported as existing, and the northbound API was
never listening. The three are the same path, so they are fixed together.

The API calls go through one helper. Each of the four raw calls set no timeout
and no redirect policy, and each classified failures differently; a missing
token, a 403 and a 500 all became {"status": False}, which the reconciler's
`while status["status"]` then skipped, returning the "progressing" it had
initialised. Failures now carry what a caller has to decide on: whether another
attempt can help, and whether a write may have landed. Reading a token that is
not usable is a configuration failure of its own, and no request is sent.

The body is decoded once, after the status is known. Logging it first meant a
403 answered in HTML raised JSONDecodeError only when debug logging was on, and
a Kubernetes Status error body was read as a Cluster's nested conditions.

ProvisioningRequest is read by name. The lookup asked for the collection, so an
empty list came back without a "status" key and was reported as this request
progressing, whatever name was asked for. It is cluster-scoped, so no namespace
goes in the path, and a collection answered to a named request is a protocol
error rather than progress. The implicit PackageVariant lookup it carried
returned a bool where the caller indexed a mapping; PackageVariant ownership
belongs to the ensure path, which has the request to compare against.

A PackageVariant is only this request's if it says so. The name is chosen by
the request, so an existing one of the same name proved nothing; it is now
matched on the request uid and the cluster it targets, carried in annotations.
metadata.label was a string where Kubernetes wants a labels map. A create whose
answer was lost, or which lost the race, is settled by reading the resource
back rather than by assuming either outcome.

Reconciliation makes one observation and hands the retry back to kopf. The
handlers were async and ran synchronous HTTP and time.sleep, which blocks the
event loop kopf watches every resource with. The rendering budget counted
sleeps, so the time the requests themselves took did not count and a restart
was granted a fresh budget; it is now measured from the request's own creation
timestamp. The Ready condition is found by type: reading conditions[-1] meant
the same conditions in a different order decided differently.

Resource identifiers come from the objects they stand for. A fresh uuid4 on
every observation traced to nothing and changed each time it was read.

Business status is written before a handler stops. A kopf failure is recorded
in an annotation, not in the resource, so a creation that failed permanently
left the request's own status saying nothing. The cluster subhandler also
raised on the previous step's result, so a cluster that failed was recorded as
failed and then not raised for. kopf.PermanentError was constructed with event
kwargs and never raised.

The northbound API is served. threading.Thread(...).start was evaluated and not
called, so nothing listened and nothing said so; it is now bound in a startup
handler, so a port already in use stops the operator instead, and closed in a
cleanup handler. Flask's development server is not meant to serve this, so
waitress does.

Both route paths ended in a space, so neither was the path the SMO calls. A
collection GET returned from inside its loop, so only the first request was
ever answered, with a jsonify Response nested inside another dict that cannot
be serialised. Provisioning state was read a level above where it is recorded,
so every request read as PROGRESSING. Invalid input was answered 500. The
resource set was the string "test"/"sample". Requests are created with the
SMO's request id as their identity rather than their display name, which is
not unique, and an id that cannot be a Kubernetes object name is refused rather
than quietly rewritten into one the caller cannot use again.

The northbound API could not create anything anyway: the ClusterRole had no
create verb for provisioningrequests. status and finalizers are separate rules
now. The admission rule named apiGroups as "admissionregistration.k8s.io/v1",
which is not a group, so it granted nothing; no handler registers a webhook, so
it is removed rather than corrected into a permission that would then be real.

The image serves the probe: kopf only exposes it when --liveness is passed, and
get_current_timestamp called datetime.datetime.now on a module imported as
"from datetime import datetime", so it raised AttributeError.

tests/conftest.py puts controllers/ on the path the way the image does, so the
modules are imported the way they import each other and the tests exercise that
layout.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Five things an adversarial pass over the previous commit turned up.

A retryable failure was reported as "progressing" with a message that said
"Cluster instance rendering failed", so the state and the text a user reads
beside it disagreed. The message now matches the state it is reported with.

The start of the provisioning budget was parsed with one format string, and a
timestamp in any other shape silently fell back to now, which hands out the
fresh budget this is here to prevent. It is read with fromisoformat, which
takes the trailing Z and any fractional seconds, and a value that still cannot
be read says so rather than resetting the budget quietly.

Every failure the API server answered a northbound create, list or read with
became 502. An object it rejected is the caller's to fix and a missing
permission is not, so 400, 409, 404 and 429 now carry through as themselves and
only the rest are reported as an upstream fault.

The identity check accepted names the API server does not: the pattern allowed
a label starting with a hyphen and an empty label, and had no length limit, so
"a.-b" was sent on and came back 422, which the mapping above would have
reported as the server's fault. It is the real RFC 1123 subdomain now, label by
label, bounded at 253.

api_call closed its session before the answer was read. That works while the
body is preloaded and stops working the moment it is not, so the answer is read
inside the session.

The test for stopping the northbound API asserted only that the module variable
had been cleared, which stop_northbound does either way. It now asserts the
thread has ended and the port can be bound again.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
kopf passes the handler a Spec, a mapping over the resource body, and Spec is
not a dict. The validator asked for one:

    if not isinstance(params, dict):
        return {"status": False, "reason": "... got Spec"}

so every real provisioning request failed validation and was recorded as
permanently failed, whatever it contained:

    validate_cluster_creation_request(kopf.Spec(body))
      -> {'status': False, 'reason': 'provisioning request must be an object, got Spec'}

Nothing caught it. The tests handed the handler dictionary literals, so they
agreed with the check rather than with the thing the check runs against. The
type checks are on Mapping now, and the operator tests build kopf.Body,
kopf.Spec, kopf.Patch and kopf.Memo and let configure fill the memo in, so the
handler runs on what it is actually given. Putting the dict check back fails
eight of the twelve.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…uests alone

Two things reading the CRD turned up.

The northbound API built its spec with data.get(), so a request that left out
name or description - and nothing requires either - was turned into a body
carrying nulls. The CRD types both as strings, so the API server rejects that,
and the caller is told its request was rejected over a field it was never asked
for. Checked against the CRD's own schema at
api@6f722d60f6757f4c5e9a7b60008d0eb3dc73d80e:

    every field       accepted
    description: null rejected, "None is not of type 'string'"
    name: null        rejected, "None is not of type 'string'"
    the key left out  accepted

So the key is left out. Everything else the CRD declares matched already:
the resource is cluster-scoped, status is a subresource, and the field names
under provisionedResourceSet and provisioningStatus are the ones written here,
so none of it is pruned.

Fulfilled is where a provisioning request stops. Resume runs for every existing
request when the operator starts, and this drove a finished one from the
rendering step again: with its PackageVariant gone the request was re-rendered,
which re-creates it and reports the request as progressing after it had been
reported done. A fulfilled request is left alone.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
… empty

spec.infrastructureRef on a Cluster API Cluster is an ObjectReference the user
writes with an apiVersion, a kind and a name. Nothing in cluster-api sets its
uid; the only mentions of InfrastructureRef.UID in that repository are in
conversion tests. So reading the uid off it is right when a provider has set
one and answers nothing the rest of the time, which is to say nearly always.

The code and the pull request both described the identifiers as coming from
the Cluster and its infrastructureRef, which reads as though the list carries
something. It does not, and saying so is the difference between an empty set
that is honest and one that looks like an oversight.

Nothing behavioural changes here. The Cluster's own uid is still the node
cluster id, and an inventory identifier this operator has not been given is
still left out rather than invented.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Reading the reference this operator claims to serve, at
api@6f722d60f6757f4c5e9a7b60008d0eb3dc73d80e, the answers did not match it.

ProvisioningRequestInfo requires provisioningRequestData,
provisioningRequestReference, status and provisionedResourceSet.
provisioningRequestReference was not answered at all. It is "a unique
reference assigned by the service producer at the time of request creation",
which is the uid the API server assigned, not something invented here.

provisioningRequestId belongs inside provisioningRequestData, where the
reference declares it. It was answered one level up, where it is not declared,
and left out of the place it is.

ProvisionedResourceSet requires nodeClusterId and infrastructureResourceIds.
The CR records the same two as oCloudNodeClusterId and
oCloudInfrastructureResourceIds, and those were passed straight through, so
neither required field was present under the name the API asks for.

ProvisioningPhase is an enum: PENDING, PROGRESSING, FULFILLED, FAILED,
DELETING. A recorded state with no mapping was answered as "UNKNOWN", which is
not one of them. It answers PENDING now and logs the state it did not
recognise, so the value is inside the enum and the surprise is still visible.

Checked by validating what each route actually answers against the
reference's own schemas: a named GET of a fulfilled request and of one with no
status yet, a collection with items and empty, and a 201, all conform, and
every phase the mapping can produce is in the enum.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…iption

The two disagree. The CRD types name and description as strings and so rejects
a null; the API reference requires both of every ProvisioningRequestData it
answers with. The previous commit left the keys out when the SMO gave neither,
which the API server accepts and the reference does not:

    the SMO gave no description   -> 'description' is a required property
    the SMO gave no name          -> 'name' is a required property

An empty string is what both take, and it says the SMO gave none rather than
inventing one, so that is what is stored and what is answered. Checked against
both schemas at once for every combination of the two fields, and for a
request written straight to the API server rather than through this API, which
nothing prevents and which may carry neither.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The deployment in nephio-project/catalog sets terminationGracePeriodSeconds: 5.
Shutdown waited up to ten seconds for the northbound API to stop, so the wait
was cut off by SIGKILL half way through and the line that says the server
outlived its budget could never be reached. Three seconds, and settable, so a
deployment that raises the grace period can raise this with it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The test Deployment overrode the pod to uid 0. The image builds for 65535 and
the catalog deployment runs as 65535, so the one place this is exercised before
release was the one place it ran as root, and a permission the operator's own
user lacks would have shown up only in a cluster.

It also declared no ports and no probes, so the liveness endpoint the image now
asks kopf to serve was served and never called. Both ports are named and the
probe points at the health one.

Checked in the built image, as the user it ships with:

    uid=65535(o2ims) gid=65535(o2ims)
    GET /healthz -> {"now": "2026-09-16T20:30:09.855647+00:00"}

Before this there was no endpoint to call: the CMD passed no --liveness, and
the probe handler raised AttributeError when it was reached.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The probe answered a timestamp, which says the process is running and nothing
about the thing this operator is here to serve. It reports the northbound API
too, so a served endpoint is distinguishable from a bound socket:

    /healthz -> {"now": "...", "northbound": "serving"}

And the test deployment gains a readiness probe pointing at it. kopf answers
that endpoint only once it is running, so ready now means the operator started
and the API is still being served, rather than that a port was bound at some
point.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The test asserted that the timeout keyword reached the adapter. That says what
was asked for, not what happens, and it passes just as well against a client
that hangs.

A listening socket that accepts the connection and then writes nothing is what
a stalled API server looks like. The call gives up:

    timeout=API_TIMEOUT   the call raises after the read bound, 1.19s
    timeout=None          the call never returns; killed at 25s

The elapsed time is bounded below as well as above, because a connection
refused outright would also satisfy "did not hang" while exercising no timeout
at all.

The keyword assertion is kept alongside it. It says the policy is applied; this
says the policy works.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the o2ims-provisioning-chain branch from 0a47c18 to 16c8b93 Compare September 19, 2026 14:06
…e budget

The northbound API has no authentication and acts with the operator's
service account, but it listened on 0.0.0.0. Not declaring the port does
not keep it off the pod network. It now listens on 127.0.0.1 unless
NBI_HOST says otherwise, and kubectl port-forward reaches it under the
caller's own RBAC.

The probe reported "stopped" inside a 200, which no kubelet acts on. It now
connects to the API and raises PermanentError, which kopf answers with 500.
If the serving thread ends by itself, the operator exits, so the pod is
restarted even where no probe is set.

The budget was checked only after rendering had finished, so a package that
never rendered kept the request progressing for good. It is checked while
rendering too.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

/test presubmit-nephio-o2ims-python-test

Two things the reference turned up that the success paths hid.

ProblemDetails types status as a string. Every failure answered an object
there, so none of them was the shape this API documents. The message is the
status now; ProblemDetails declares no additionalProperties, so the time it
happened travels alongside it.

And load_incluster_config raises ConfigException, which is not an ApiException,
so no route caught it. Run anywhere the client cannot be configured, all three
answered Flask's HTML error page:

    GET  collection  ->  500  <!doctype html> <title>500 Internal Server Error
    GET  named       ->  500  <!doctype html> ...
    POST             ->  500  <!doctype html> ...

That is a northbound caller parsing JSON and getting a web page. It answers
503 and says the API server cannot be reached, which is the actionable part.

An errorhandler behind both of those answers the documented shape for anything
unforeseen, and tells an unauthenticated caller only that the request could not
be served; the traceback goes to the log, where it is of use. It re-raises
HTTPException, so werkzeug's own 404 for an unknown route stays a 404 rather
than becoming a 500.

Checked by validating every failure each route can answer against
ProblemDetails itself, including the ones that reach no cluster at all.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
nephio-project#1170 made every request to the API server carry a freshly read token. The
northbound routes do not use that code: they reach the API server through a
kubernetes client cached for the process, so the invariant rests on something
this repository does not own.

It holds. load_incluster_config installs a refresh hook by default, the client
reaches it from auth_settings on every request, and it re-reads the token file
once its own minute has passed. Verified by rotating the file and expiring the
window, through auth_settings rather than by reading a field:

    first request   bearer first-token
    rotated, window expired
    next request    bearer second-token

Nothing in the routes said so, and nothing would have failed if the client were
built another way. Passing try_refresh_token=False is a one-word change, and it
fails this now.

certifi is declared for the tests, which was already being used as a CA bundle
by the TLS corpus without being asked for.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The guard that stops a finished request being driven again reads the recorded
status off the Body kopf hands the handler. Body is a mapping and not a dict,
and asking for a dict is what made every real request fail validation one
commit ago, so the one decision that keeps a fulfilled request alone is worth
a test against the type it actually sees.

Nothing changes in the operator. body["status"] is a plain dict today, so the
check works either way; this fails if that stops being true.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant