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" 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..e96181a 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)) @@ -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(): 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 }} 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