k3s-deployer 0.1.0 → 1.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k3s-deployer",
3
- "version": "0.1.0",
3
+ "version": "1.0.1",
4
4
  "description": "Automatic k3s deployment planner and executor for full stack applications",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.mjs",
@@ -1,154 +1,57 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
- const crypto = require('node:crypto');
5
4
  const { upsertCloudflareDnsRecord } = require('../dns/cloudflare.cjs');
6
5
 
7
6
  /* ────────────────────────────────────────────────────────────────────────────
8
- * redroid + scrcpy-web adapter
7
+ * redroid + ws-scrcpy adapter
9
8
  *
10
9
  * Runs Android natively on ARM64 via redroid (no emulation).
11
- * Uses a scrcpy + noVNC sidecar for browser-based display.
10
+ * Uses ws-scrcpy sidecar for browser-based display (H.264 WebSocket).
12
11
  *
13
12
  * Flow:
14
13
  * 1. Load binder kernel module (init container)
15
14
  * 2. Start redroid container (native ARM64 Android)
16
- * 3. Start display sidecar (scrcpy Xvfb → x11vnc → noVNC)
15
+ * 3. Start ws-scrcpy sidecar (direct H.264 streaming)
17
16
  * 4. Install APK via adb
18
- * 5. Browser preview via noVNC
17
+ * 5. Browser preview via ws-scrcpy web UI
19
18
  * ──────────────────────────────────────────────────────────────────────────── */
20
19
 
21
20
  const REDROID_IMAGE = 'docker.io/redroid/redroid:12.0.0-latest';
22
21
 
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"]
22
+ /* ws-scrcpy uses a pre-built image from the local registry */
23
+ const WS_SCRCPY_IMAGE = 'ws-scrcpy:latest';
24
+
25
+ const WS_SCRCPY_PORT = 8000;
26
+
27
+ /**
28
+ * Generate a custom index.html for ws-scrcpy that auto-redirects to the
29
+ * H.264 MSE stream and centers the video with high quality display.
30
+ */
31
+ function generateStreamIndexHtml() {
32
+ return `<!doctype html><html lang="en"><head><meta charset="UTF-8">
33
+ <meta content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" name="viewport"/>
34
+ <title>Mobile Preview</title>
35
+ <script>
36
+ (function(){
37
+ var h="#!action=stream&udid=127.0.0.1%3A5555&player=webcodecs&ws=wss%3A%2F%2F"+location.host+"%2F%3Faction%3Dproxy-adb%26remote%3Dtcp%253A8886%26udid%3D127.0.0.1%253A5555";
38
+ if(!location.hash||location.hash==="#"||location.hash.indexOf("action=stream")===-1){
39
+ window.location.hash=h;window.location.reload();
40
+ }
41
+ })();
42
+ </script>
43
+ <script defer="defer" src="bundle.js"></script>
44
+ <link href="main.css" rel="stylesheet">
45
+ <style>
46
+ *{margin:0!important;padding:0!important;box-sizing:border-box!important}
47
+ html,body{width:100%!important;height:100%!important;background:#000!important;overflow:hidden!important}
48
+ body,.device-view,.stream{position:absolute!important;top:0!important;left:0!important;width:100%!important;height:100%!important;display:flex!important;justify-content:center!important;align-items:center!important}
49
+ .video,.video-layer{display:flex!important;justify-content:center!important;align-items:center!important;width:100%!important;height:100%!important}
50
+ video,canvas{display:block!important;height:100vh!important;width:auto!important;max-width:100vw!important;object-fit:contain!important}
51
+ .control-buttons,.toolbar{position:fixed!important;right:0!important;top:50%!important;transform:translateY(-50%)!important;z-index:999!important}
52
+ </style></head><body></body></html>
40
53
  `;
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
54
  }
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
55
 
153
56
  function generateManifests(namespace, displayImage, domain, emulatorName) {
154
57
  const core = [];
@@ -213,7 +116,8 @@ spec:
213
116
  - androidboot.use_memfd=true
214
117
  - androidboot.redroid_width=720
215
118
  - androidboot.redroid_height=1280
216
- - androidboot.redroid_fps=30
119
+ - androidboot.redroid_fps=24
120
+ - androidboot.redroid_dpi=280
217
121
  - androidboot.redroid_dex2oat_threads=1
218
122
  - ro.crypto.state=unencrypted
219
123
  - ro.crypto.type=none
@@ -240,27 +144,30 @@ spec:
240
144
  capabilities:
241
145
  add: ["NET_ADMIN"]
242
146
  ports:
243
- - containerPort: ${NOVNC_PORT}
244
- name: novnc
147
+ - containerPort: ${WS_SCRCPY_PORT}
148
+ name: ws-scrcpy
245
149
  startupProbe:
246
- exec:
247
- command: ["/health-check.sh", "startup"]
150
+ httpGet:
151
+ path: /
152
+ port: ${WS_SCRCPY_PORT}
248
153
  initialDelaySeconds: 30
249
154
  periodSeconds: 10
250
- timeoutSeconds: 10
155
+ timeoutSeconds: 5
251
156
  failureThreshold: 60
252
157
  readinessProbe:
253
- exec:
254
- command: ["/health-check.sh", "ready"]
158
+ httpGet:
159
+ path: /
160
+ port: ${WS_SCRCPY_PORT}
255
161
  periodSeconds: 15
256
- timeoutSeconds: 10
162
+ timeoutSeconds: 5
257
163
  failureThreshold: 5
258
164
  livenessProbe:
259
- exec:
260
- command: ["/health-check.sh", "startup"]
165
+ httpGet:
166
+ path: /
167
+ port: ${WS_SCRCPY_PORT}
261
168
  initialDelaySeconds: 60
262
169
  periodSeconds: 30
263
- timeoutSeconds: 10
170
+ timeoutSeconds: 5
264
171
  failureThreshold: 10
265
172
  volumes:
266
173
  - name: redroid-data
@@ -281,9 +188,9 @@ spec:
281
188
  app: ${emulatorName}
282
189
  ports:
283
190
  - protocol: TCP
284
- port: ${NOVNC_PORT}
285
- targetPort: ${NOVNC_PORT}
286
- name: novnc`);
191
+ port: ${WS_SCRCPY_PORT}
192
+ targetPort: ${WS_SCRCPY_PORT}
193
+ name: ws-scrcpy`);
287
194
 
288
195
  if (domain) {
289
196
  ingress.push(`apiVersion: networking.k8s.io/v1
@@ -293,10 +200,11 @@ metadata:
293
200
  namespace: ${namespace}
294
201
  annotations:
295
202
  cert-manager.io/cluster-issuer: letsencrypt-production
296
- nginx.ingress.kubernetes.io/app-root: "/vnc_lite.html?autoconnect=true&resize=scale&scaleViewport=true"
203
+ nginx.ingress.kubernetes.io/proxy-body-size: "0"
297
204
  nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
298
205
  nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
299
206
  nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
207
+ nginx.ingress.kubernetes.io/websocket-services: "${emulatorName}"
300
208
  spec:
301
209
  ingressClassName: nginx
302
210
  tls:
@@ -313,7 +221,7 @@ spec:
313
221
  service:
314
222
  name: ${emulatorName}
315
223
  port:
316
- number: ${NOVNC_PORT}`);
224
+ number: ${WS_SCRCPY_PORT}`);
317
225
  }
318
226
 
319
227
  return { core: core.join('\n---\n'), ingress: ingress.length ? ingress.join('\n---\n') : null };
@@ -331,8 +239,8 @@ function createWebAndroidEmulatorAdapter(adapterOptions = {}) {
331
239
 
332
240
  const previewUrl = adapterOptions.previewUrl ||
333
241
  (domain
334
- ? `https://${domain}/vnc.html?autoconnect=true&resize=scale`
335
- : `http://localhost:${NOVNC_PORT}/vnc.html?autoconnect=true&resize=scale`);
242
+ ? `https://${domain}`
243
+ : `http://localhost:${WS_SCRCPY_PORT}`);
336
244
 
337
245
  return {
338
246
  async start({ runner, workspacePath, artifactPath, applicationId, onLog, projectSlug, domain: projectDomain, cloudflareDns: projectCloudflareDns }) {
@@ -342,66 +250,22 @@ function createWebAndroidEmulatorAdapter(adapterOptions = {}) {
342
250
  const effectiveDomain = projectDomain || domain;
343
251
  const effectiveCloudflareDns = projectCloudflareDns || cloudflareDns;
344
252
 
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}`;
253
+ /* ── 1. Use pre-built ws-scrcpy image ────────────────────────────── */
254
+ const registryImage = `${registryHost}/${WS_SCRCPY_IMAGE}`;
255
+ await emit(`[redroid] Using ws-scrcpy display image: ${registryImage}`);
354
256
 
355
257
  const checkResult = await runner.run(
356
- `docker images -q ${imageTag} 2>/dev/null || sudo docker images -q ${imageTag} 2>/dev/null`,
258
+ `docker images -q ${registryHost}/${WS_SCRCPY_IMAGE} 2>/dev/null || sudo docker images -q ${registryHost}/${WS_SCRCPY_IMAGE} 2>/dev/null`,
357
259
  { allowFailure: true },
358
260
  );
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'),
261
+ if (!checkResult.stdout.trim()) {
262
+ await emit('[redroid] ws-scrcpy image not found in registry, tagging and pushing...');
263
+ await runner.run(
264
+ `(docker tag ${WS_SCRCPY_IMAGE} ${registryImage} && docker push ${registryImage}) || (sudo docker tag ${WS_SCRCPY_IMAGE} ${registryImage} && sudo docker push ${registryImage})`,
265
+ { onStdout: emit, onStderr: emit },
375
266
  );
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
267
  }
396
268
 
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
269
  /* ── 3. Ensure redroid image is available on nodes ───────────────── */
406
270
  await emit('[redroid] Pre-pulling redroid image on nodes...');
407
271
  try {
@@ -511,6 +375,27 @@ function createWebAndroidEmulatorAdapter(adapterOptions = {}) {
511
375
  }
512
376
  await emit('[redroid] Pod is ready');
513
377
 
378
+ /* ── 5.4. Inject custom stream index.html ──────────────────────── */
379
+ if (effectiveDomain) {
380
+ try {
381
+ const podForHtml = await runner.run(
382
+ `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}'`,
383
+ );
384
+ const htmlPodName = podForHtml.stdout.trim().replace(/'/g, '');
385
+ if (htmlPodName) {
386
+ await emit('[redroid] Injecting custom stream UI (auto-redirect, centered display)...');
387
+ const indexHtml = generateStreamIndexHtml();
388
+ const indexB64 = Buffer.from(indexHtml).toString('base64');
389
+ await runner.run(
390
+ `echo '${indexB64}' | kubectl exec -n ${namespace} ${htmlPodName} -c display -i -- sh -c 'base64 -d > /app/public/index.html' || echo '${indexB64}' | sudo kubectl exec -n ${namespace} ${htmlPodName} -c display -i -- sh -c 'base64 -d > /app/public/index.html'`,
391
+ );
392
+ await emit('[redroid] Custom stream UI injected');
393
+ }
394
+ } catch (err) {
395
+ await emit(`[redroid] Custom UI injection failed (non-fatal): ${err.message || err}`);
396
+ }
397
+ }
398
+
514
399
  /* ── 5.5. Fix Android policy routing ────────────────────────────── */
515
400
  {
516
401
  const fixPodResult = await runner.run(
@@ -690,7 +575,7 @@ exit 0'`,
690
575
  }
691
576
 
692
577
  const projectPreviewUrl = effectiveDomain
693
- ? `https://${effectiveDomain}/vnc.html?autoconnect=true&resize=scale`
578
+ ? `https://${effectiveDomain}`
694
579
  : previewUrl;
695
580
  await emit(`[redroid] Preview ready: ${projectPreviewUrl}`);
696
581
  return { url: projectPreviewUrl };