From cd9da841503e3a5e58b3594da7dc14a4b8b33e2b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 17 Aug 2026 20:06:33 -0400 Subject: [PATCH 1/4] fix: align tests and deployment templates with core v0.3.0 (#7) Restore the build and Kubernetes conformance after the core v0.3.0 upgrade: - Pass the RuntimeContext argument now required by network.GenerateNetworkMappings in the create-to-run test. - Harden the deployment template to satisfy the KUBERNETES_OUTPUT_PROFILE_EPHEMERAL_LOCAL_APPLY_V1 and restricted conformance profiles: pod/container securityContext, resource bounds, liveness/readiness/startup probes, terminationGracePeriodSeconds and automountServiceAccountToken, digest-pinned image, and secretKeyRef-based secret injection under restricted profiles. - Pin the workload to numeric UID 1000 (deployment runAsUser + Dockerfile adduser). Kubernetes' runAsNonRoot admission check rejects a container whose image user is a non-numeric name, so runAsNonRoot alone would leave every deployed pod stuck in CreateContainerConfigError. - Give the read-only container writable scratch: mount a bounded emptyDir (sizeLimit 1Gi) at /tmp and set HOME=/tmp so ~/.cache writes land there instead of failing against readOnlyRootFilesystem. - Label the owned Namespace with app.kubernetes.io/managed-by: codefly and elide the inline Secret under restricted profiles. - Assert the rendered deployment pins runAsUser, mounts and bounds /tmp, and redirects HOME, so these runtime requirements can't regress silently past static conformance. Co-Authored-By: Claude Opus 4.8 --- deployment_test.go | 22 ++++++- main_test.go | 2 +- templates/builder/Dockerfile.tmpl | 4 +- .../kustomize/base/deployment.yaml.tmpl | 66 ++++++++++++++++++- .../kustomize/base/namespace.yaml.tmpl | 1 + .../environment/kustomization.yaml.tmpl | 7 +- .../overlays/environment/secret.yaml.tmpl | 4 +- 7 files changed, 96 insertions(+), 10 deletions(-) diff --git a/deployment_test.go b/deployment_test.go index f157066..0c39afc 100644 --- a/deployment_test.go +++ b/deployment_test.go @@ -1,11 +1,31 @@ package main import ( + "os" + "path/filepath" "testing" agenttesting "github.com/codefly-dev/core/agents/testing" + "github.com/stretchr/testify/require" ) func TestDeploymentTemplates(t *testing.T) { - agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{}) + destination := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{}) + + rendered, err := os.ReadFile(filepath.Join(destination, "base", "deployment.yaml")) + require.NoError(t, err) + manifest := string(rendered) + + // Kubernetes rejects runAsNonRoot when the image user is non-numeric, so the + // workload must pin a numeric UID matching the Dockerfile's appuser (1000). + require.Contains(t, manifest, "runAsUser: 1000") + // readOnlyRootFilesystem leaves the container without writable storage, so a + // scratch mount at /tmp is required for the app to run. + require.Contains(t, manifest, "mountPath: /tmp") + // The scratch volume is bounded so a runaway writer evicts only this pod + // rather than filling node ephemeral storage. + require.Contains(t, manifest, "sizeLimit: 1Gi") + // $HOME must point at the writable mount so ~/.cache writes don't hit the + // read-only root filesystem. + require.Contains(t, manifest, "value: /tmp") } diff --git a/main_test.go b/main_test.go index 97bdcc6..e62272b 100644 --- a/main_test.go +++ b/main_test.go @@ -94,7 +94,7 @@ func testCreateToRun(t *testing.T, runtimeContext *basev0.RuntimeContext) { require.Equal(t, 1, len(runtime.Endpoints)) - networkMappings, err := networkManager.GenerateNetworkMappings(ctx, env, workspace, runtime.Identity, runtime.Endpoints) + networkMappings, err := networkManager.GenerateNetworkMappings(ctx, env, workspace, runtime.Identity, runtime.Endpoints, runtimeContext) require.NoError(t, err) require.Equal(t, 1, len(networkMappings)) diff --git a/templates/builder/Dockerfile.tmpl b/templates/builder/Dockerfile.tmpl index ad87929..c087a80 100644 --- a/templates/builder/Dockerfile.tmpl +++ b/templates/builder/Dockerfile.tmpl @@ -17,7 +17,9 @@ FROM {{.Builder}} as runtime WORKDIR /app -RUN adduser -D appuser +# UID pinned to match runAsUser in the kustomize Deployment: Kubernetes' +# runAsNonRoot check rejects a container whose image user is non-numeric. +RUN adduser -D -u 1000 appuser USER appuser ENV VIRTUAL_ENV=/app/.venv \ diff --git a/templates/deployment/kustomize/base/deployment.yaml.tmpl b/templates/deployment/kustomize/base/deployment.yaml.tmpl index 44a9885..ebadcca 100644 --- a/templates/deployment/kustomize/base/deployment.yaml.tmpl +++ b/templates/deployment/kustomize/base/deployment.yaml.tmpl @@ -14,11 +14,75 @@ spec: app: {{ .Service.Name.DNSCase }} sha: {{ .Sha }} spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: {{ .Service.Name.DNSCase }} - image: image:tag + image: {{ .Image.FullName }} + ports: + - containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + readOnlyRootFilesystem: true + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + startupProbe: + httpGet: + path: /version + port: 8080 + failureThreshold: 30 + periodSeconds: 2 + readinessProbe: + httpGet: + path: /version + port: 8080 + livenessProbe: + httpGet: + path: /version + port: 8080 + volumeMounts: + - name: tmp + mountPath: /tmp envFrom: - configMapRef: name: config-{{ .Service.Name.DNSCase }} +{{- if not .Restricted }} - secretRef: name: secret-{{ .Service.Name.DNSCase }} +{{- end }} + env: + # readOnlyRootFilesystem makes $HOME unwritable; point caches at the + # writable scratch mount so libraries that use ~/.cache still work. + - name: HOME + value: /tmp +{{- if .Restricted }} +{{- range $environmentVariable, $reference := .SecretReferences }} + - name: {{ $environmentVariable }} + valueFrom: + secretKeyRef: + name: {{ $reference.Name }} + key: {{ $reference.Key }} + optional: {{ $reference.Optional }} +{{- end }} +{{- end }} + volumes: + - name: tmp + emptyDir: + sizeLimit: 1Gi diff --git a/templates/deployment/kustomize/base/namespace.yaml.tmpl b/templates/deployment/kustomize/base/namespace.yaml.tmpl index 477dbb8..7db2a31 100644 --- a/templates/deployment/kustomize/base/namespace.yaml.tmpl +++ b/templates/deployment/kustomize/base/namespace.yaml.tmpl @@ -3,4 +3,5 @@ kind: Namespace metadata: name: "{{ .Namespace }}" labels: + app.kubernetes.io/managed-by: codefly istio-injection: "enabled" diff --git a/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl b/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl index 2b56028..0790a63 100644 --- a/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl +++ b/templates/deployment/kustomize/overlays/environment/kustomization.yaml.tmpl @@ -1,9 +1,6 @@ resources: - ../../base - configmap.yaml +{{- if not .Restricted }} - secret.yaml - -images: - - name: image:tag - newName: {{.Image.Name}} - newTag: {{.Image.Tag}} +{{- end }} diff --git a/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl b/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl index c644902..204fe3a 100644 --- a/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl +++ b/templates/deployment/kustomize/overlays/environment/secret.yaml.tmpl @@ -1,3 +1,4 @@ +{{- if not .Restricted }} apiVersion: v1 kind: Secret metadata: @@ -6,4 +7,5 @@ metadata: data: {{- range $key, $value := .Deployment.SecretMap }} {{ $key }}: "{{ $value }}" - {{- end }} +{{- end }} +{{- end }} From dd6c66a4d34f6c004ee593c677ff595193e3c4ca Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 18 Aug 2026 14:55:11 -0400 Subject: [PATCH 2/4] fix: run the container runtime as the host user (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestCreateToRunDocker failed on Linux CI (but not macOS) on its second Init: the dependency cache hashes code/uv.lock and got "permission denied". Root cause: the container ran as root, so `uv sync` wrote uv.lock into the bind-mounted source (and populated the venv) as root. On a host where the invoking user isn't root — Linux CI — the next Init could no longer read the root-owned uv.lock to hash it. Docker Desktop's UID remapping masked this on macOS. Run the container as the invoking host user (uid:gid) so everything it writes into bind mounts is host-owned. uv's download cache defaults to $HOME/.cache, which that user can't write inside the image, so mount a dedicated host-owned cache dir and point UV_CACHE_DIR at it. Also assert init.Status is READY in the create-to-run test: Init reports failures through the response status, not the returned error, so the previous require.NoError let a failed Init through and surfaced as a confusing empty-network-mappings error later. Co-Authored-By: Claude Opus 4.8 --- main_test.go | 4 ++++ runtime.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/main_test.go b/main_test.go index e62272b..e96181a 100644 --- a/main_test.go +++ b/main_test.go @@ -124,6 +124,10 @@ func testRun(t *testing.T, runtime *Runtime, ctx context.Context, identity *base ProposedNetworkMappings: networkMappings}) require.NoError(t, err) require.NotNil(t, init) + // Init reports failures through the response status, not the Go error, so a + // bare NoError check would let a failed Init through and surface as a + // confusing empty-network-mappings error further down. + require.Equal(t, runtimev0.InitStatus_READY, init.GetStatus().GetState(), init.GetStatus().GetMessage()) instance, err := resources.FindNetworkInstanceInNetworkMappings(ctx, init.NetworkMappings, runtime.FastAPI.RestEndpoint, resources.NewNativeNetworkAccess()) require.NoError(t, err) diff --git a/runtime.go b/runtime.go index 972c26e..3a2d65a 100644 --- a/runtime.go +++ b/runtime.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "os" "path" "strings" @@ -126,6 +127,12 @@ func (s *Runtime) CreateRunnerEnvironment(ctx context.Context) error { return s.Wool.Wrapf(err, "cannot create docker runner") } dockerEnv.WithPause() + // Run as the invoking host user. uv sync writes uv.lock into the + // bind-mounted source and populates the venv; as root those files + // become root-owned on the host, and a later Init that hashes uv.lock + // for its dependency cache then fails with "permission denied" on any + // host where the user isn't root (e.g. Linux CI). + dockerEnv.WithUser(fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())) instance, err := resources.FindNetworkInstanceInNetworkMappings(ctx, s.NetworkMappings, s.FastAPI.RestEndpoint, resources.NewNativeNetworkAccess()) if err != nil { @@ -147,6 +154,14 @@ func (s *Runtime) CreateRunnerEnvironment(ctx context.Context) error { if err != nil { return s.Wool.Wrapf(err, "cannot create cache location") } + // uv's download cache defaults to $HOME/.cache/uv; the host user has no + // home inside the image, so give uv a writable, host-owned cache mount. + uvCache, err := s.LocalDirCreate(ctx, ".cache/container/uv") + if err != nil { + return s.Wool.Wrapf(err, "cannot create uv cache location") + } + dockerEnv.WithMount(uvCache, "/uv-cache") + dockerEnv.WithEnvironmentVariables(ctx, resources.Env("UV_CACHE_DIR", "/uv-cache")) s.runnerEnvironment = dockerEnv case s.Base.Runtime.IsNixRuntime(): From 206f397895fe72bf247c4b897cc022fc6547d4ce Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 18 Aug 2026 15:01:11 -0400 Subject: [PATCH 3/4] ci: install uv for the Python runtime test suite (#7) The container-mode lifecycle test drives the Python runtime's Test step, which runs `uv` on the host to sync dependencies and run pytest. The runner doesn't ship uv, so `go test ./...` failed with `exec: "uv": executable file not found in $PATH`. Install it via the shared workflow's setup-run hook. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1930fb4..0df48e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,3 +13,9 @@ concurrency: jobs: ci: uses: codefly-dev/core/.github/workflows/go-service-ci.yml@main + with: + # The Python runtime tests drive `uv` on the host (dependency sync, + # pytest); it isn't on the runner by default. + setup-run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" From 140ebc737cb96b78f3001944dcabb71ba5f7a2cb Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Tue, 18 Aug 2026 18:26:57 -0400 Subject: [PATCH 4/4] fix: keep generated FastAPI service tests from hanging on codefly.init (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated service's pytest hung indefinitely under the Python runtime's Test step. codefly-sdk's init() walks parent directories for service.codefly.yaml without stopping at the filesystem root, so it spins forever when the manifest isn't found — which is exactly the case core's test runner creates by executing pytest against an isolated source snapshot (the snapshot holds code/, not the service-root manifest). Importing src.main ran init() at module load and wedged collection. - Guard the manifest lookup in main.py with a bounded search; init() only runs when service.codefly.yaml is actually reachable, and runtime config otherwise flows through CODEFLY__* env vars. - Make the admin test hermetic: seed the CODEFLY__* values it needs (setdefault preserves real runtime values) and drop the codefly.init("..") walk that would spin in the snapshot. Co-Authored-By: Claude Opus 4.8 --- templates/factory/code/src/main.py.tmpl | 22 ++++++++++++++++++- .../code/tests/admin/test_admin.py.tmpl | 19 ++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/templates/factory/code/src/main.py.tmpl b/templates/factory/code/src/main.py.tmpl index ebcaeef..6445c19 100644 --- a/templates/factory/code/src/main.py.tmpl +++ b/templates/factory/code/src/main.py.tmpl @@ -1,8 +1,28 @@ +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import codefly_sdk.codefly as codefly -codefly.init() + +def _service_manifest_reachable() -> bool: + # codefly.init() walks parent directories for service.codefly.yaml but does + # not stop at the filesystem root, so it loops forever when the manifest is + # absent (e.g. tests running against an isolated source snapshot). Only init + # when the manifest is actually reachable; runtime config is otherwise + # supplied through CODEFLY__* environment variables. + directory = os.getcwd() + while True: + if os.path.isfile(os.path.join(directory, "service.codefly.yaml")): + return True + parent = os.path.dirname(directory) + if parent == directory: + return False + directory = parent + + +if _service_manifest_reachable(): + codefly.init() app = FastAPI() diff --git a/templates/factory/code/tests/admin/test_admin.py.tmpl b/templates/factory/code/tests/admin/test_admin.py.tmpl index 7cbb9f3..49589cc 100644 --- a/templates/factory/code/tests/admin/test_admin.py.tmpl +++ b/templates/factory/code/tests/admin/test_admin.py.tmpl @@ -1,21 +1,26 @@ +import os + +# Provide the runtime configuration the app reads without depending on a +# service.codefly.yaml on disk: the test runner executes against an isolated +# source snapshot where the manifest isn't present, and codefly.init()'s +# parent-directory walk would otherwise spin. setdefault preserves any real +# values injected by the codefly runtime. +os.environ.setdefault("CODEFLY__MODULE", "mod") +os.environ.setdefault("CODEFLY__SERVICE", "svc") +os.environ.setdefault("CODEFLY__SERVICE_VERSION", "0.0.0") + import pytest from httpx import AsyncClient, ASGITransport from src.main import app -import codefly_sdk.codefly as codefly - from src.admin.version import get_version from src.admin.models import Version - @pytest.mark.asyncio async def test_version(): - codefly.init("..") - endpoint = codefly.endpoint(api="rest") - address = endpoint.address if endpoint else "http://localhost:8080" - async with AsyncClient(transport=ASGITransport(app=app), base_url=address) as ac: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost:8080") as ac: response = await ac.get("/version") assert response.status_code == 200