Skip to content

Add container workspaces through the shared SAW lifecycle - #153

Open
pmatouse wants to merge 4 commits into
validatedpatterns-sandbox:mainfrom
pmatouse:feat/container-workspaces
Open

pmatouse wants to merge 4 commits into
validatedpatterns-sandbox:mainfrom
pmatouse:feat/container-workspaces

Conversation

@pmatouse

@pmatouse pmatouse commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

This PR adds container workspaces as a second SAW deployment option while keeping VM as the default. A workspace selects the same SAW-BOM profile, provider inputs, harnesses and policies through either backend. RHDH, static Validated Pattern configuration and the Make entry point converge on the same container composition and readiness contract.

This PR was prepared with assistance from OpenAI Codex (GPT-6).

Why this change

SAW's existing profile and self-service model is useful independently of VM provisioning. Running that model on Kubernetes should not introduce another BOM format, duplicate profile-specific behavior, or make Developer Hub a requirement for creating a container workspace. The implementation separates common desired-state logic from the infrastructure adapters that realize it.

The VM adapter continues to install and operate the VM-local OpenShell/Podman stack. The container adapter calls the pinned stock OpenShell gateway's Kubernetes driver; it does not create application Pods independently or require changes to OpenShell gateway, supervisor or driver code.

Architecture and ownership

flowchart TD
  BOM[Shared SAW-BOM profiles and provider catalog] --> Common[Shared profile engine and workspace projection]
  Portal[RHDH authenticated registration] --> Common
  Values[Operator-owned static YAML / Make] --> Common
  Common --> VM[Existing VM adapter and signed installer]
  Common --> Argo[Argo CD container composition]
  Argo --> Foundation[Identity, policies, proxy, storage, gateway]
  Foundation --> Enrollment[Approved boundary enrollment]
  Enrollment --> Intent[BOM + public intent + bound resource identities]
  Intent --> Controller[Scoped Kubernetes reconciler and retained ledger]
  Controller --> Gateway[Stock OpenShell gateway]
  Gateway --> Supervisor[Stock supervisor Pod]
  Gateway --> Sandbox[Stock Agent Sandbox resource/controller]
  Sandbox --> Workload[Application Pod]
  Supervisor <--> Workload
Loading

Each container registration owns its runtime namespace and gateway. Control-plane helpers, provider Secrets and the controller ledger reside in a separate trusted namespace. Audit history is isolated from the application and retained independently. Browser access uses a separate relay namespace and a workspace-scoped nonadmin identity. Keycloak, Vault/ESO, governance, Argo and Agent Sandbox are shared platform dependencies; deleting a workspace does not uninstall those dependencies.

There is no new SAW workspace CRD. The public registration, BOM and intent remain ConfigMap/chart inputs. Agent Sandbox is the stock workload API used by the OpenShell Kubernetes driver.

Shared code and backend adapters

  • saw_core.py contains profile parsing and validation, provider selection, dependency ordering, typed inference inputs and tool-gate bundle construction. The VM and Kubernetes adapters load the same canonical source. The VM embeds it in its existing signed apply_bom.py, preserving the verifier's manifest format.
  • workspace.py projects a registration and catalog into saw-users values, including sandbox topology, model inputs, approved profile digest and enrollment publications. It also implements common container readiness checks. RHDH packages an exact generated copy; the standalone ApplicationSet plugin imports the same implementation.
  • The Kubernetes reconciler compiles the shared profile into pinned OpenShell RPCs. A retained ledger fences installation identity, intent generation and object ownership. Namespace/PVC UID and approved-boundary checks precede mutations. Retries observe existing objects instead of assuming that an interrupted RPC did not commit.
  • Preparation Jobs use the shared policy and harness sources to publish read-only workload inputs. The application receives its workspace PVC, prepared policy/harness and audit exchange, without provider keys or history-storage access.
  • The existing governance interceptor extension gains explicit validation of Kubernetes driver configuration against the approved storage/resource envelope. The extension is ordinary source plus an explicit patch against its pinned upstream source, rather than inline Dockerfile rewriting. The existing SAW interceptor extension remains necessary; this PR does not claim that it is an unmodified upstream image.

Policy and credential distribution

The operator approves profile digests, images, destinations, storage and the deployment boundary. An owner chooses an allowed profile and public model inputs; forms do not accept arbitrary PVCs, executable images or driver configuration. Enrollment records resource UIDs and approved hashes in public immutable publications, which the projector incorporates into the runtime intent. Discovery alone does not approve a boundary.

Provider credentials follow the existing RHDH request-to-Vault flow and ESO projections. A static installation supplies the corresponding Vault references. The credential proxy substitutes real credentials only for approved provider destinations; applications receive opaque markers. Shared provider/profile policy governs both backends.

Native OpenShift admission and network policy enforce the Kubernetes resource and network boundaries. The shared OpenClaw tool gate retains its policy decision semantics. For containers, a restricted forwarder sends audit records from an exchange PVC to a writer that owns the retained history and signing key. A valid signed append receipt is required for a gated action to continue.

Browser traffic follows owner OIDC login → scoped relay → OpenShell forwarding → application. The relay's dedicated Keycloak client receives only the intended workspace's user membership. It does not hold gateway administration credentials.

Security follow-up

The runtime Pod policy defaults to denial. The admission review exposed three ways to submit an unauthorized Pod. The fix binds each of the five supported Pod classes to its producer, owner, executable and approved inputs. It rejects a malicious workload template even when the legitimate Agent Sandbox controller submits it. Argo owns the two stock network fences. The gateway can manage its own boundary Services and bootstrap Secrets under admission. The site enrolls the gateway Role, the admission policies and their bindings.

The portal checks the enrolled Keycloak subject and reserves the registration before writing credentials to Vault. Browser forwarding requires a per-slot proof. Relay registration uses a realm-scoped provisioner and a per-client deletion receipt. The signed tool gate terminates the executor if it cannot record an approval. The runtime and governance image workflows both give PR builds read-only access. Only their main-branch publication jobs can write packages.

Administrator identity lifecycle

Both backends use the same saw_admin.py per-operation identity code. It creates a one-day P-256 CA/client pair in tmpfs, destroys the CA signing key immediately, and removes the client files when the operation ends. Containers do not retain an administrator key or require certificate-renewal inputs.

The VM adapter updates its local client-trust file. The Kubernetes adapter updates only the named public client-CA Secret, using resourceVersion compare-and-swap so the reconciler and relay membership Job can operate concurrently. Each temporary CA is bound to a Pod UID and expiry. The caller waits for an authenticated RPC after the stock gateway reloads projected trust, then withdraws its CA and closes its connection. Subsequent updates remove expired or abandoned sessions. A per-Pod lock protects recovery after a container restart: the helper withdraws its prior entries and removes stale private files from the surviving memory volume before issuing a new identity.

These trust-write permissions remain administrative and belong only to trusted helpers. They are not granted to the application or browser service accounts. Gateway server TLS, JWT signing keys, credential-encryption key and workspace identities remain unchanged by an admin session.

Installation and lifecycle

Entry point Behavior
Validated Pattern / static GitOps Operator-owned saw-users entries enable the read-only ApplicationSet projector; Argo owns component applications. VM entries retain their existing path.
Make DEPLOYMENT_MODE=container dispatches create/status/delete to the same saw-users composition. Helm owns the root release and Argo owns its children. RHDH is optional.
RHDH Existing authenticated create/update/delete pipelines gain a backend selector and backend-approved profile choices. Registration records the backend; update and cleanup use that recorded value.

The standalone projector has named public ConfigMap read access and no Secret-read or resource-write permissions. Its plugin authentication material is generated once and scoped to the owning installation. Both portal and standalone paths consume the same enrollment and relay publications.

Image/input changes create new revision-named preparation, enrollment and membership Jobs with immutable script inputs. Public intent generations drive model changes and stop/start behavior; foreground cleanup removes owned compute and access while retaining designated audit history. Existing registrations cannot silently change backend or adopt replaced resource identities.

The runtime image packages the reconciler, pinned protobuf SDK, helpers and shared source. Its publication workflow builds both amd64 and arm64; deployment values consume reviewed digest pins. Documentation covers component roles, configuration and policy distribution, inference/browser/audit data paths, installation inputs and lifecycle ownership.

Boundaries and next steps

  • Container mode requires Agent Sandbox, Argo ApplicationSet support, enforced native admission/network policy, shared SAW services, and suitable RWO/shared filesystem storage. The workspace installer checks dependencies without taking ownership of unrelated platform operators.
  • Profile capability approval remains explicit. Adding a BOM does not automatically approve its images, credentials, endpoints or mount permissions.
  • A registration has one deployment backend. Changing an enrolled security boundary requires deliberate review; an image change does not implicitly approve new identities.
  • Tool-hook decisions and signed append receipts are application controls, not kernel-level confinement of arbitrary compromised application code. Cluster and storage administrators remain trusted.
  • Trust removal propagates asynchronously and affects new TLS connections; it does not revoke a connection already held by another process. Session cleanup and private-key removal are separate from retained gateway server-identity lifecycle.

Validation

OpenAI/data-science qualification passed on both container sandboxes: actual inference and owner browser chat; shared tool approval/denial and durable audit; model update, stop/start/restart persistence; RHDH create/update/delete/recreate; owner and credential-routing isolation; and fresh static GitOps and RHDH workspace installation. The final fresh installations used normal automatic Job retries, with no manual workload repair or operator retry.

A separate VM regression passed both sandboxes' inference/chat, audited tool decisions, isolation and temporary administrator cleanup. RHDH model update plus the documented targeted restart preserved the VM/root PVC and original workspace/audit data while creating a fresh VMI. RHDH deletion removed compute, root storage, registration and scoped Vault keys. Existing workspaces were unchanged. VM root-disk audit deletion and container audit-history retention remain distinct contracts.

PR history contains one feature commit and one security commit. The security revision was exercised with API-server admission probes and fresh static and RHDH OpenAI installations. The support matrix records provider and hardware scope. Site inputs, credentials and raw evidence remain outside the PR. Support matrix.

Ready for review. Review should settle the supported platform/storage matrix and publication pins before merge. Other providers, Kata/confidential, a fresh entire-platform installation and continuity of an already-open WebSocket are not qualified. An earlier intermittent stock-exec transport failure remains recorded separately; later passes do not erase it.

Add selectable container workspaces alongside the default VM backend, using
shared BOM/provider/tool-gate configuration and the stock OpenShell Kubernetes
driver. Unify RHDH, static GitOps and Make composition, enrollment, lifecycle,
credential isolation, owner browser access and retained audit handling.

Include offline contracts, build workflows, installation and architecture
guides, and the bounded OpenAI/data-science qualification matrix. Live checks
covered container lifecycle and fresh-workspace replay plus VM inference,
chat, audited tools, model update/restart persistence and RHDH deletion.

Developed with assistance from OpenAI Codex.
@pmatouse
pmatouse force-pushed the feat/container-workspaces branch from aaadb1a to 49e545b Compare November 6, 2026 22:18
@pmatouse
pmatouse marked this pull request as ready for review November 6, 2026 22:44

@Hadar301 Hadar301 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one high-severity issue in the Kubernetes admission policy. runtime-validation.yaml:36-39 sets runtimePod only for Pods with the sandbox service account or a workload/supervisor label. The validations at :73-85 then allow any Pod that has none of those markers. The gateway service account can create Pods (rbac.yaml:21-23), so a compromised gateway can create an unmarked Pod whose PodSpec bypasses these checks. That Pod can mount namespace Secrets even though its service account cannot read them through the API. Please deny every Pod that is not an approved gateway Pod or a Sandbox-owned runtime Pod, while keeping the creator and owner checks for each allowed class.

@pmatouse
pmatouse force-pushed the feat/container-workspaces branch 3 times, most recently from dc8da2c to c607880 Compare November 8, 2026 12:44
@pmatouse
pmatouse force-pushed the feat/container-workspaces branch from c607880 to f647f8a Compare November 8, 2026 12:58
@pmatouse

pmatouse commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

@Hadar301, I reproduced the bypass you reported. A server-side dry-run as the gateway service account admitted an unmarked Pod referencing a Secret the gateway could not read directly.

Fixed in f647f8a. Pod admission now defaults to deny. Each permitted class must match its expected creator, owner and Pod spec. The two helper classes needed for gate preparation and audit forwarding are restricted to their Job and ReplicaSet controllers, pinned images and slot-specific volumes.

On OpenShift, the installed policy rejected all six probes, including the original unmarked Pod, a forged workload owner, an unrelated supervisor Secret, a malicious workload submitted by the legitimate Sandbox controller, role removal and image replacement. Legitimate workload creation and scheduling-gate removal passed. Final CI and owner chat on both recreated RHDH sandboxes also passed.

Could you re-review the Pod admission change? Admission policy · Reproducible admission probes

@pmatouse
pmatouse requested a review from Hadar301 November 8, 2026 18:00

@yuvalk yuvalk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

I checked each finding below against the branch head (f647f8a); details are in the inline comments. The diff is about 20k lines across 253 files. The review covered the core Python closely (RHDH portal.py/workspace.py, the standalone projector, and kubernetes-runtime controller/reconcile/intent/boundary/adapters/serve), plus the saw-users templates, enroll.py and the apply_bom.py move. Only skimmed: the Rust driver patch, the vendored agent-sandbox manifest and most Helm templates.

Suggested order

# Priority Where Issue
1 P1, fix before merge kubernetes-runtime/saw_kubernetes/reconcile.py:397 Stopping a workspace before its sandbox ID is recorded leaves it Blocked for good
2 P1, fix before merge charts/openshell-rhdh/files/portal.py:708 A failed or crashed credential write hides the workspace from the catalog and the generator
3 P2 kubernetes-runtime/saw_kubernetes/intent.py:74 Intent CLI rejects restartGeneration, so it can't be used with lifecycle workspaces
4 P2 charts/openshell-rhdh/files/workspace.py:106 Bad site config → KeyError escapes the RHDH generator (dropped connection instead of a 500)
5 P3 kubernetes-runtime/saw_kubernetes/serve.py:36 Boundary verification runs twice per Running pass
6 P3 charts/saw-users/files/projector.py:64 One missing publication returns 503 for every standalone workspace
7 P3 charts/openshell-rhdh/files/workspace.py:145 + charts/saw-users/templates/_helpers.tpl:473 Dead if not site check; misleading "not implemented" error for a missing container site
8 P3 charts/openshell-rhdh/files/portal.py:1081 Catalog /entities probes every route one after another (minutes with many workspaces)
9 nit charts/openshell-rhdh/files/portal.py:459 Hard-coded web-search exclusion belongs in the catalog

Recommendations

  1. Fix the two P1s before merging. Both leave a workspace stuck with no way for the owner to recover from the UI. Each should get a regression test: create → immediate stop for #1, and a Vault write that fails after reserve_credentials for #2.
  2. Make RHDH and the standalone projector agree. Several findings (#4, #6, #7) come from the two entry points into the shared workspace.py handling errors differently: which exceptions get converted, what a 404 publication means, and whether a missing site is rejected. Choosing one behaviour and covering it with shared tests would remove this whole class of bug.
  3. Make the CLI accept the same intent schema as the controller (#3), ideally with a test that runs a portal-emitted lifecycle intent through _previous.
  4. Load and latency (#5, #8) can follow in a later PR, but are cheap enough to do now.
  5. Consider splitting future work of this size. At 253 files, the Rust driver patch and the vendored manifests didn't get a real review here. A dedicated pass on the driver patch is worth it before release.

Checked and not raised: the RuntimeClass read in serve.py's scoped reader can't be reached, because compiler.py:248 rejects any RuntimeClass in container mode.

🤖 Generated with Claude Code

# A stopped new intent never starts a workload to stop it again.
pending.append(ref)
return False
self._mutate("CreateSandbox", {**scope, **sandbox}, plan, generation)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Stopping a new workspace before its sandbox ID is recorded leaves it Blocked for good

This line ignores the CreateSandbox reply, so the sandbox's objectIDs entry is only written by _identity on a later Running pass (line 400). If the owner switches to Stopped before that pass, _stop_references finds the sandbox through GetSandbox but ref not in ids, so it raises ContractError("shutdown refuses unrecorded or replaced workspace/sandbox identity") (line 288). The controller reports Blocked/CoreRejected. Every retry gets the same answer, so the workspace stays stuck until the owner starts it again.

Steps: register a container workspace → first pass sends CreateSandbox and returns Pending → owner submits stop within the ~5s retry window.

Suggestion: record the identity from the create reply, as _apply_provider already does for credentialed providers (lines 356-359):

reply = self._mutate("CreateSandbox", {**scope, **sandbox}, plan, generation)
if reply and reply.get("sandbox"):
    self._identity("sandbox", ws["name"], sname, reply["sandbox"], plan, state)

Also worth deciding whether the stop path should adopt an unrecorded sandbox when its owner/managed labels match this installation, instead of refusing. A regression test for create → immediate stop would cover both.

Comment thread charts/openshell-rhdh/files/portal.py Outdated
log(f"ERROR: registry entry {name} is malformed (no valid user.json); skipped")
bad.append(name)
continue
if cm["metadata"].get("annotations", {}).get(CREDENTIAL_WRITER):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A failed or crashed create/update hides the workspace until someone re-runs create

reserve_credentials (line 636) puts saw.redhat.com/credential-writer on the entry, and only the final put_configmap (around line 1455) writes the entry back without it. If vault.write raises, or the TaskRun pod is OOM-killed or evicted between the two, the annotation stays. This check then skips the entry without looking at the writer, unlike check_credential_writer (line 621), which does check whether the PipelineRun has finished.

Effect: the workspace disappears from the RHDH catalog (list_workspaces(..., deleting=True) at line 1686) and from the ApplicationSet generator (line 1765). Updates and revision pins stop reaching it, and the owner has no catalog entry to act on. Cleanup still works, because finish-delete doesn't go through list_workspaces.

Suggestion: only skip while the writer is actually running, e.g. reuse the check_credential_writer logic (a cached PipelineRun lookup is fine). Or keep showing the entry as Failed / "credential publication incomplete" so the owner or operator can retry.

except (ValueError, TypeError):
raise IntentError("previous intent contains invalid JSON") from None
if (not isinstance(intent, dict) or set(intent) != {
"schemaVersion", "generation", "desiredState", "profileRef", "bindings"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] _previous rejects intents that include restartGeneration

The controller accepts restartGeneration (controller.py:269, set(intent) - {"restartGeneration"}). The portal emits it for lifecycle registrations (portal.py:1440), and enrolled-runtime.yaml:16 puts it in the published intent. This exact-set check makes python -m saw_kubernetes.intent --previous-intent current.json ... fail with "previous intent has an invalid schema" for any model-lifecycle workspace. build_intent also can't emit the field.

Suggestion: allow restartGeneration as an optional non-negative int here, matching the controller, and let build_intent take it so the CLI stays a faithful generator and rollback check.

if not data:
return fresh
bound=json.loads(data["binding.json"])
if set(bound)!={"registration","intent","approved","gatewayCA"} or bound["registration"]!=reg or set(bound["approved"])!=set(a["reference"]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bad site config fails with KeyError instead of WorkspaceError

enrolled_site reads a["reference"] directly here, and below a["relay"] and a["revision"]/a["image"]/a["buildNamespace"]. json.loads(data["binding.json"]) can also raise. reference is only injected when profileBoundaries[selected] exists (project_workspace, around line 168). So automaticEnrollment without a boundary for the selected profile raises KeyError as soon as the enrollment is published.

The standalone projector catches KeyError/TypeError/ValueError (projector.py:96). portal.generator_params only converts WorkspaceError (portal.py:1636), and the serve handler only catches PortalError. The ApplicationSet plugin call then fails with a dropped connection and a traceback instead of a clear 500 naming the bad site.

Suggestion: check the required automaticEnrollment keys up front and raise WorkspaceError("site … missing <key>"), or also catch KeyError/TypeError/ValueError in generator_params the way the projector does. Ideally the chart would also reject this config at render time.


def boundary(plan):
verify(config, plan, get)
resources = KubernetesResourceVerifier(read, boundary)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The full boundary check runs twice on every Running pass

boundary is passed to both Controller(..., boundary) (called at controller.py:352) and KubernetesResourceVerifier(read, boundary), whose __call__ calls verify_boundary(plan) again from Reconciler.reconcile. boundary.verify makes about 22 API reads, including AdminNetworkPolicy and Node lists, so every pass does them twice. At the 5s Pending / 30s resync cadence, that's twice the API-server load and pass latency per workspace, with no added safety.

Suggestion: keep one of the two. The controller-level check maps the failure to BoundaryRejected, which is the better status, so passing a no-op to the verifier (or adding a flag to skip it there) is probably the smaller change. Note adapters.py documents verify_boundary as mandatory, so update that docstring too.

Comment thread charts/saw-users/files/projector.py Outdated
status = missing.code
missing.close()
if status == 404:
return {} # first projection, before foundation creation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] One missing publication blocks projection for every workspace

When the saw-ctl-<reg> namespace exists but saw-portal-enrollment (or a subject map) returns 404/403, this raises ProjectionError. parameters() is all-or-nothing, so /getparams returns 503 for every standalone workspace. The RHDH path treats the same 404 as "not yet published" (ok404=True plus 403 → {} in generator_params).

In practice the namespace is sync-wave -2 and the publication ConfigMaps/Role are wave 0 (saw-portal-container/templates/publications.yaml), so this usually lasts a few seconds. But if that sync stalls (a failed wave -1 resource, a hook failure), every standalone workspace stops updating.

Suggestion: either treat 404 like RHDH does (return {}, i.e. "not bound yet", which enrolled_site already handles), or limit the failure to the one workspace so the other workspaces still get projected. Erroring is the safer default only if a missing publication can mean tampering. If that's the intent, a comment saying so would help.

values = {**{k:v for k,v in defaults.items() if k != "workspaceRevisions"}, "users": [ws]}
if ws.get("name") in revisions:
values["global"] = {**defaults.get("global", {}), "targetRevision": revisions[ws["name"]]}
if mode != "container" or not site:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] The if not site check below never runs

This line already returns for mode != "container" or not site, so the next line's if not site is dead. For a container workspace with no containerSites entry (removed or mistyped), RHDH's generator_params passes site=None/{} and gets plain values back with no registration or installationID.

The chart does stop it (_helpers.tpl:473), but with a misleading message; see the comment there. Suggestion: return early only for mode != "container", and let a container workspace without a site raise WorkspaceError("container site is missing for <name>"). That matches the standalone projector, which already does this at projector.py:92.

Comment thread charts/saw-users/templates/_helpers.tpl Outdated
{{- if or (not $u.ownerSubject) (ne $u.ownerSubject $site.subject) (not (regexMatch "^[0-9a-f]{16}$" (default "" $u.registration))) (ne (len ($u.profiles | default list)) 1) (not (hasKey $catalog $profile)) (not $approved) (not $u.pruneOnRemove) -}}
{{- fail "container requires verified owner subject, scoped registration, approved BOM profile and foreground cleanup" -}}
{{- end -}}
{{- else if ne $mode "vm" -}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] A container user with no site gets a misleading error

When deployment.mode=container and containerSites has no entry for the user, the if above is false and we land here with: "deployment.mode=container is not implemented in this revision; no VM fallback". Container mode is implemented; the real problem is the missing site.

Suggestion: add an explicit branch before this one, e.g.

{{- else if eq $mode "container" -}}
{{- fail (printf "user %q: deployment.mode=container requires a containerSites entry" .user.name) -}}

"text": f"Deleting: {detail}"}
mode = entry_deployment_mode(entry)
if mode == "container" and container_site(user):
return container_status(k8s, user, entry, argo_ns, domain, ping or owner_route_available, catalog)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Catalog fetches probe every UI route one after another

/entities calls workspace_status for each workspace in turn (around line 1690). For container workspaces that now probes each UI link with owner_route_available: up to 3 HTTPS hops with a 3s timeout each. An unreachable route fails on the first hop, so about 3s per link. A route that answers slowly at each redirect can take longer. With 20 container workspaces × 2 UI links that aren't up yet, one catalog refresh takes about 2 minutes, which risks RHDH catalog-provider timeouts and repeats outbound probes on every refresh.

The VM path already pinged one link at a time (ping_url), so the pattern is older than this PR, but the multi-hop probe makes it worse. Suggestion: probe concurrently (e.g. a small ThreadPoolExecutor), or cache readiness for ~30s, or both.

Comment thread charts/openshell-rhdh/files/portal.py Outdated
dedicated_endpoint = (request_deployment_mode(data) == "container" and
any(sb.get("endpointInput") for _, sb in enabled_catalog_sandboxes(catalog[profile])))
for name, spec in sorted(catalog[profile].get("secrets", {}).items()):
if dedicated_endpoint and name == "web-search":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] web-search is excluded by a hard-coded name

For container profiles with a dedicated endpointInput, the request parser drops the secret named web-search, and handle() separately forces webSearch.enabled=False (around line 1446). Both catalog profiles use that exact name today, so nothing is broken. But a renamed search secret, or another tool secret that can't get past the dedicated broker host, would still be required and written to Vault.

Suggestion: declare this in the catalog, e.g. a per-secret "dedicatedEndpoint": "exclude" flag or listing the excluded secrets on the sandbox entry, and have parse_request and handle() read it. Then the profile engine owns the rule.

@pmatouse

pmatouse commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Yuval, I addressed your nine findings in acadb64.

For the create/stop race, the reconciler now journals the create request and records the verified sandbox ID from the gateway reply before returning Pending. On a fresh RHDH registration, we submitted stop about one second after observing the first receipt. The workspace reached Stopped with that exact ID and zero workload Pods. We also reproduced a lost local receipt and recovered the original ID by replaying the request against the stock gateway.

For interrupted Vault writes, the catalog now keeps the registration visible while GitOps uses the last committed configuration. We verified this with a real Vault denial, a terminal Tekton writer and an owner retry.

The follow-up also aligns CLI and controller handling of restartGeneration, fixes the shared projector and Helm diagnostics, preserves bound children across missing publications, removes the duplicate boundary check, and moves catalog route probes off the request path. Dedicated-endpoint credential selection now comes from the BOM. In a two-owner ApplicationSet, injected 403 and 404 reads held the affected child at its exact prior configuration while the other owner advanced.

CI passed, and both freshly recreated RHDH workspaces answered through their authenticated browser chats. Could you re-review the follow-up, particularly the sandbox receipt and credential-publication paths?

@pmatouse
pmatouse requested a review from yuvalk November 8, 2026 23:49
yuvalk
yuvalk previously approved these changes Nov 9, 2026

@yuvalk yuvalk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after acadb64

All nine findings from the first review are addressed. 304 tests pass.

# Finding Status How it's fixed
1 P1 Stopping before sandbox ID recorded ✅ Fixed Sandbox create now persists a pendingSandboxCreates entry, requires a receipt, records the identity from the reply. Stop path raises MissingSandboxReceipt → StartToRecoverSandboxReceipt if the receipt is missing. Two new tests.
2 P1 Failed credential write hides workspace ✅ Fixed reserve_credentials preserves committed.json (last good state). list_workspaces uses it to keep showing the entry. workspace_status shows the writer state with actionable messages. Runtime actions blocked while a writer is pending.
3 P2 Intent CLI rejects restartGeneration ✅ Fixed valid_lifecycle_intent() shared between controller and intent CLI. build_intent accepts --restart-generation and preserves it on update. Two new tests including a full Helm → CLI → controller round-trip.
4 P2 KeyError escapes generator_params ✅ Fixed project_workspace wraps _project_workspace and catches KeyError/TypeError/ValueError/AttributeError → WorkspaceError with safe diagnostics. enrolled_site validates the five required keys up front.
5 P3 Boundary verification runs twice ✅ Fixed KubernetesResourceVerifier split into __call__ (boundary + attachments) and verify_attachments (identity + PVCs). serve.py passes the split methods to the Reconciler; Controller runs boundary once.
6 P3 One missing publication blocks all workspaces ✅ Fixed Projector returns {} for 404/403 (matches RHDH). New previous_child mechanism reads the existing Application to retain last good values during bootstrap or a lost publication. RBAC changed from ClusterRole on namespaces to namespaced Role on Application names.
7 P3 Dead if not site / misleading error ✅ Fixed _project_workspace raises WorkspaceError("container site is missing for <name>"). _helpers.tpl message now says "requires a containerSites entry". Projector template adds a matching early fail.
8 P3 Slow catalog route probes ✅ Fixed New CatalogRouteCache with ThreadPoolExecutor(4), 30s TTL, capacity-bounded. probe() returns False immediately if the check is in flight, starts a background check. Scope-keyed to invalidate on registration changes.
9 nit Hard-coded web-search exclusion ✅ Fixed Catalog now carries dedicatedEndpointSecrets (generated from BOM). parse_request and handle() use it instead of a magic string.

No new issues found in the fix commit. The test coverage for each fix looks solid — the runtime tests cover the create → stop race, lost reply, and replacement detection; the portal tests cover interrupted updates, committed entry projection, cache concurrency, and the catalog-driven secret exclusion; the intent test does a full Helm render → CLI → controller round-trip for lifecycle intents.

🤖 Generated with Claude Code

@Hadar301

Hadar301 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

summary.md
Tried to deploy it. See the attached summary; additional info here

@pmatouse

pmatouse commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

summary.md Tried to deploy it. See the attached summary; additional info here

on it. i started with shared RHDH installation already present -/ thanks, Hadar!

@pmatouse
pmatouse force-pushed the feat/container-workspaces branch 10 times, most recently from e054670 to a535ebb Compare November 9, 2026 19:58
@pmatouse
pmatouse force-pushed the feat/container-workspaces branch from a535ebb to df3919b Compare November 9, 2026 20:17
…dering

Order and verify RHDH startup, preserve bounded reviewed enrollment, and provide stock Kubernetes image preparation for slow cold pulls. Drain owned consumers before webhook removal and verify OpenShell replay by request and sandbox identity.
@pmatouse
pmatouse force-pushed the feat/container-workspaces branch from df3919b to aa3cb54 Compare November 9, 2026 21:01
@pmatouse

pmatouse commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Hadar, thanks for the report. We fixed the RHDH bootstrap ordering, teardown with a blocked Argo hook, sandbox creation recovery after a lost response, and the Pod admission bypass you identified.

@Hadar301

Copy link
Copy Markdown
Collaborator

Round2:
Tried to deploy it; additional info here

The full local test suite also did not pass. Static GitOps and Make entry points did not complete. VM lifecycle and live inference coverage remain incomplete.

@petr-matousek

Copy link
Copy Markdown

@Hadar301 thanks! checking

This branch has not been deployed

No deployments
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.

4 participants