k3s-deployer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,701 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const crypto = require('node:crypto');
5
+ const { upsertCloudflareDnsRecord } = require('../dns/cloudflare.cjs');
6
+
7
+ /* ────────────────────────────────────────────────────────────────────────────
8
+ * redroid + scrcpy-web adapter
9
+ *
10
+ * Runs Android natively on ARM64 via redroid (no emulation).
11
+ * Uses a scrcpy + noVNC sidecar for browser-based display.
12
+ *
13
+ * Flow:
14
+ * 1. Load binder kernel module (init container)
15
+ * 2. Start redroid container (native ARM64 Android)
16
+ * 3. Start display sidecar (scrcpy → Xvfb → x11vnc → noVNC)
17
+ * 4. Install APK via adb
18
+ * 5. Browser preview via noVNC
19
+ * ──────────────────────────────────────────────────────────────────────────── */
20
+
21
+ const REDROID_IMAGE = 'docker.io/redroid/redroid:12.0.0-latest';
22
+
23
+ const DISPLAY_DOCKERFILE = `FROM ubuntu:22.04
24
+ ENV DEBIAN_FRONTEND=noninteractive
25
+ RUN apt-get update && apt-get install -y --no-install-recommends \\
26
+ scrcpy xvfb x11vnc adb wget curl ca-certificates procps python3 iproute2 && \\
27
+ SCRCPY_SERVER=\$(find /usr -name "scrcpy-server" -type f 2>/dev/null | head -1) && \\
28
+ if [ -z "\$SCRCPY_SERVER" ]; then \\
29
+ wget -qO /usr/share/scrcpy/scrcpy-server https://github.com/nickolay/scrcpy-server-mirror/releases/download/v1.21/scrcpy-server-v1.21.jar || true; \\
30
+ fi && \\
31
+ mkdir -p /opt/noVNC/utils/websockify && \\
32
+ wget -qO- https://github.com/novnc/noVNC/archive/refs/heads/master.tar.gz | tar xz --strip-components=1 -C /opt/noVNC && \\
33
+ wget -qO- https://github.com/novnc/websockify/archive/refs/heads/master.tar.gz | tar xz --strip-components=1 -C /opt/noVNC/utils/websockify && \\
34
+ rm -rf /var/lib/apt/lists/*
35
+ COPY entrypoint.sh /entrypoint.sh
36
+ COPY health-check.sh /health-check.sh
37
+ RUN chmod +x /entrypoint.sh /health-check.sh
38
+ EXPOSE 6080
39
+ ENTRYPOINT ["/entrypoint.sh"]
40
+ `;
41
+
42
+ const DISPLAY_ENTRYPOINT = `#!/usr/bin/env bash
43
+ set -euo pipefail
44
+
45
+ cleanup() {
46
+ pkill -f scrcpy || true
47
+ pkill -f x11vnc || true
48
+ pkill -f Xvfb || true
49
+ pkill -f novnc_proxy || true
50
+ }
51
+ trap cleanup SIGINT SIGTERM EXIT
52
+
53
+ export DISPLAY=:99
54
+
55
+ echo "[display] Starting Xvfb"
56
+ Xvfb :99 -screen 0 720x1280x24 -ac +extension GLX +render -noreset &
57
+ sleep 1
58
+
59
+ echo "[display] Starting x11vnc"
60
+ x11vnc -display :99 -forever -shared -nopw -rfbport 5900 -listen 0.0.0.0 -xkb &
61
+
62
+ echo "[display] Starting noVNC on port 6080"
63
+ /opt/noVNC/utils/novnc_proxy --vnc 127.0.0.1:5900 --listen 6080 &
64
+ sleep 1
65
+
66
+ echo "[display] Starting network routing fix loop"
67
+ (while true; do ip rule del from all unreachable 2>/dev/null || true; sleep 2; done) &
68
+
69
+ echo "[display] Waiting for Android (adb)..."
70
+ for i in \$(seq 1 180); do
71
+ if adb connect 127.0.0.1:5555 2>/dev/null | grep -Eq "connected to|already connected to"; then
72
+ BOOT=\$(adb -s 127.0.0.1:5555 shell getprop sys.boot_completed 2>/dev/null | tr -d '\\r' || true)
73
+ DEV_BOOT=\$(adb -s 127.0.0.1:5555 shell getprop dev.bootcomplete 2>/dev/null | tr -d '\\r' || true)
74
+ BOOT_ANIM=\$(adb -s 127.0.0.1:5555 shell getprop init.svc.bootanim 2>/dev/null | tr -d '\\r' || true)
75
+ if [ "\$BOOT" = "1" ] || [ "\$DEV_BOOT" = "1" ] || [ "\$BOOT_ANIM" = "stopped" ]; then
76
+ echo "[display] Android boot_completed=1"
77
+ break
78
+ fi
79
+ fi
80
+ sleep 3
81
+ done
82
+
83
+ echo "[display] Provisioning device..."
84
+ adb -s 127.0.0.1:5555 shell settings put global device_provisioned 1 2>/dev/null || true
85
+ adb -s 127.0.0.1:5555 shell settings put secure user_setup_complete 1 2>/dev/null || true
86
+ adb -s 127.0.0.1:5555 shell locksettings set-disabled true 2>/dev/null || true
87
+ adb -s 127.0.0.1:5555 shell wm dismiss-keyguard 2>/dev/null || true
88
+ adb -s 127.0.0.1:5555 shell input keyevent 82 2>/dev/null || true
89
+
90
+ echo "[display] Waiting for user unlock (RUNNING_UNLOCKED)..."
91
+ echo "[display] Note: vold deadlock will be broken by deployer via kubectl exec"
92
+ for i in \$(seq 1 120); do
93
+ USER_STATE=\$(adb -s 127.0.0.1:5555 shell "am get-started-user-state 0" 2>/dev/null | grep -o "RUNNING_[A-Z_]*" || true)
94
+ if [ "\$USER_STATE" = "RUNNING_UNLOCKED" ]; then
95
+ echo "[display] User 0 is RUNNING_UNLOCKED"
96
+ break
97
+ fi
98
+ if [ \$((i % 30)) -eq 0 ]; then
99
+ echo "[display] User state: \$USER_STATE (attempt \$i/120)"
100
+ fi
101
+ sleep 2
102
+ done
103
+ echo "[display] Android is ready (state: \$USER_STATE)"
104
+
105
+ echo "[display] Starting scrcpy supervisor loop"
106
+ SCRCPY_SERVER_PATH=\$(find /usr -name "scrcpy-server" -type f 2>/dev/null | head -1)
107
+ if [ -n "\$SCRCPY_SERVER_PATH" ]; then
108
+ export SCRCPY_SERVER_PATH
109
+ fi
110
+
111
+ while true; do
112
+ if adb connect 127.0.0.1:5555 2>/dev/null | grep -Eq "connected to|already connected to"; then
113
+ BOOT=\$(adb -s 127.0.0.1:5555 shell getprop sys.boot_completed 2>/dev/null | tr -d '\\r' || true)
114
+ DEV_BOOT=\$(adb -s 127.0.0.1:5555 shell getprop dev.bootcomplete 2>/dev/null | tr -d '\\r' || true)
115
+ BOOT_ANIM=\$(adb -s 127.0.0.1:5555 shell getprop init.svc.bootanim 2>/dev/null | tr -d '\\r' || true)
116
+ if [ "\$BOOT" = "1" ] || [ "\$DEV_BOOT" = "1" ] || [ "\$BOOT_ANIM" = "stopped" ]; then
117
+ echo "[display] Launching scrcpy..."
118
+ scrcpy --serial 127.0.0.1:5555 --max-fps 30 --bit-rate 4M --window-x 0 --window-y 0 --window-width 720 --window-height 1280 --window-borderless 2>&1 || true
119
+ echo "[display] scrcpy exited, retrying in 3s"
120
+ else
121
+ echo "[display] Android not fully booted yet (sys.boot_completed=\$BOOT, dev.bootcomplete=\$DEV_BOOT, bootanim=\$BOOT_ANIM)"
122
+ fi
123
+ else
124
+ echo "[display] adb connect not ready yet"
125
+ fi
126
+ sleep 3
127
+ done
128
+ `;
129
+
130
+ const HEALTH_CHECK = `#!/usr/bin/env bash
131
+ set -euo pipefail
132
+ MODE="\${1:-ready}"
133
+
134
+ if [ "\$MODE" = "startup" ]; then
135
+ pgrep -f "Xvfb" >/dev/null
136
+ pgrep -f "x11vnc" >/dev/null
137
+ exit 0
138
+ fi
139
+
140
+ curl -fsS --max-time 3 "http://127.0.0.1:6080/vnc.html" >/dev/null
141
+ adb start-server >/dev/null 2>&1 || true
142
+ adb connect 127.0.0.1:5555 >/dev/null 2>&1 || true
143
+
144
+ BOOT=\$(adb -s 127.0.0.1:5555 shell getprop sys.boot_completed 2>/dev/null | tr -d '\\r' || true)
145
+ DEV_BOOT=\$(adb -s 127.0.0.1:5555 shell getprop dev.bootcomplete 2>/dev/null | tr -d '\\r' || true)
146
+ BOOT_ANIM=\$(adb -s 127.0.0.1:5555 shell getprop init.svc.bootanim 2>/dev/null | tr -d '\\r' || true)
147
+
148
+ [ "\$BOOT" = "1" ] || [ "\$DEV_BOOT" = "1" ] || [ "\$BOOT_ANIM" = "stopped" ]
149
+ `;
150
+
151
+ const NOVNC_PORT = 6080;
152
+
153
+ function generateManifests(namespace, displayImage, domain, emulatorName) {
154
+ const core = [];
155
+ const ingress = [];
156
+
157
+ core.push(`apiVersion: apps/v1
158
+ kind: Deployment
159
+ metadata:
160
+ name: ${emulatorName}
161
+ namespace: ${namespace}
162
+ spec:
163
+ replicas: 1
164
+ progressDeadlineSeconds: 1800
165
+ selector:
166
+ matchLabels:
167
+ app: ${emulatorName}
168
+ strategy:
169
+ type: Recreate
170
+ template:
171
+ metadata:
172
+ labels:
173
+ app: ${emulatorName}
174
+ spec:
175
+ hostPID: true
176
+ initContainers:
177
+ - name: load-binder
178
+ image: busybox:latest
179
+ command:
180
+ - sh
181
+ - -c
182
+ - |
183
+ set -e
184
+ nsenter --target 1 --mount --uts --ipc --net -- sh -c '
185
+ modprobe binder_linux devices=binder,hwbinder,vndbinder 2>/dev/null || true
186
+ if ! mountpoint -q /dev/binderfs; then
187
+ mkdir -p /dev/binderfs
188
+ mount -t binder binder /dev/binderfs || true
189
+ fi
190
+ # Create binder devices in host namespace
191
+ for dev in binder hwbinder vndbinder; do
192
+ if [ ! -e /dev/binderfs/$dev ]; then
193
+ ln -sf /dev/$dev /dev/binderfs/$dev 2>/dev/null || true
194
+ fi
195
+ done
196
+ ls -la /dev/binderfs/ 2>/dev/null || true
197
+ ls -la /dev/binder* 2>/dev/null || true
198
+ '
199
+ securityContext:
200
+ privileged: true
201
+ volumeMounts:
202
+ - name: host-lib-modules
203
+ mountPath: /lib/modules
204
+ readOnly: true
205
+ containers:
206
+ - name: redroid
207
+ image: ${REDROID_IMAGE}
208
+ securityContext:
209
+ privileged: true
210
+ args:
211
+ - androidboot.redroid_gpu_mode=auto
212
+ - androidboot.redroid_gpu_node=/dev/dri/renderD128
213
+ - androidboot.use_memfd=true
214
+ - androidboot.redroid_width=720
215
+ - androidboot.redroid_height=1280
216
+ - androidboot.redroid_fps=30
217
+ - androidboot.redroid_dex2oat_threads=1
218
+ - ro.crypto.state=unencrypted
219
+ - ro.crypto.type=none
220
+ - ro.setupwizard.mode=DISABLED
221
+ - ro.lockscreen.disable=1
222
+ - ro.hw_timeout_multiplier=50
223
+ resources:
224
+ requests:
225
+ memory: "2Gi"
226
+ cpu: "1"
227
+ limits:
228
+ memory: "4Gi"
229
+ cpu: "4"
230
+ ports:
231
+ - containerPort: 5555
232
+ name: adb
233
+ volumeMounts:
234
+ - name: redroid-data
235
+ mountPath: /data
236
+ - name: display
237
+ image: ${displayImage}
238
+ imagePullPolicy: Always
239
+ securityContext:
240
+ capabilities:
241
+ add: ["NET_ADMIN"]
242
+ ports:
243
+ - containerPort: ${NOVNC_PORT}
244
+ name: novnc
245
+ startupProbe:
246
+ exec:
247
+ command: ["/health-check.sh", "startup"]
248
+ initialDelaySeconds: 30
249
+ periodSeconds: 10
250
+ timeoutSeconds: 10
251
+ failureThreshold: 60
252
+ readinessProbe:
253
+ exec:
254
+ command: ["/health-check.sh", "ready"]
255
+ periodSeconds: 15
256
+ timeoutSeconds: 10
257
+ failureThreshold: 5
258
+ livenessProbe:
259
+ exec:
260
+ command: ["/health-check.sh", "startup"]
261
+ initialDelaySeconds: 60
262
+ periodSeconds: 30
263
+ timeoutSeconds: 10
264
+ failureThreshold: 10
265
+ volumes:
266
+ - name: redroid-data
267
+ emptyDir:
268
+ sizeLimit: 10Gi
269
+ - name: host-lib-modules
270
+ hostPath:
271
+ path: /lib/modules
272
+ type: Directory`);
273
+
274
+ core.push(`apiVersion: v1
275
+ kind: Service
276
+ metadata:
277
+ name: ${emulatorName}
278
+ namespace: ${namespace}
279
+ spec:
280
+ selector:
281
+ app: ${emulatorName}
282
+ ports:
283
+ - protocol: TCP
284
+ port: ${NOVNC_PORT}
285
+ targetPort: ${NOVNC_PORT}
286
+ name: novnc`);
287
+
288
+ if (domain) {
289
+ ingress.push(`apiVersion: networking.k8s.io/v1
290
+ kind: Ingress
291
+ metadata:
292
+ name: ${emulatorName}
293
+ namespace: ${namespace}
294
+ annotations:
295
+ cert-manager.io/cluster-issuer: letsencrypt-production
296
+ nginx.ingress.kubernetes.io/app-root: "/vnc_lite.html?autoconnect=true&resize=scale&scaleViewport=true"
297
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
298
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
299
+ nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
300
+ spec:
301
+ ingressClassName: nginx
302
+ tls:
303
+ - hosts:
304
+ - ${domain}
305
+ secretName: ${emulatorName}-tls
306
+ rules:
307
+ - host: ${domain}
308
+ http:
309
+ paths:
310
+ - path: /
311
+ pathType: Prefix
312
+ backend:
313
+ service:
314
+ name: ${emulatorName}
315
+ port:
316
+ number: ${NOVNC_PORT}`);
317
+ }
318
+
319
+ return { core: core.join('\n---\n'), ingress: ingress.length ? ingress.join('\n---\n') : null };
320
+ }
321
+
322
+ function createWebAndroidEmulatorAdapter(adapterOptions = {}) {
323
+ const namespace = adapterOptions.namespace || 'default';
324
+ const domain = adapterOptions.domain || null;
325
+ const registryHost = adapterOptions.registryHost || 'localhost:5000';
326
+ const cloudflareDns = adapterOptions.cloudflareDns || null;
327
+ const parsedRolloutTimeout = Number(adapterOptions.rolloutTimeoutSeconds);
328
+ const rolloutTimeoutSeconds = Number.isFinite(parsedRolloutTimeout) && parsedRolloutTimeout >= 60
329
+ ? Math.floor(parsedRolloutTimeout)
330
+ : 1800;
331
+
332
+ const previewUrl = adapterOptions.previewUrl ||
333
+ (domain
334
+ ? `https://${domain}/vnc.html?autoconnect=true&resize=scale`
335
+ : `http://localhost:${NOVNC_PORT}/vnc.html?autoconnect=true&resize=scale`);
336
+
337
+ return {
338
+ async start({ runner, workspacePath, artifactPath, applicationId, onLog, projectSlug, domain: projectDomain, cloudflareDns: projectCloudflareDns }) {
339
+ const emit = async (msg) => { if (onLog) await onLog(msg); };
340
+ const buildDir = path.posix.join(workspacePath, '.web-android-emulator');
341
+ const emulatorName = projectSlug || 'android';
342
+ const effectiveDomain = projectDomain || domain;
343
+ const effectiveCloudflareDns = projectCloudflareDns || cloudflareDns;
344
+
345
+ /* ── 1. Build display sidecar image ──────────────────────────────── */
346
+ const buildHash = crypto.createHash('sha256')
347
+ .update(DISPLAY_DOCKERFILE)
348
+ .update(DISPLAY_ENTRYPOINT)
349
+ .update(HEALTH_CHECK)
350
+ .digest('hex')
351
+ .slice(0, 12);
352
+ const imageTag = `redroid-display:${buildHash}`;
353
+ const registryImage = `${registryHost}/${imageTag}`;
354
+
355
+ const checkResult = await runner.run(
356
+ `docker images -q ${imageTag} 2>/dev/null || sudo docker images -q ${imageTag} 2>/dev/null`,
357
+ { allowFailure: true },
358
+ );
359
+ const imageExists = checkResult.stdout.trim().length > 0;
360
+
361
+ if (!imageExists) {
362
+ await emit('[redroid] Building display sidecar image...');
363
+ await runner.run(`mkdir -p ${buildDir}/scripts`);
364
+ await runner.writeBase64File(
365
+ path.posix.join(buildDir, 'Dockerfile'),
366
+ Buffer.from(DISPLAY_DOCKERFILE).toString('base64'),
367
+ );
368
+ await runner.writeBase64File(
369
+ path.posix.join(buildDir, 'entrypoint.sh'),
370
+ Buffer.from(DISPLAY_ENTRYPOINT).toString('base64'),
371
+ );
372
+ await runner.writeBase64File(
373
+ path.posix.join(buildDir, 'health-check.sh'),
374
+ Buffer.from(HEALTH_CHECK).toString('base64'),
375
+ );
376
+ await runner.run(`chmod +x ${buildDir}/entrypoint.sh ${buildDir}/health-check.sh`);
377
+
378
+ let lastOutputAt = Date.now();
379
+ const heartbeat = setInterval(async () => {
380
+ const s = Math.floor((Date.now() - lastOutputAt) / 1000);
381
+ if (s >= 30) await emit(`[redroid] still building... (${s}s)`);
382
+ }, 30 * 1000);
383
+ const track = (line) => { lastOutputAt = Date.now(); return emit(line); };
384
+ try {
385
+ await runner.run(
386
+ `docker build --provenance=false --sbom=false -t ${imageTag} ${buildDir} || sudo docker build --provenance=false --sbom=false -t ${imageTag} ${buildDir}`,
387
+ { onStdout: track, onStderr: track, timeoutMs: 30 * 60 * 1000 },
388
+ );
389
+ } finally {
390
+ clearInterval(heartbeat);
391
+ }
392
+ await emit('[redroid] Display sidecar built');
393
+ } else {
394
+ await emit('[redroid] Display sidecar image exists, skipping build');
395
+ }
396
+
397
+ /* ── 2. Push display sidecar ─────────────────────────────────────── */
398
+ await emit(`[redroid] Pushing display sidecar to ${registryImage}...`);
399
+ await runner.run(
400
+ `(docker tag ${imageTag} ${registryImage} && docker push ${registryImage}) || (sudo docker tag ${imageTag} ${registryImage} && sudo docker push ${registryImage})`,
401
+ { onStdout: emit, onStderr: emit },
402
+ );
403
+ await emit('[redroid] Image pushed');
404
+
405
+ /* ── 3. Ensure redroid image is available on nodes ───────────────── */
406
+ await emit('[redroid] Pre-pulling redroid image on nodes...');
407
+ try {
408
+ await runner.run(
409
+ `sudo k3s ctr -n k8s.io images pull ${REDROID_IMAGE}`,
410
+ { onStdout: emit, onStderr: emit, timeoutMs: 10 * 60 * 1000 },
411
+ );
412
+ } catch (err) {
413
+ await emit(`[redroid] Pre-pull skipped (non-fatal): ${err.message || err}. Kubelet will pull the image when scheduling the pod.`);
414
+ }
415
+
416
+ /* ── 4. Apply k8s manifests ──────────────────────────────────────── */
417
+ await emit('[redroid] Deleting existing resources (clean restart)...');
418
+ await runner.run(
419
+ `kubectl delete deployment ${emulatorName} -n ${namespace} --ignore-not-found=true 2>/dev/null || sudo kubectl delete deployment ${emulatorName} -n ${namespace} --ignore-not-found=true 2>/dev/null || true`,
420
+ { allowFailure: true, onStdout: emit, onStderr: emit },
421
+ );
422
+ await runner.run(
423
+ `kubectl delete ingress ${emulatorName} -n ${namespace} --ignore-not-found=true 2>/dev/null || sudo kubectl delete ingress ${emulatorName} -n ${namespace} --ignore-not-found=true 2>/dev/null || true`,
424
+ { allowFailure: true, onStdout: emit, onStderr: emit },
425
+ );
426
+
427
+ await emit('[redroid] Applying Kubernetes manifests...');
428
+ const manifests = generateManifests(namespace, registryImage, effectiveDomain, emulatorName);
429
+ const corePath = path.posix.join(buildDir, 'k8s-emulator-core.yaml');
430
+ await runner.writeBase64File(corePath, Buffer.from(manifests.core).toString('base64'));
431
+ await runner.run(
432
+ `kubectl apply -f ${corePath} || sudo kubectl apply -f ${corePath}`,
433
+ { onStdout: emit, onStderr: emit },
434
+ );
435
+
436
+ if (manifests.ingress) {
437
+ const ingressPath = path.posix.join(buildDir, 'k8s-emulator-ingress.yaml');
438
+ await runner.writeBase64File(ingressPath, Buffer.from(manifests.ingress).toString('base64'));
439
+ try {
440
+ await runner.run(
441
+ `kubectl apply -f ${ingressPath} || sudo kubectl apply -f ${ingressPath}`,
442
+ { onStdout: emit, onStderr: emit },
443
+ );
444
+ } catch (err) {
445
+ await emit(`[redroid] Ingress creation failed (non-fatal): ${err.message || err}`);
446
+ }
447
+ }
448
+
449
+ /* ── 4b. Sync Cloudflare DNS for emulator domain ─────────────────── */
450
+ if (effectiveDomain && effectiveCloudflareDns && effectiveCloudflareDns.targetIp) {
451
+ const emulatorDomain = effectiveDomain;
452
+ try {
453
+ await emit(`[redroid] Syncing Cloudflare DNS for ${emulatorDomain}...`);
454
+ await upsertCloudflareDnsRecord({ ...effectiveCloudflareDns, domain: emulatorDomain });
455
+ await emit(`[redroid] DNS synced: ${emulatorDomain} -> ${effectiveCloudflareDns.targetIp}`);
456
+ } catch (err) {
457
+ await emit(`[redroid] DNS sync failed for ${emulatorDomain}: ${err.message || err}`);
458
+ }
459
+ }
460
+
461
+ /* ── 5. Wait for rollout ─────────────────────────────────────────── */
462
+ await emit('[redroid] Waiting for pod (Android boot + display)...');
463
+ let lastRolloutAt = Date.now();
464
+ const rolloutHB = setInterval(async () => {
465
+ const s = Math.floor((Date.now() - lastRolloutAt) / 1000);
466
+ if (s >= 30) await emit(`[redroid] still waiting... (${s}s)`);
467
+ }, 30 * 1000);
468
+ const trackRollout = (line) => { lastRolloutAt = Date.now(); return emit(line); };
469
+ try {
470
+ await runner.run(
471
+ `kubectl rollout status deployment/${emulatorName} -n ${namespace} --timeout=${rolloutTimeoutSeconds}s || sudo kubectl rollout status deployment/${emulatorName} -n ${namespace} --timeout=${rolloutTimeoutSeconds}s`,
472
+ { onStdout: trackRollout, onStderr: trackRollout, timeoutMs: (rolloutTimeoutSeconds + 20) * 1000 },
473
+ );
474
+ } catch (rolloutError) {
475
+ await emit('[redroid] Rollout command failed. Verifying real pod readiness before aborting...');
476
+ const readinessProbeResult = await runner.run(
477
+ `kubectl get deployment ${emulatorName} -n ${namespace} -o jsonpath='{.status.readyReplicas}/{.status.updatedReplicas}/{.status.replicas}' 2>/dev/null || sudo kubectl get deployment ${emulatorName} -n ${namespace} -o jsonpath='{.status.readyReplicas}/{.status.updatedReplicas}/{.status.replicas}'`,
478
+ { allowFailure: true },
479
+ );
480
+ const readinessText = (readinessProbeResult.stdout || '').trim().replace(/'/g, '');
481
+ const [readyRaw, updatedRaw, replicasRaw] = readinessText.split('/');
482
+ const readyReplicas = Number(readyRaw || 0);
483
+ const updatedReplicas = Number(updatedRaw || 0);
484
+ const desiredReplicas = Number(replicasRaw || 0);
485
+
486
+ if (
487
+ Number.isFinite(readyReplicas) &&
488
+ Number.isFinite(updatedReplicas) &&
489
+ Number.isFinite(desiredReplicas) &&
490
+ desiredReplicas > 0 &&
491
+ readyReplicas >= 1 &&
492
+ updatedReplicas >= 1
493
+ ) {
494
+ await emit(
495
+ `[redroid] Rollout status command failed but deployment is ready (${readyReplicas}/${desiredReplicas}). Continuing...`,
496
+ );
497
+ } else {
498
+ await emit('[redroid] Rollout failed. Collecting diagnostics...');
499
+ for (const cmd of [
500
+ `kubectl get pods -n ${namespace} -l app=${emulatorName} -o wide`,
501
+ `kubectl describe pod -n ${namespace} -l app=${emulatorName}`,
502
+ `kubectl logs -n ${namespace} -l app=${emulatorName} -c redroid --tail=50`,
503
+ `kubectl logs -n ${namespace} -l app=${emulatorName} -c display --tail=50`,
504
+ ]) {
505
+ await runner.run(`${cmd} || sudo ${cmd}`, { allowFailure: true, onStdout: emit, onStderr: emit });
506
+ }
507
+ throw rolloutError;
508
+ }
509
+ } finally {
510
+ clearInterval(rolloutHB);
511
+ }
512
+ await emit('[redroid] Pod is ready');
513
+
514
+ /* ── 5.5. Fix Android policy routing ────────────────────────────── */
515
+ {
516
+ const fixPodResult = await runner.run(
517
+ `kubectl get pod -n ${namespace} -l app=${emulatorName} -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || sudo kubectl get pod -n ${namespace} -l app=${emulatorName} -o jsonpath='{.items[0].metadata.name}'`,
518
+ );
519
+ const fixPodName = fixPodResult.stdout.trim().replace(/'/g, '');
520
+ if (fixPodName) {
521
+ await emit('[redroid] Fixing Android network policy routing...');
522
+ await runner.run(
523
+ `kubectl exec -n ${namespace} ${fixPodName} -c redroid -- ip rule del from all unreachable 2>/dev/null || sudo kubectl exec -n ${namespace} ${fixPodName} -c redroid -- ip rule del from all unreachable 2>/dev/null || true`,
524
+ { allowFailure: true, onStdout: emit, onStderr: emit },
525
+ );
526
+ await emit('[redroid] Network routing fixed');
527
+ }
528
+ }
529
+
530
+ /* ── 6. Install APK ──────────────────────────────────────────────── */
531
+ if (artifactPath) {
532
+ const podResult = await runner.run(
533
+ `kubectl get pod -n ${namespace} -l app=${emulatorName} -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || sudo kubectl get pod -n ${namespace} -l app=${emulatorName} -o jsonpath='{.items[0].metadata.name}'`,
534
+ );
535
+ const podName = podResult.stdout.trim().replace(/'/g, '');
536
+ if (!podName) throw new Error('[redroid] No pod found');
537
+
538
+ await emit(`[redroid] Copying APK to pod ${podName}...`);
539
+ await runner.run(
540
+ `kubectl cp "${artifactPath}" "${namespace}/${podName}:/tmp/app.apk" -c display || sudo kubectl cp "${artifactPath}" "${namespace}/${podName}:/tmp/app.apk" -c display`,
541
+ );
542
+
543
+ const apkCheck = await runner.run(
544
+ `kubectl exec -n ${namespace} ${podName} -c display -- sh -c 'test -s /tmp/app.apk && echo OK || echo MISSING' || sudo kubectl exec -n ${namespace} ${podName} -c display -- sh -c 'test -s /tmp/app.apk && echo OK || echo MISSING'`,
545
+ { allowFailure: true },
546
+ );
547
+
548
+ if (!apkCheck.stdout.includes('OK')) {
549
+ await emit('[redroid] APK not found after kubectl cp. Retrying via stream copy...');
550
+ await runner.run(
551
+ `cat "${artifactPath}" | kubectl exec -i -n ${namespace} ${podName} -c display -- sh -c 'cat > /tmp/app.apk' || cat "${artifactPath}" | sudo kubectl exec -i -n ${namespace} ${podName} -c display -- sh -c 'cat > /tmp/app.apk'`,
552
+ );
553
+
554
+ const apkCheckAfterStream = await runner.run(
555
+ `kubectl exec -n ${namespace} ${podName} -c display -- sh -c 'test -s /tmp/app.apk && echo OK || echo MISSING' || sudo kubectl exec -n ${namespace} ${podName} -c display -- sh -c 'test -s /tmp/app.apk && echo OK || echo MISSING'`,
556
+ { allowFailure: true },
557
+ );
558
+
559
+ if (!apkCheckAfterStream.stdout.includes('OK')) {
560
+ throw new Error('[redroid] Failed to stage APK at /tmp/app.apk inside display container');
561
+ }
562
+ }
563
+
564
+ /* Wait for boot, kill vold to break prepareUserStorage deadlock, wait for RUNNING_UNLOCKED */
565
+ await emit('[redroid] Waiting for Android boot...');
566
+ await runner.run(
567
+ `for i in $(seq 1 120); do
568
+ BOOT=$(kubectl exec -n ${namespace} ${podName} -c redroid -- getprop sys.boot_completed 2>/dev/null | tr -d "\\r" || true);
569
+ if [ "$BOOT" = "1" ]; then echo "Boot complete"; break; fi;
570
+ if [ $((i % 20)) -eq 0 ]; then echo "Waiting for boot... ($i/120)"; fi;
571
+ sleep 3;
572
+ done`,
573
+ { onStdout: emit, onStderr: emit, timeoutMs: 8 * 60 * 1000 },
574
+ );
575
+
576
+ await emit('[redroid] Breaking vold deadlock (kill -9 vold in redroid container)...');
577
+ await runner.run(
578
+ `kubectl exec -n ${namespace} ${podName} -c redroid -- sh -c 'kill -9 $(pidof vold)' || true`,
579
+ { onStdout: emit, onStderr: emit, allowFailure: true },
580
+ );
581
+
582
+ await emit('[redroid] Waiting for RUNNING_UNLOCKED...');
583
+ await runner.run(
584
+ `READY=0;
585
+ for i in $(seq 1 60); do
586
+ USER_STATE=$(kubectl exec -n ${namespace} ${podName} -c redroid -- am get-started-user-state 0 2>/dev/null | grep -o "RUNNING_[A-Z_]*" || true);
587
+ if [ "$USER_STATE" = "RUNNING_UNLOCKED" ]; then
588
+ echo "User 0 RUNNING_UNLOCKED";
589
+ READY=1;
590
+ break;
591
+ fi;
592
+ if [ $((i % 10)) -eq 0 ]; then
593
+ echo "User state=$USER_STATE ($i/60), re-killing vold...";
594
+ kubectl exec -n ${namespace} ${podName} -c redroid -- sh -c 'kill -9 $(pidof vold)' 2>/dev/null || true;
595
+ fi;
596
+ sleep 2;
597
+ done;
598
+ if [ "$READY" -ne 1 ]; then
599
+ echo "WARNING: User not fully unlocked, attempting install anyway...";
600
+ fi;
601
+ echo "Verifying PM is responsive...";
602
+ timeout 10 kubectl exec -n ${namespace} ${podName} -c redroid -- pm path android 2>/dev/null && echo "PM OK" || echo "PM check failed"`,
603
+ { onStdout: emit, onStderr: emit, timeoutMs: 8 * 60 * 1000 },
604
+ );
605
+
606
+ await emit('[redroid] Installing APK...');
607
+ await runner.run(
608
+ `kubectl exec -n ${namespace} ${podName} -c display -- sh -c '
609
+ MAX_RETRIES=12;
610
+ for attempt in $(seq 1 $MAX_RETRIES); do
611
+ echo "Install attempt $attempt/$MAX_RETRIES";
612
+ if [ ! -s /tmp/app.apk ]; then
613
+ echo "APK file missing in display container: /tmp/app.apk";
614
+ exit 1;
615
+ fi;
616
+ adb connect 127.0.0.1:5555 >/dev/null 2>&1 || true;
617
+ if ! adb -s 127.0.0.1:5555 shell service check package 2>/dev/null | grep -q "found"; then
618
+ echo "Package manager service unavailable; waiting 10s";
619
+ sleep 10;
620
+ continue;
621
+ fi;
622
+ RESULT=$(adb -s 127.0.0.1:5555 install -r -d -g /tmp/app.apk 2>&1 || true);
623
+ echo "$RESULT";
624
+ if echo "$RESULT" | grep -q "Success"; then
625
+ echo "APK installed successfully";
626
+ sleep 3;
627
+ echo "Verifying activities...";
628
+ adb -s 127.0.0.1:5555 shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER ${applicationId ? applicationId.replace(/[^a-zA-Z0-9._]/g, '') : ''} 2>/dev/null || true;
629
+ exit 0;
630
+ fi;
631
+ if [ "$attempt" -lt "$MAX_RETRIES" ]; then
632
+ echo "Retrying in 10s...";
633
+ sleep 10;
634
+ fi;
635
+ done;
636
+ echo "APK install failed after $MAX_RETRIES attempts";
637
+ exit 1'`,
638
+ { onStdout: emit, onStderr: emit, timeoutMs: 16 * 60 * 1000 },
639
+ );
640
+
641
+ if (applicationId) {
642
+ const launchAppId = applicationId.replace(/[^a-zA-Z0-9._]/g, '');
643
+ await emit('[redroid] Waiting for ActivityManager...');
644
+ await runner.run(
645
+ `kubectl exec -n ${namespace} ${podName} -c display -- sh -c '
646
+ for i in $(seq 1 120); do
647
+ if adb -s 127.0.0.1:5555 shell service check activity 2>/dev/null | grep -q "found"; then
648
+ if adb -s 127.0.0.1:5555 shell cmd activity get-current-user >/dev/null 2>&1 || adb -s 127.0.0.1:5555 shell am stack list >/dev/null 2>&1; then
649
+ echo "ActivityManager is ready";
650
+ exit 0;
651
+ fi;
652
+ fi;
653
+ echo "Waiting for ActivityManager... ($i/120)";
654
+ sleep 2;
655
+ done;
656
+ echo "ActivityManager is not ready yet";
657
+ exit 1'`,
658
+ { onStdout: emit, onStderr: emit, timeoutMs: 8 * 60 * 1000 },
659
+ );
660
+
661
+ await emit(`[redroid] Launching ${launchAppId}...`);
662
+ await runner.run(
663
+ `kubectl exec -n ${namespace} ${podName} -c display -- sh -c '
664
+ APP_ID='"'"'${launchAppId}'"'"';
665
+ for i in $(seq 1 10); do
666
+ ACT=$(adb -s 127.0.0.1:5555 shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER "$APP_ID" 2>/dev/null | tr -d "\\r" | tail -n 1 || true);
667
+ if echo "$ACT" | grep -q "/"; then
668
+ RESULT=$(adb -s 127.0.0.1:5555 shell am start -W -n "$ACT" 2>&1 || true);
669
+ echo "$RESULT";
670
+ if echo "$RESULT" | grep -Eq "Status: ok|Activity:"; then
671
+ echo "App launched successfully via am start";
672
+ exit 0;
673
+ fi;
674
+ fi;
675
+
676
+ RESULT=$(adb -s 127.0.0.1:5555 shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 2>&1 || true);
677
+ echo "$RESULT";
678
+ if echo "$RESULT" | grep -q "Events injected"; then
679
+ echo "App launched successfully via monkey";
680
+ exit 0;
681
+ fi;
682
+ echo "Retry launch ($i/10)...";
683
+ sleep 6;
684
+ done;
685
+ echo "Launch retries exhausted";
686
+ exit 0'`,
687
+ { allowFailure: true, onStdout: emit, onStderr: emit, timeoutMs: 4 * 60 * 1000 },
688
+ );
689
+ }
690
+ }
691
+
692
+ const projectPreviewUrl = effectiveDomain
693
+ ? `https://${effectiveDomain}/vnc.html?autoconnect=true&resize=scale`
694
+ : previewUrl;
695
+ await emit(`[redroid] Preview ready: ${projectPreviewUrl}`);
696
+ return { url: projectPreviewUrl };
697
+ },
698
+ };
699
+ }
700
+
701
+ module.exports = { createWebAndroidEmulatorAdapter };