Architecture¶
Overview¶
dwpk has three runtime components:
cmd/manager, the Kubebuilder controller managercmd/gateway, the SSH gatewaycmd/ui, the marketplace web UI
The control plane state lives in Kubernetes objects. dwpk does not use an external database.
Component map¶
Developer browser / VS Code / ssh client
|
| HTTPS or SSH
v
+------------------------------+
| dwpk UI |
| OAuth2 login, session store, |
| TokenRequest per request |
+------------------------------+
|
| Kubernetes API with minted ServiceAccount token
v
+------------------------------+
| kube-apiserver |
| WorkspaceImage, UserSpace, |
| Workspace, RBAC, webhooks |
+------------------------------+
^
|
+------------------------------+ +------------------------------+
| dwpk manager | | dwpk gateway |
| reconciles CRDs into | | SSH auth, pods/exec, |
| namespaces, quotas, | | pods/portforward |
| StatefulSets, Services | +------------------------------+
+------------------------------+ |
| |
+------------------------------+---------+
|
v
user namespace, workspace pod,
home PVC, ServiceAccount, RBAC
API model¶
Four CRDs define the product surface:
WorkspaceImage, cluster scoped catalog entries curated by admins or synced from a registryUserSpace, cluster scoped records that map one person to one namespace and its quota, RBAC, and network policyWorkspace, namespaced developer sessions backed by a single-replicaStatefulSetImageRegistry, cluster scoped configuration for an external registry (AWS ECR today) to poll and sync into the catalog
Relationship chain:
- A
WorkspaceImagepublishes the runtime image, placement rules, working directory, and UID. It does not publish sizes: resources are chosen per workspace. It optionally names animagePullSecretReffor a private image. - A
UserSpaceprovisionsdwpk-<name>and binds the owner, plus thesessionandworkspaceServiceAccounts, to namespace-scoped access. It also mirrors every labelled pull Secret from the manager's namespace into its own, so a private catalog image can actually be pulled there. - A
Workspacein that namespace references oneWorkspaceImageby name and turns into a headlessService, a single-podStatefulSet, and a retained home PVC. - An
ImageRegistrypolls its configured registry on an interval, applies include/exclude and tag selection, and owns theWorkspaceImageobjects it creates (labelled by registry name, so a prune pass and a second registry never collide).
Manager¶
cmd/manager wires three reconcilers and the Workspace admission webhooks. ImageRegistryReconciler is the one built around a poll timer rather than purely a watch.
UserSpace reconciliation¶
internal/controller/userspace_controller.go turns one UserSpace into these children:
- Namespace
dwpk-<userspace-name> ResourceQuotanameddwpk-quotaLimitRangenameddwpk-limitsNetworkPolicynameddwpk-isolation- Three
ServiceAccounts:workspace,session, andsession-readonly(see below) - Role
dwpk-workspace-user(full owner rights) and Roledwpk-workspace-reader(read-only) - RoleBindings
dwpk-owner,dwpk-reader, anddwpk-workspace-edit - ClusterRole
dwpk-userspace-<userspace-name> - ClusterRoleBinding
dwpk-userspace-<userspace-name>
Three ServiceAccounts, not one, because a browser session and a workspace container must never
share an identity (internal/workspace/names.go):
workspaceis the identity the workspace pod itself runs as. Thedwpk-workspace-editRoleBinding grants it the built-ineditClusterRole, scoped to that namespace only - this is what makeskubectlwork from inside a session against the user's own namespace.sessionis the identity the UI mints a token for on every authenticated request (TokenScopeFull). It is bound intodwpk-owneralongside the human's own OIDCUsersubject, so a browser session gets exactly the same namespaced rights as the owner: full CRUD onWorkspace, pod/log/event reads, and thepods/execgrant the browser terminal needs.session-readonlybacks read-scoped API tokens (TokenScopeRead, see API reference). It is bound intodwpk-reader, the same shape asdwpk-ownerminus every write verb. Kubernetes cannot narrow aTokenRequestbelow the ServiceAccount's own rights, so a read-only token has to mint for a ServiceAccount that only ever had read rights to begin with.
The ClusterRole exists because UserSpace and WorkspaceImage are cluster scoped. It grants:
get,watch, andpatchon exactly oneUserSpace, viaresourceNames(patchonly so a person can save their own SSH keys from My profile - the validator refuses anyone but an administrator changing a field besidesspec.sshAuthorizedKeys)get,list,watch, and the customuseverb onWorkspaceImage
Its ClusterRoleBinding names three subjects: the owner's OIDC User, session, and
session-readonly - all three read the catalog and their own UserSpace by name.
The reconciler also resolves the Kubernetes API server address from the kubernetes Service and its EndpointSlice objects, then bakes that address into the NetworkPolicy egress rules.
Workspace reconciliation¶
internal/controller/workspace_controller.go drives the developer session lifecycle.
Flow:
- Fetch the referenced
WorkspaceImage. - Fail with
status.phase=PendingandImageResolved=Falseif the image does not exist. - Fail if
spec.storageis still unset, which means the CRD default did not apply. - Apply a headless
Servicefirst, then aStatefulSet. - Reflect observed state into
status.phase,status.endpoint,status.podName,status.observedGeneration, andstatus.conditions.
The controller uses a StatefulSet, not a Deployment or a bare Pod, because the StatefulSet gives stable pod naming, suspend and resume through replicas, and PVC retention across restarts.
spec.running=true maps to one replica. spec.running=false maps to zero replicas. The controller never deletes the home PVC - not when a workspace is suspended, and not when it is deleted. Removing it is an explicit act by the person deleting the workspace, offered in the UI's delete dialog and carried out with their own token.
Webhooks¶
The manager serves two Workspace webhooks:
- A mutating webhook on CREATE
- A validating webhook on CREATE and UPDATE
The mutating webhook does two things, both needing an object the CRD cannot see:
- Copies the owner's
UserSpace.spec.sshAuthorizedKeyswhen the workspace names none - Stamps
metadata.annotations["dwpk.devops-ia.io/requester"]fromrequest.userInfo.username
The validating webhook makes the cross-object checks:
spec.imageRef.namemust resolve, and on create the entry must still be on offer- Every SSH key must parse as a real key blob, which CEL cannot do - it can check a prefix but cannot decode base64
spec.volumesmust not shadow the home volume, and mounts must resolve- Requests must not exceed limits, and an extended resource must have them equal
- The requested resources plus what is already running must fit in
UserSpace.spec.quota, workspace count included
The quota check runs on update as well as create. It used to be skipped there, on the reasoning that a count cannot change on an update - which stopped being true when resources became free-form, since a resize is an update.
Rules that do not need cross-object reads stay on the CRD as CEL validations. Examples: UserSpace.spec.owner immutability, Workspace.spec.storage immutability, SSH key prefix checks, and running=true requiring at least one SSH key.
SSH gateway¶
The gateway is a separate stateless server. It does not use leader election.
Connection flow in internal/gateway/server.go:
- Accept SSH on the configured listen address.
- List
Workspaceobjects. - Match the SSH username to
Workspace.metadata.name. - Parse each
spec.sshAuthorizedKeysentry and compare the offered public key. - Store the resolved namespace and workspace name in SSH permissions.
- Re-fetch the
Workspaceand reject the session if it is notRunning. - Resolve the pod from
status.podNameor<workspace-name>-0. - Reject the target if the pod does not carry
dwpk.devops-ia.io/workspace=<workspace-name>. - Open
pods/execfor shell and exec channels. - Open
pods/portforwardwhen SSHdirect-tcpiptargetslocalhost,127.0.0.1, or::1. - Patch
status.lastActivityTimeon session open and close.
Why pods/portforward matters: VS Code Remote-SSH opens loopback listeners inside the pod. The gateway uses pods/portforward for direct-tcpip so those listeners stay reachable.
UI¶
The UI is a Go server with templ templates, htmx, and embedded assets. It is not a SPA.
Routes from internal/ui/server.go:
GET /loginGET /login/{provider}GET /callback/{provider}GET /(dashboard)GET /catalogGET /workspace-images/{name}/iconGET /newPOST /newGET /w/{name}GET /w/{name}/statusPOST /w/{name}/startPOST /w/{name}/stopGET /w/{name}/logsGET /api/v1/workspacesand the rest of the REST surface - seedocs/API_REFERENCE.mdGET /w/{name}/terminal/wsGET /admin/overviewGET /admin/usersGET /admin/quotaGET /admin/catalogGET /admin/settingsGET /admin/workspacesGET /profilePOST /profile/passwordPOST /logout
Current screens:
- Login picker
- Dashboard: workspace status cards, counts, and quota usage - the landing page
- Catalog with text and tag filters, plus a deprecated toggle, at
/catalog - Workspace create form
- Workspace details page with SSH command, VS Code deep link, start and stop buttons
- Browser terminal tab backed by a websocket, with a connection chip and a reconnect button
- Logs and Events tabs, both read with the requesting user's own token
- Admin users page, joining UserSpaces to local password logins
- Admin quota page, usage against limit
- Admin catalog, workspaces, overview and settings pages
- My profile, with quota usage and a password change for local logins
The terminal is xterm.js, vendored under internal/ui/assets/vendor because the
CSP allows no CDN. It connects when its tab is first opened and not before: one
websocket is one kubectl exec, so connecting on page load started a shell in
the pod whether or not anyone wanted one.
internal/ui/workspace.templ splits the page in two on purpose. Only the status
card polls; the tab bar, the terminal and the log view sit outside it. They used
to be inside the polled region, so every three-second tick replaced the terminal
element - orphaning its socket, wiping the output and resetting the selected tab.
Anything that has to survive a poll belongs outside WorkspaceStatusCard.
Logs poll rather than stream. A second websocket would be a second transport to
secure for a tail a five-second GET already delivers. There is no metrics view:
it would need metrics.k8s.io, which dwpk does not depend on.
Destructive actions go through confirmDelete in internal/ui/layout.templ - a
native <dialog> holding the POST form, so the button on the page only ever
opens a question. The workspace confirmation names the retained home PVC, because
no persistentVolumeClaimRetentionPolicy is set on the StatefulSet
(internal/workspace/statefulset.go) and the volume therefore outlives the
workspace.
Multi-provider auth model¶
The provider registry supports these names:
entra-idgooglegitlabkeycloakgithub
cmd/ui/main.go loads provider config from environment. OIDC providers use issuer discovery plus ID token claim checks. GitHub uses OAuth2 plus REST calls to /user and /user/emails because it is the one supported provider without an ID token flow in this codebase.
Per login:
- User picks a provider at
/login. - UI creates a short-lived login challenge with state and nonce.
- Provider returns an authorization code to
/callback/{provider}. - UI exchanges the code for tokens.
- UI reads a verified email from the provider response.
- UI resolves a
UserSpacewhosespec.ownerequals that email. - UI creates a server-side session and stores only an opaque session ID in the browser cookie.
- For each authenticated request, UI mints a short-lived token for the
sessionServiceAccount in the user's namespace by calling the KubernetesTokenRequestAPI (session-readonlyinstead, for a read-scoped API token - see API reference). - UI forwards Kubernetes requests with that minted token.
This matters for security. The UI does not keep standing Kubernetes write access for user actions. Authorization still comes from the session ServiceAccount's own RBAC in the target namespace - a separate identity from the workspace ServiceAccount the pod itself runs as, so a shell inside a workspace never inherits whatever rights an administrator's own browser session carries.
Security boundaries¶
Main boundaries in the current code:
- Identity comes from external OAuth2 providers.
- Namespace isolation comes from one
UserSpacenamespace per person. - Catalog access is checked with
SelfSubjectAccessReviewfor the customuseverb onWorkspaceImage. - Session access comes from exact public key matches in
Workspace.spec.sshAuthorizedKeys. - Workspace API access comes from a minted
session(or, for a read-scoped API token,session-readonly) ServiceAccount token scoped to the user's namespace - never theworkspaceServiceAccount the pod itself runs as. - Gateway pod access is derived from the
Workspaceobject, never from a client-supplied pod name. - Webhook
namespaceSelectorexcludesdwpk-systemandkube-systemso system workloads do not deadlock behind the webhook. ImageRegistrycredentials are never stored in the cluster: auth resolves through the AWS SDK's default credential chain (IRSA, EKS Pod Identity, an instance profile, orspec.aws.roleArnassumed via STS), all resolved inside the manager process. The one Secret this feature does touch -imagePullSecretRef- lives in the manager's own namespace and is mirrored per user namespace byUserSpaceReconciler, never read by the gateway (SPEC ยง6.5: the gateway never reads Secrets).
High availability¶
Leader election is enabled only for the manager. cmd/manager/main.go sets:
LeaderElection=truewhen--leader-electis passedLeaderElectionID=dwpk-controller.dwpk.devops-ia.ioLeaderElectionNamespace=dwpk-system- Lease duration 15s
- Renew deadline 10s
- Retry period 2s
LeaderElectionReleaseOnCancel=true
The raw kustomize deployment in config/manager/manager.yaml runs two manager replicas. The Helm chart defaults manager.replicas to 1, so HA with Helm needs an explicit override.
The gateway and UI are ordinary stateless Deployments. The chart defaults are gateway.replicas=2 and ui.replicas=1.
Current implementation notes¶
A few design points are present in the API or spec but not finished in code:
Workspace.spec.idleTimeoutexists, and the gateway updatesstatus.lastActivityTime, but there is noIdleReconcilerin the repository yet. Auto-stop on idle is not implemented.Workspace.spec.observability.logsEnabledandmetricsEnabledonly stamp a pod annotation and label. The repo does not ship a workspace log sidecar injector or a workspacePodMonitor.- The admin pages are routed in the UI, but the standard per-user
sessionServiceAccount does not get cluster-wide list rights. Those pages only work if an admin adds broader RBAC for that user'ssessionservice account.