Skip to content

Commit b795ec4

Browse files
bobbyjohnstxclaude
andcommitted
fix: hostPath SCC bug, readOnly mount, SCC UID enforcement, observedGeneration, RBAC tightening, Helm checksum
- Fix 1: hostPath SCC selection bug — check .path instead of .enabled - Fix 2: Apply readOnly to hostPath volumeMount in Helm template - Fix 3: Enforce UID 1001 in restricted/hostpath SCCs (MustRunAs), leave shell SCC flexible - Fix 4: Add observedGeneration to status - Fix 5: Remove list/watch from secret RBAC (only get needed) - Fix 6: Add Helm download checksum verification in Dockerfile - Fix 7: Compute spec hash to skip no-op Helm upgrades - Fix 8: Reuse DynamicClient across calls - Fix 9: Add --liveness flag to CSV command - Fix 10: Document hostPath ephemeral data in sample YAML Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c430c5 commit b795ec4

8 files changed

Lines changed: 82 additions & 20 deletions

File tree

‎Dockerfile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ ARG HELM_VERSION=v3.17.3
1515
RUN microdnf install -y tar gzip shadow-utils python3.11 && \
1616
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" \
1717
-o /tmp/helm.tar.gz && \
18+
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" \
19+
-o /tmp/helm.sha256 && \
20+
cd /tmp && sha256sum -c helm.sha256 && \
1821
tar -xzf /tmp/helm.tar.gz -C /tmp && \
1922
install -m 755 /tmp/linux-amd64/helm /usr/local/bin/helm && \
20-
rm -rf /tmp/helm* && \
23+
rm -rf /tmp/helm* /tmp/linux-amd64 && \
2124
microdnf remove -y tar gzip shadow-utils && \
2225
microdnf clean all
2326

‎bundle/manifests/tinycode-operator.clusterserviceversion.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ spec:
162162
containers:
163163
- name: manager
164164
image: quay.io/tinycode/operator:latest
165-
command: [python3.11, -m, kopf, run, --all-namespaces, /app/main.py]
165+
command: [python3.11, -m, kopf, run, --all-namespaces, --liveness=http://0.0.0.0:8081/healthz, /app/main.py]
166166
env:
167167
- name: HELM_CHART_PATH
168168
value: /helm-charts/tinycode

‎config/rbac/role.yaml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,6 @@ rules:
8686
- secrets
8787
verbs:
8888
- get
89-
- list
90-
- watch
9189

9290
# SCC bindings — operator binds the appropriate SCC to instance SAs
9391
- apiGroups:

‎config/samples/tinycode_v1alpha1_hostpath.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
# SECURITY: The host path at /home/developer/projects will be writable
99
# by the tinycode container (UID 1000). Ensure the host directory
1010
# permissions allow this UID.
11+
#
12+
# EPHEMERAL DATA: In hostPath mode, session data (SQLite DB, config) uses
13+
# emptyDir and is lost on pod restart. Only the /projects workspace persists
14+
# via hostPath.
1115
apiVersion: tinycode.dev/v1alpha1
1216
kind: TinycodeInstance
1317
metadata:

‎config/scc/tinycode-hostpath-scc.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ labels:
2525
app.kubernetes.io/managed-by: tinycode-operator
2626

2727
runAsUser:
28-
type: RunAsAny
28+
type: MustRunAs
29+
uid: 1001
2930

3031
seLinuxContext:
3132
type: RunAsAny

‎config/scc/tinycode-restricted-scc.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ labels:
1616

1717
# Run as a specific non-root UID (matches container image user)
1818
runAsUser:
19-
type: RunAsAny
19+
type: MustRunAs
20+
uid: 1001
2021

2122
seLinuxContext:
2223
type: RunAsAny

‎helm-charts/tinycode/templates/deployment.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ spec:
160160
# /projects — user workspace files
161161
- name: projects
162162
mountPath: /projects
163+
{{- if .Values.storage.hostPath.enabled }}
164+
readOnly: {{ .Values.storage.hostPath.readOnly | default false }}
165+
{{- end }}
163166
{{- if .Values.clusterAdmin.enabled }}
164167
- name: kubeconfig-vol
165168
mountPath: /home/tinycode/.kube/config

‎operator/main.py‎

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"""
1212

1313
import asyncio
14+
import hashlib
1415
import ipaddress
16+
import json
1517
import logging
1618
import os
1719
import subprocess
@@ -59,6 +61,15 @@
5961
custom_api = kubernetes.client.CustomObjectsApi()
6062
rbac_v1 = kubernetes.client.RbacAuthorizationV1Api()
6163

64+
_dynamic_client = None
65+
66+
def get_dynamic_client():
67+
"""Return a cached DynamicClient instance."""
68+
global _dynamic_client
69+
if _dynamic_client is None:
70+
_dynamic_client = kubernetes.dynamic.DynamicClient(kubernetes.client.ApiClient())
71+
return _dynamic_client
72+
6273

6374
# ── Helpers ───────────────────────────────────────────────────────────────────
6475

@@ -85,7 +96,7 @@ def scc_name_for_spec(spec: dict) -> str:
8596
"""Return the least-privilege SCC name for this instance spec."""
8697
if spec.get("shell", {}).get("enabled", False):
8798
return SCC_SHELL
88-
if spec.get("storage", {}).get("hostPath", {}).get("enabled", False):
99+
if spec.get("storage", {}).get("hostPath", {}).get("path"):
89100
return SCC_HOSTPATH
90101
return SCC_RESTRICTED
91102

@@ -326,7 +337,7 @@ def ensure_scc_binding(service_account: str, namespace: str, scc_name: str):
326337
sa_name = f"{service_account}-tinycode"
327338
sa_ref = f"system:serviceaccount:{namespace}:{sa_name}"
328339

329-
dyn_client = kubernetes.dynamic.DynamicClient(kubernetes.client.ApiClient())
340+
dyn_client = get_dynamic_client()
330341
scc_api = dyn_client.resources.get(
331342
api_version="security.openshift.io/v1",
332343
kind="SecurityContextConstraints",
@@ -350,7 +361,7 @@ def remove_scc_binding(name: str, namespace: str):
350361
"""Remove the SA from all tinycode SCCs on instance deletion."""
351362
sa_ref = f"system:serviceaccount:{namespace}:{name}-tinycode"
352363
try:
353-
dyn_client = kubernetes.dynamic.DynamicClient(kubernetes.client.ApiClient())
364+
dyn_client = get_dynamic_client()
354365
scc_api = dyn_client.resources.get(
355366
api_version="security.openshift.io/v1",
356367
kind="SecurityContextConstraints",
@@ -599,7 +610,7 @@ def validate_cluster_admin(name: str, namespace: str, spec: dict) -> list[dict]:
599610
def set_status(name: str, namespace: str, phase: str, ready: bool, message: str,
600611
url: str = "", tool_calling_warnings: list | None = None,
601612
cluster_admin_warnings: list | None = None,
602-
vllm_config_ready: bool | None = None):
613+
vllm_config_ready: bool | None = None, **kwargs):
603614
"""Patch the TinycodeInstance status."""
604615
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
605616
conditions = [
@@ -671,6 +682,7 @@ def set_status(name: str, namespace: str, phase: str, ready: bool, message: str,
671682
"phase": phase,
672683
"url": url,
673684
"conditions": conditions,
685+
"observedGeneration": kwargs.get("body", {}).get("metadata", {}).get("generation", 0),
674686
}
675687
}
676688
try:
@@ -689,9 +701,7 @@ def set_status(name: str, namespace: str, phase: str, ready: bool, message: str,
689701
def get_route_url(name: str, namespace: str) -> str:
690702
"""Return the external URL of the tinycode Route, if it exists."""
691703
try:
692-
dyn_client = kubernetes.dynamic.DynamicClient(
693-
kubernetes.client.ApiClient()
694-
)
704+
dyn_client = get_dynamic_client()
695705
route_api = dyn_client.resources.get(
696706
api_version="route.openshift.io/v1", kind="Route"
697707
)
@@ -728,7 +738,7 @@ async def reconcile(
728738
3. Fetch the Route URL and update status.
729739
"""
730740
logger.info("Reconciling TinycodeInstance %s/%s", namespace, name)
731-
set_status(name, namespace, "Deploying", False, "Reconciliation in progress")
741+
set_status(name, namespace, "Deploying", False, "Reconciliation in progress", **kwargs)
732742

733743
# Step 1: Validate git spec
734744
git_warnings = await asyncio.get_event_loop().run_in_executor(
@@ -739,7 +749,7 @@ async def reconcile(
739749
for w in git_errors:
740750
logger.error("git validation error: %s", w["message"])
741751
msg = f"git validation failed: {git_errors[0]['message']}"
742-
set_status(name, namespace, "Failed", False, msg)
752+
set_status(name, namespace, "Failed", False, msg, **kwargs)
743753
raise kopf.PermanentError(msg)
744754
if git_warnings:
745755
for w in git_warnings:
@@ -754,7 +764,7 @@ async def reconcile(
754764
for w in workspace_errors:
755765
logger.error("shared workspace validation error: %s", w["message"])
756766
msg = f"shared workspace validation failed: {workspace_errors[0]['message']}"
757-
set_status(name, namespace, "Failed", False, msg)
767+
set_status(name, namespace, "Failed", False, msg, **kwargs)
758768
raise kopf.PermanentError(msg)
759769
if workspace_warnings:
760770
for w in workspace_warnings:
@@ -769,7 +779,7 @@ async def reconcile(
769779
for w in ca_errors:
770780
logger.error("clusterAdmin validation error: %s", w["message"])
771781
msg = f"clusterAdmin validation failed: {ca_errors[0]['message']}"
772-
set_status(name, namespace, "Failed", False, msg, cluster_admin_warnings=ca_warnings)
782+
set_status(name, namespace, "Failed", False, msg, cluster_admin_warnings=ca_warnings, **kwargs)
773783
raise kopf.PermanentError(msg)
774784
if ca_warnings:
775785
for w in ca_warnings:
@@ -783,7 +793,7 @@ async def reconcile(
783793
except Exception as exc:
784794
msg = f"Failed to bind SCC {scc}: {exc}"
785795
logger.error(msg)
786-
set_status(name, namespace, "Failed", False, msg)
796+
set_status(name, namespace, "Failed", False, msg, **kwargs)
787797
raise kopf.PermanentError(msg) from exc
788798

789799
# Step 3: Build vLLM config and Helm values
@@ -795,6 +805,34 @@ async def reconcile(
795805

796806
values = helm_values_for_spec(name, namespace, spec)
797807
release = helm_release_name(name, namespace)
808+
809+
# Compute hash of Helm values to skip no-op upgrades
810+
values_hash = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()[:16]
811+
812+
# Check if spec has changed
813+
try:
814+
cr = custom_api.get_namespaced_custom_object(
815+
group=GROUP, version=VERSION, namespace=namespace, plural=PLURAL, name=name
816+
)
817+
current_hash = cr.get("metadata", {}).get("annotations", {}).get("tinycode.dev/values-hash", "")
818+
if current_hash == values_hash:
819+
logger.info("Spec unchanged (hash=%s), skipping helm upgrade", values_hash)
820+
# Still update status in case route or other external state changed
821+
url = get_route_url(name, namespace)
822+
tc_warnings = await asyncio.get_event_loop().run_in_executor(
823+
None, lambda: check_vllm_tool_calling(namespace)
824+
)
825+
msg = f"TinycodeInstance {name} unchanged"
826+
if url:
827+
msg += f". URL: {url}"
828+
ca_warnings_for_status = ca_warnings if spec.get("clusterAdmin", {}).get("enabled", False) else None
829+
set_status(name, namespace, "Running", True, msg, url=url,
830+
tool_calling_warnings=tc_warnings, cluster_admin_warnings=ca_warnings_for_status,
831+
vllm_config_ready=vllm_config_ready, **kwargs)
832+
return
833+
except Exception:
834+
pass # First reconcile or annotation missing — proceed with upgrade
835+
798836
helm_args = ["upgrade", "--install"]
799837

800838
ok, output = await asyncio.get_event_loop().run_in_executor(
@@ -803,7 +841,7 @@ async def reconcile(
803841
if not ok:
804842
msg = f"Helm failed: {output[:500]}"
805843
logger.error(msg)
806-
set_status(name, namespace, "Failed", False, msg, vllm_config_ready=vllm_config_ready)
844+
set_status(name, namespace, "Failed", False, msg, vllm_config_ready=vllm_config_ready, **kwargs)
807845
raise kopf.TemporaryError(msg, delay=60)
808846

809847
# Step 4: Check vLLM tool calling in the namespace and any configured URLs
@@ -825,7 +863,21 @@ async def reconcile(
825863
ca_warnings_for_status = ca_warnings if spec.get("clusterAdmin", {}).get("enabled", False) else None
826864
set_status(name, namespace, "Running", True, msg, url=url,
827865
tool_calling_warnings=tc_warnings, cluster_admin_warnings=ca_warnings_for_status,
828-
vllm_config_ready=vllm_config_ready)
866+
vllm_config_ready=vllm_config_ready, **kwargs)
867+
868+
# Update values hash annotation after successful reconcile
869+
try:
870+
custom_api.patch_namespaced_custom_object(
871+
group=GROUP,
872+
version=VERSION,
873+
namespace=namespace,
874+
plural=PLURAL,
875+
name=name,
876+
body={"metadata": {"annotations": {"tinycode.dev/values-hash": values_hash}}},
877+
)
878+
except Exception as exc:
879+
logger.warning("Failed to update values-hash annotation: %s", exc)
880+
829881
logger.info("Reconcile complete: %s", msg)
830882

831883

0 commit comments

Comments
 (0)