Skip to content

Commit 26632d2

Browse files
committed
refactor(pynixd): migrate cache paths from /nix to /data, remove initContainer
Move pynixd cache paths from /nix/var to /data/var, removing the initCopy initContainer that was needed to bootstrap /nix-volume. Shared-setup now creates /data directories directly and installs /usr/bin/env symlink alongside /bin/sh. Remove GC root fixup and tmp/var directory creation from shared-setup since these are now handled by the pynixd setup module. Add pynixd admin_users to dev/lillecarl deployment config.
1 parent 288558b commit 26632d2

9 files changed

Lines changed: 155 additions & 98 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Pynixd-Nixkube Improvements
2+
3+
Improvements for the interaction between pynixd, nixkube and Kubernetes, discussed
4+
[2026-05-25](./../../.opencode/logs/2026-05-25.md).
5+
6+
- ✅ Agreed + actionable
7+
- 👀 Agreed but lower priority
8+
- ❌ Not a priority (accepted tradeoff or non-issue)
9+
10+
## Builder Manager Resiliency (✅)
11+
12+
Fix race conditions and edge cases in the 726-line autoscaling builder pool:
13+
14+
- **Creation race**: `_ensure_min_builders` and `_maybe_create_builder` both list
15+
jobs independently then create. Two concurrent calls can over-provision. Fix
16+
with a creation lock or make the watcher the sole creator.
17+
- **Orphan reaping**: `_reap_orphaned_builder_pods` runs once at startup only.
18+
Orphaned pods created later are never cleaned. Run it periodically.
19+
- **Probe timeout**: `_pending_probes` has no timeout — a hanging probe blocks
20+
reprobing of that node forever. Add a timeout + retry.
21+
- **Rate limiting**: `_ensure_min_builders` bypasses cooldown. Rapid Deployment
22+
scale-up events can burst Job creations. Respect cooldown in all creation
23+
paths.
24+
- **Lost registrations on restart**: In-flight `SSHSubprocessStore` connections
25+
are lost when the cache pod restarts. Persist store registrations to SQLite
26+
(pynixd already has `use_db`), then re-register on startup and let pynixd
27+
reschedule failed builds. (Note: losing individual in-flight builds when the
28+
cache itself dies is acceptable — this is about recovering the pool, not
29+
preserving individual builds.)
30+
31+
## Observability (✅)
32+
33+
- **Prometheus metrics**: Queue depth, builder utilization, cache hit ratio, CSI
34+
operation latency. pynixd already has `PrometheusMetrics` — wire it up.
35+
- **Health endpoint**: Add an HTTP `/healthz` that returns 200 only after
36+
`server` + `builder_manager` are initialized (stores registered, reconciler
37+
running). The current SSH TCP probe passes even before initialization.
38+
- **Structured builder lifecycle**: Add more structured fields (system,
39+
store_id, pod_name) and correlation IDs across builder lifecycle events.
40+
41+
## GC Owned by Pynixd (✅)
42+
43+
Replace the bash-based GC script with pynixd-managed GC:
44+
45+
- **Current state**: `environments/cache/default.nix` has a dinit-managed GC
46+
service that runs `nix store delete` via bash + `shuf` for jitter. It has no
47+
coordination with pynixd — it could delete paths being served.
48+
- **Target state**: pynixd already has `StoreMonitor` and `use_db` for tracking
49+
path metadata. Have pynixd periodically delete paths with zero references or
50+
last-access beyond retention. This removes the bash GC entirely.
51+
- **Bonus**: The `IS_CACHE=true` env var (needed for store signing in the GC
52+
script) is never set in the Kubernetes manifests, so store signing never
53+
actually happens. Either wire it up or remove the dead code.
54+
55+
## Resource-Aware Builder Scheduling (👀)
56+
57+
Builders are created purely by `system` availability. No consideration of:
58+
59+
- Current CPU/memory utilization of existing builders
60+
- Node resource pressure
61+
- Build queue size vs. builder count correlation
62+
63+
Fix: expose builder resource metrics (via Prometheus), and let the builder
64+
manager consider utilization in scaling decisions. Or use the K8s scheduler's
65+
node selection (`_schedule_builder` picks nodes with available capacity).
66+
67+
## Replace Environments (👀)
68+
69+
The `environments/` directory (dinix-based service sets with bash GC scripts)
70+
should eventually be replaced by something cleaner. Store signing via the bash
71+
script is dead code (never actually runs).
72+
73+
## Not A Priority
74+
75+
These were discussed but accepted as tradeoffs or non-issues for now:
76+
77+
- **Bootstrap ordering**: The cache's CSI `init-store` volume creates a circular
78+
dependency on fresh clusters. Dismissed because the cache can fetch from
79+
cachix instead.
80+
- **Secrets management**: The `init-secrets` kluctl hook regenerates SSH keys on
81+
every deploy (destructive). Current behavior is intentional — it only runs
82+
when something is missing, and destroying + recreating is acceptable.
83+
- **In-flight build recovery on cache crash**: Losing in-flight builds when the
84+
cache pod restarts is an acceptable tradeoff.
85+
- **Builder security**: Privileged builder pods are an accepted tradeoff —
86+
pynixd-nixkube should not be used to run untrusted builds.

dev/lillecarl/default.nix

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ rec {
33
inherit (default) pkgs;
44
inherit (pkgs) lib;
55

6+
hostname = "pynixd.lillecarl.com";
7+
68
easykube = default.easykubenix {
79
inherit (default) pkgs;
810
modules = [
@@ -12,7 +14,7 @@ rec {
1214
{
1315
config = {
1416
kubernetes.objects.nixkube.Service.pynixd-lb.metadata.annotations = {
15-
"external-dns.alpha.kubernetes.io/hostname" = "pynixd.lillecarl.com";
17+
"external-dns.alpha.kubernetes.io/hostname" = hostname;
1618
"external-dns.alpha.kubernetes.io/ttl" = "60";
1719
};
1820
kubernetes.objects.nixkube.StatefulSet.pynixd.spec.template.metadata.labels."cilium.io/ingress" =
@@ -36,7 +38,7 @@ rec {
3638
nix copy \
3739
--substitute-on-destination \
3840
--no-check-sigs \
39-
--to ssh-ng://nix@pynixd.lillecarl.com:2222 \
41+
--to ssh-ng://nix@${hostname}:2222 \
4042
${config.kluctl.projectDir} || true
4143
'';
4244
};
@@ -50,6 +52,11 @@ rec {
5052
"aarch64-linux" = false;
5153
};
5254
cache.storageClassName = "hcloud-volumes";
55+
pynixd = {
56+
settings = {
57+
admin_users = [ "nix" ];
58+
};
59+
};
5360
pynixd.controller.nixConfig.settings.max-jobs = lib.mkForce 0;
5461
pynixd.authorizedKeys = [
5562
(builtins.readFile /home/lillecarl/.ssh/id_ed25519.pub)
@@ -153,7 +160,7 @@ rec {
153160
ns = config.nixkube.namespace;
154161
file = (builtins.unsafeGetAttrPos "x" { x = "y"; }).file;
155162
common = "--file ${file} --no-link --print-out-paths --max-jobs 0 --print-build-logs";
156-
storeUri = "ssh-ng://nix@pynixd.lillecarl.com:2222";
163+
storeUri = "ssh-ng://nix@${hostname}:2222";
157164
builderUri = ''"${storeUri} x86_64-linux - 10 1 ca-derivations,dynamic-derivations,recursive-nix - -"'';
158165
targets = [
159166
"normalDerivation"
@@ -165,15 +172,15 @@ rec {
165172
"dynamicFromFixed"
166173
];
167174
testBuild = target: ''
168-
nix build ${common} --builders ${builderUri} ${target}
169-
nix build ${common} --store ${storeUri} --eval-store auto ${target}
175+
timeout 60 nix build ${common} --builders ${builderUri} ${target}
176+
timeout 60 nix build ${common} --store ${storeUri} --eval-store auto ${target}
170177
'';
171178
allTests = lib.concatStringsSep "\n" (map testBuild targets);
172179
in
173180
''
174181
set -x
175182
${lib.getExe easykube.deploymentScript} --yes
176-
kubectl --namespace ${ns} rollout status --watch --timeout=180s statefulset/pynixd daemonset/nix-node
183+
kubectl --namespace ${ns} rollout status --watch --timeout=60s statefulset/pynixd daemonset/nix-node
177184
178185
${allTests}
179186
'';

environments/cache/default.nix

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,49 +35,21 @@ let
3535
];
3636
};
3737

38-
cacheEnv = pkgs.buildEnv {
39-
name = "cacheEnv";
40-
paths = with pkgs; [
41-
dinixEval.config.containerWrapper
42-
bash
43-
coreutils
44-
nix
45-
openssh
46-
pynixd-nixkube
47-
pynixd-nixkube.fakeNss
48-
tini
49-
# dev
50-
fishMinimal
51-
];
52-
# So we can peek into eval
53-
passthru.dinixEval = dinixEval;
54-
};
55-
56-
initCopy = pkgs.writeShellApplication {
57-
name = "initCopy";
58-
runtimeInputs = [
59-
pkgs.nix
60-
pkgs.rsync
61-
];
62-
text = # bash
63-
''
64-
set -euo pipefail
65-
set -x
66-
# AI: This isn't a duplicate with the setup since they occur in different containers
67-
rsync --archive ${pkgs.dockerTools.caCertificates}/ /
68-
# Install environment into persistent volume
69-
nix build \
70-
--extra-substituters "local?trusted=true&read-only=true" \
71-
--store /nix-volume \
72-
--out-link /nix-volume/nix/var/result \
73-
/nix/var/result
74-
'';
75-
};
7638
in
7739
pkgs.buildEnv {
78-
name = "cache-init-env";
79-
paths = [
80-
cacheEnv
81-
initCopy
40+
name = "cacheEnv";
41+
paths = with pkgs; [
42+
dinixEval.config.containerWrapper
43+
bash
44+
coreutils
45+
nix
46+
openssh
47+
pynixd-nixkube
48+
pynixd-nixkube.fakeNss
49+
tini
50+
# dev
51+
fishMinimal
8252
];
53+
# So we can peek into eval
54+
passthru.dinixEval = dinixEval;
8355
}

environments/modules/setup.nix

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,10 @@
2525
''
2626
set -euo pipefail
2727
set -x
28-
mkdir --parents {/tmp,/var/tmp}
29-
chmod -R 1777 {/tmp,/var/tmp}
30-
mkdir --parents {/var/log,/nix/var/nix-csi}
31-
chmod -R 0755 {/var/log,/nix/var/nix-csi}
3228
# Copy in "well-known paths" into container root
3329
rsync --archive ${pkgs.dockerTools.binSh}/ /
3430
rsync --archive ${pkgs.dockerTools.caCertificates}/ /
3531
rsync --archive ${pkgs.dockerTools.usrBinEnv}/ /
36-
# Fix gcroots for /nix/var/result. The one created by initCopy
37-
# points to invalid symlinks in the chain
38-
# (auto -> /nix-volume/var/result) rather than
39-
# (auto -> /nix/var/result). The link back to store works
40-
# though so this just fixes gcroots.
41-
# /nix/var/result will always exist, else the initContainer will fail
42-
nix build --store local --out-link /nix/var/result /nix/var/result
4332
'';
4433
};
4534
};

kubenix/config.nix

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ in
1212
{
1313
config = lib.mkIf cfg.enable {
1414
nixkube = {
15-
nixConfig.settings = lib.mkDefault {
15+
nixConfig.settings = {
1616
allowed-users = [ "*" ];
1717
trusted-public-keys = [
1818
"nix-csi.cachix.org-1:i4w33gR4efO67jpz8U7g/MdvRQ6mQ3LEF9fB8tES60g="
@@ -33,10 +33,22 @@ in
3333
narinfo-cache-positive-ttl = 0;
3434
warn-dirty = false;
3535
store = "daemon";
36+
trusted-users = [ "root" ];
37+
system-features = [
38+
"nixos-test"
39+
"benchmark"
40+
"big-parallel"
41+
];
3642
};
37-
node.nixConfig.settings = lib.mapAttrsRecursive (name: value: lib.mkDefault value) cfg.nixConfig.settings;
38-
pynixd.controller.nixConfig.settings = lib.mapAttrsRecursive (name: value: lib.mkDefault value) cfg.nixConfig.settings;
39-
pynixd.builder.nixConfig.settings = lib.mapAttrsRecursive (name: value: lib.mkDefault value) cfg.nixConfig.settings;
43+
node.nixConfig.settings = lib.mapAttrsRecursive (
44+
name: value: lib.mkDefault value
45+
) cfg.nixConfig.settings;
46+
pynixd.controller.nixConfig.settings = lib.mapAttrsRecursive (
47+
name: value: lib.mkDefault value
48+
) cfg.nixConfig.settings;
49+
pynixd.builder.nixConfig.settings = lib.mapAttrsRecursive (
50+
name: value: lib.mkDefault value
51+
) cfg.nixConfig.settings;
4052
};
4153
kubernetes.resources.${cfg.namespace} = {
4254
ConfigMap.nix-node = {

kubenix/nixOptions.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ types.submodule (
6969
};
7070
};
7171
config = {
72-
settings = {
72+
settings = lib.mapAttrsRecursive (n: v: lib.mkDefault v) {
7373
trusted-public-keys = [
7474
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
7575
];

kubenix/pynixd.nix

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -187,34 +187,7 @@ in
187187
spec = {
188188
serviceAccountName = "nixkube";
189189
priorityClassName = "system-cluster-critical";
190-
initContainers = lib.mkNumberedList {
191-
"1" = {
192-
name = "initcopy";
193-
inherit image;
194-
command = [ "initCopy" ];
195-
imagePullPolicy = "Always";
196-
securityContext.capabilities.add = [ "SYS_CHROOT" ]; # chroot store
197-
volumeMounts = lib.mkNamedList {
198-
init-store = {
199-
mountPath = "/nix";
200-
subPath = "nix";
201-
readOnly = true;
202-
};
203-
nix-store.mountPath = "/nix-volume";
204-
nix-config.mountPath = "/etc/nix";
205190

206-
ssh-config.mountPath = "/etc/ssh";
207-
ssh-dynauth.mountPath = "/etc/ssh-dynauth";
208-
ssh-key.mountPath = "/etc/ssh-key";
209-
};
210-
resources = {
211-
requests = {
212-
memory = "64Mi";
213-
cpu = "100m";
214-
};
215-
};
216-
};
217-
};
218191
containers = lib.mkNamedList {
219192
pynixd = {
220193
command = [
@@ -228,15 +201,15 @@ in
228201
PYNIXD_SSH_HOST.value = "";
229202
PYNIXD_SSH_PORT.value = "22";
230203
PYNIXD_HTTP_PORT.value = "8080";
231-
PYNIXD_SSH_HOST_KEY.value = "/nix/var/pynixd/host_key";
232-
HOME.value = "/nix/var/nix-csi/root";
204+
PYNIXD_SSH_HOST_KEY.value = "/data/var/pynixd/host_key";
205+
HOME.value = "/data/var/nix-csi/root";
233206
PYNIXD_KUBE_NAMESPACE.valueFrom.fieldRef.fieldPath = "metadata.namespace";
234207
PYNIXD_BUILDER_MAX.value = "3";
235208
PYNIXD_BUILDER_MIN.value = "1";
236209
PYNIXD_IDLE_TIMEOUT.value = "300";
237210
PYNIXD_SCHEDULE_MODE.value = "scheduler";
238211
PYNIXD_SYSTEMS.value = lib.concatStringsSep "," (builtins.attrNames enabledSystems);
239-
PYNIXD_CONFIG.value = "/nix/etc/pynixd-config/config.json";
212+
PYNIXD_CONFIG.value = "/etc/pynixd-config/config.json";
240213
};
241214
ports = lib.mkNamedList {
242215
ssh.containerPort = 22;
@@ -247,15 +220,17 @@ in
247220
{
248221
nix-config.mountPath = "/etc/nix";
249222
nix-key.mountPath = "/etc/nix-key";
250-
nix-store = {
223+
nix-store.mountPath = "/data";
224+
init-store = {
251225
mountPath = "/nix";
252226
subPath = "nix";
227+
readOnly = true;
253228
};
254229

255230
ssh-config.mountPath = "/etc/ssh";
256231
ssh-dynauth.mountPath = "/etc/ssh-dynauth";
257232
ssh-key.mountPath = "/etc/ssh-key";
258-
pynixd-config.mountPath = "/nix/etc/pynixd-config";
233+
pynixd-config.mountPath = "/etc/pynixd-config";
259234
}
260235
// cfg.pynixd.extraVolumeMounts
261236
);

pkgs/default.nix

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ self: pkgs: {
6868
pynixd =
6969
(import ../../pynixd {
7070
inherit pkgs;
71-
lib = pkgs.lib;
7271
}).library;
7372
pynixd-nixkube = pkgs.python3Packages.callPackage ./pynixd-nixkube {
7473
inherit (self) pynixd kr8s;

pkgs/pynixd-nixkube/pynixd_nixkube/setup.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ def install_nss() -> None:
4343
sh_dst.symlink_to("/nix/var/result/bin/bash")
4444
log.info("installed_bin_sh", target="/nix/var/result/bin/bash")
4545

46-
host_key_dir = Path("/nix/var/pynixd")
46+
env_dst = Path("/usr/bin/env")
47+
if not env_dst.exists():
48+
env_dst.parent.mkdir(parents=True, exist_ok=True)
49+
env_dst.symlink_to("/nix/var/result/bin/env")
50+
log.info("installed_usr_bin_env", target="/nix/var/result/bin/env")
51+
52+
for path, mode in (
53+
("/tmp", 0o1777),
54+
("/var/tmp", 0o1777),
55+
("/var/log", 0o755),
56+
("/data/var/nix-csi", 0o755),
57+
):
58+
p = Path(path)
59+
p.mkdir(parents=True, exist_ok=True)
60+
p.chmod(mode)
61+
log.info("ensured_directory", path=path, mode=oct(mode))
62+
63+
host_key_dir = Path("/data/var/pynixd")
4764
host_key_dir.mkdir(parents=True, exist_ok=True)
4865
log.info("ensured_host_key_dir", path=str(host_key_dir))

0 commit comments

Comments
 (0)