Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
22 changes: 21 additions & 1 deletion deployment_test.go
Original file line numberDiff line numberDiff line change
@@ -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")
}
6 changes: 5 additions & 1 deletion main_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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))

Expand DownExpand Up@@ -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)
Expand Down
15 changes: 15 additions & 0 deletions runtime.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"os"
"path"
"strings"

Expand DownExpand Up@@ -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 {
Expand All@@ -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():
Expand Down
4 changes: 3 additions & 1 deletion templates/builder/Dockerfile.tmpl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 \
Expand Down
66 changes: 65 additions & 1 deletion templates/deployment/kustomize/base/deployment.yaml.tmpl
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
1 change: 1 addition & 0 deletions templates/deployment/kustomize/base/namespace.yaml.tmpl
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,4 +3,5 @@ kind: Namespace
metadata:
name: "{{ .Namespace }}"
labels:
app.kubernetes.io/managed-by: codefly
istio-injection: "enabled"
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
resources:
- ../../base
- configmap.yaml
{{- if not .Restricted }}
- secret.yaml

images:
- name: image:tag
newName: {{.Image.Name}}
newTag: {{.Image.Tag}}
{{- end }}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
{{- if not .Restricted }}
apiVersion: v1
kind: Secret
metadata:
Expand All@@ -6,4 +7,5 @@ metadata:
data:
{{- range $key, $value := .Deployment.SecretMap }}
{{ $key }}: "{{ $value }}"
{{- end }}
{{- end }}
{{- end }}
22 changes: 21 additions & 1 deletion templates/factory/code/src/main.py.tmpl
Original file line numberDiff line numberDiff line change
@@ -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()

Expand Down
19 changes: 12 additions & 7 deletions templates/factory/code/tests/admin/test_admin.py.tmpl
Original file line numberDiff line numberDiff line change
@@ -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

Expand Down
Loading