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.
- package/index.cjs +1 -0
- package/index.d.ts +193 -0
- package/index.mjs +4 -0
- package/package.json +47 -0
- package/src/bootstrap/index.cjs +211 -0
- package/src/detect/index.cjs +461 -0
- package/src/dns/cloudflare.cjs +163 -0
- package/src/execute/index.cjs +386 -0
- package/src/index.cjs +67 -0
- package/src/mobile/index.cjs +1093 -0
- package/src/mobile/web-android-emulator-adapter.cjs +701 -0
- package/src/plan/index.cjs +200 -0
- package/src/runtime/index.cjs +16 -0
- package/src/runtime/local-runner.cjs +69 -0
- package/src/runtime/ssh-runner.cjs +206 -0
- package/src/shared/source.cjs +145 -0
- package/src/shared/utils.cjs +104 -0
- package/src/templates/dockerfile.cjs +114 -0
- package/src/templates/manifests.cjs +291 -0
|
@@ -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 };
|