diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 2e16450..f85b63d 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -5,6 +5,11 @@ metadata: control-plane: controller-manager app.kubernetes.io/name: loom app.kubernetes.io/managed-by: kustomize + # Privileged policy allows Unconfined seccomp for buildah user namespaces. + # The spindle jobs themselves are still hardened (non-root, no caps, no privilege escalation). + pod-security.kubernetes.io/enforce: privileged + pod-security.kubernetes.io/audit: privileged + pod-security.kubernetes.io/warn: privileged name: system --- apiVersion: apps/v1 @@ -22,6 +27,9 @@ spec: control-plane: controller-manager app.kubernetes.io/name: loom replicas: 1 + # Use Recreate strategy because we use RWO PVCs that can only attach to one pod + strategy: + type: Recreate template: metadata: annotations: diff --git a/go.mod b/go.mod index 0107b94..7ea78ee 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,10 @@ module tangled.org/evan.jarrett.net/loom go 1.24.4 -toolchain go1.24.9 +toolchain go1.24.10 require ( + github.com/cenkalti/backoff/v4 v4.3.0 github.com/cyphar/filepath-securejoin v0.4.1 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 @@ -31,7 +32,6 @@ require ( github.com/carlmjohnson/versioninfo v0.22.5 // indirect github.com/casbin/casbin/v2 v2.103.0 // indirect github.com/casbin/govaluate v1.3.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect @@ -197,4 +197,4 @@ require ( ) // Use our custom version of tangled until its upstreamed -replace tangled.org/core => tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3 +replace tangled.org/core => tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251205153606-d61374e9daa6 diff --git a/go.sum b/go.sum index 31e62fd..a199317 100644 --- a/go.sum +++ b/go.sum @@ -687,5 +687,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= -tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3 h1:uoAq/kDgUByxxG0kxjIRoBbcIzoU119DAI1hsNYIyVY= -tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251124173227-196aa76bafc3/go.mod h1:DpfEc3N9VfsIYVcXwP71zDQpGWnTQ3wBLBxqV0oom8g= +tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251205153606-d61374e9daa6 h1:PyvQ+Ptvb34ZNgF/IMi8UpYOdE7GjnXp/bZpLk2E/pU= +tangled.org/evan.jarrett.net/core v1.11.0-alpha.0.20251205153606-d61374e9daa6/go.mod h1:DpfEc3N9VfsIYVcXwP71zDQpGWnTQ3wBLBxqV0oom8g= diff --git a/internal/engine/kubernetes_engine.go b/internal/engine/kubernetes_engine.go index 06313ca..fbf2ac4 100644 --- a/internal/engine/kubernetes_engine.go +++ b/internal/engine/kubernetes_engine.go @@ -94,7 +94,7 @@ func (s SimpleStep) Kind() models.StepKind { // InitWorkflow parses the workflow YAML and initializes a Workflow model. // Pipeline environment variables (TANGLED_*) are injected into workflow.Environment // by the framework after this method returns. -func (e *KubernetesEngine) InitWorkflow(twf tangled.Pipeline_Workflow, cloneStep models.CloneStep) (*models.Workflow, error) { +func (e *KubernetesEngine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { // Parse the Raw YAML into the unified WorkflowSpec type var spec loomv1alpha1.WorkflowSpec if err := yaml.Unmarshal([]byte(twf.Raw), &spec); err != nil { @@ -123,6 +123,14 @@ func (e *KubernetesEngine) InitWorkflow(twf tangled.Pipeline_Workflow, cloneStep StepKind: models.StepKindUser, }) } + + // Build clone step (uses upstream models.BuildCloneStep which is self-contained) + var cloneStep models.CloneStep + devMode := false // TODO: Make this configurable + + if twf.Clone == nil || !twf.Clone.Skip { + cloneStep = models.BuildCloneStep(twf, *tpl.TriggerMetadata, devMode) + } // Store pre-computed workflow data workflowData := &kubernetesWorkflowData{ diff --git a/internal/jobbuilder/job_template.go b/internal/jobbuilder/job_template.go index d303f40..d4fcaef 100644 --- a/internal/jobbuilder/job_template.go +++ b/internal/jobbuilder/job_template.go @@ -194,11 +194,13 @@ func BuildJob(config WorkflowConfig, nodes *corev1.NodeList) (*batchv1.Job, erro RunAsNonRoot: &[]bool{true}[0], RunAsUser: &[]int64{1000}[0], FSGroup: &[]int64{1000}[0], - // Note: User namespaces (hostUsers: false) for enhanced buildah rootless - // operation requires Kubernetes 1.33+ and is not yet available in the - // current API version. Buildah will still work in rootless mode without it. + // Unconfined seccomp is required for buildah to create user namespaces + // via unshare(CLONE_NEWUSER). The container is still hardened with: + // - RunAsNonRoot, RunAsUser 1000 + // - AllowPrivilegeEscalation: false + // - Capabilities: Drop ALL SeccompProfile: &corev1.SeccompProfile{ - Type: corev1.SeccompProfileTypeRuntimeDefault, + Type: corev1.SeccompProfileTypeUnconfined, }, }, // Disable ServiceAccount token mounting for security @@ -223,7 +225,17 @@ root:x:0: nobody:x:65534: runner:x:1000: EOF -mkdir -p /home-override/runner +# subuid/subgid mappings for rootless buildah user namespaces +cat > /etc-override/subuid <<'EOF' +runner:100000:65536 +EOF +cat > /etc-override/subgid <<'EOF' +runner:100000:65536 +EOF +# Create home directory structure then fix ownership +mkdir -p /home-override/runner/.config/containers +chmod 700 /home-override/runner/.config +chown -R 1000:1000 /home-override/runner echo "User setup complete" `}, SecurityContext: &corev1.SecurityContext{ @@ -234,6 +246,8 @@ echo "User setup complete" RunAsUser: &[]int64{0}[0], Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{"ALL"}, + // CAP_CHOWN is needed to set ownership of home directory for UID 1000 + Add: []corev1.Capability{"CHOWN"}, }, }, VolumeMounts: []corev1.VolumeMount{ @@ -272,20 +286,13 @@ echo "User setup complete" Image: "quay.io/buildah/stable:latest", Command: []string{"/bin/sh", "-c"}, Args: []string{` -# Configure buildah for rootless operation +# Configure buildah storage - native overlay (kernel 6.12+ supports in user namespaces) mkdir -p /var/lib/containers/storage cat > /var/lib/containers/storage.conf <<'EOF' [storage] driver = "overlay" runroot = "/var/lib/containers/runroot" graphroot = "/var/lib/containers/storage" - -[storage.options] -additionalimagestores = [] - -[storage.options.overlay] -mount_program = "/usr/bin/fuse-overlayfs" -mountopt = "nodev,metacopy=on" EOF # Copy buildah binary to shared location @@ -293,6 +300,9 @@ cp /usr/bin/buildah /runner-bin/buildah echo "Buildah configured successfully" `}, + Env: []corev1.EnvVar{ + {Name: "HOME", Value: "/home/runner"}, + }, SecurityContext: &corev1.SecurityContext{ AllowPrivilegeEscalation: &[]bool{false}[0], RunAsNonRoot: &[]bool{true}[0], @@ -314,6 +324,33 @@ echo "Buildah configured successfully" Name: "tmp", MountPath: "/tmp", }, + // Mount passwd/group/subuid/subgid and home directory so buildah + // sees consistent user identity and can write to ~/.config + { + Name: "etc-override", + MountPath: "/etc/passwd", + SubPath: "passwd", + }, + { + Name: "etc-override", + MountPath: "/etc/group", + SubPath: "group", + }, + { + Name: "etc-override", + MountPath: "/etc/subuid", + SubPath: "subuid", + }, + { + Name: "etc-override", + MountPath: "/etc/subgid", + SubPath: "subgid", + }, + { + Name: "home-override", + MountPath: "/home/runner", + SubPath: "runner", + }, }, }, buildCloneInitContainer(config), @@ -329,7 +366,9 @@ echo "Buildah configured successfully" WorkingDir: "/tangled/workspace", SecurityContext: &corev1.SecurityContext{ - AllowPrivilegeEscalation: &[]bool{false}[0], + // AllowPrivilegeEscalation is required for newuidmap/newgidmap + // to set up user namespace mappings for rootless buildah. + AllowPrivilegeEscalation: &[]bool{true}[0], RunAsNonRoot: &[]bool{true}[0], RunAsUser: &[]int64{1000}[0], // Note: ReadOnlyRootFilesystem is NOT set for the runner container @@ -337,6 +376,9 @@ echo "Buildah configured successfully" // (e.g., /go/pkg, ~/.cache, /var/tmp) that we can't predict or mount Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{"ALL"}, + // SETUID/SETGID are needed for newuidmap/newgidmap file capabilities + // to work when setting up user namespace mappings for buildah + Add: []corev1.Capability{"SETUID", "SETGID"}, }, }, @@ -344,10 +386,12 @@ echo "Buildah configured successfully" VolumeMounts: buildRunnerVolumeMounts(config), - Env: append(buildEnvironmentVariables(config), corev1.EnvVar{ - Name: "LOOM_WORKFLOW_SPEC", - Value: string(workflowSpecJSON), - }), + Env: append(buildEnvironmentVariables(config), + corev1.EnvVar{ + Name: "LOOM_WORKFLOW_SPEC", + Value: string(workflowSpecJSON), + }, + ), // Inject repository secrets via envFrom if available EnvFrom: buildEnvFromSources(config), @@ -477,7 +521,7 @@ func buildRunnerVolumeMounts(config WorkflowConfig) []corev1.VolumeMount { Name: "buildah-storage", MountPath: "/var/lib/containers", }, - // Mount passwd/group files created by setup-user init container + // Mount passwd/group/subuid/subgid files created by setup-user init container // This ensures UID 1000 is recognized by tools like buildah { Name: "etc-override", @@ -489,6 +533,16 @@ func buildRunnerVolumeMounts(config WorkflowConfig) []corev1.VolumeMount { MountPath: "/etc/group", SubPath: "group", }, + { + Name: "etc-override", + MountPath: "/etc/subuid", + SubPath: "subuid", + }, + { + Name: "etc-override", + MountPath: "/etc/subgid", + SubPath: "subgid", + }, { Name: "home-override", MountPath: "/home/runner",