@stackframe/stack-cli 2.8.85 → 2.8.88

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.
@@ -75,12 +75,24 @@ write_files:
75
75
  # ssk/sak: required by the emulator's own dashboard (StackServerApp
76
76
  # construction throws without them). Not used by user-app flows; the
77
77
  # /local-emulator/project route mints separate per-project credentials.
78
+ #
79
+ # Snapshot-build mode (STACK_EMULATOR_BUILD_SNAPSHOT=1 in /etc/stack-build.env):
80
+ # use deterministic placeholder hex strings instead of random values. The
81
+ # built image then contains these placeholders; at every `emulator start`
82
+ # resume the host generates fresh per-install secrets and
83
+ # /usr/local/bin/rotate-secrets (inside the stack container) swaps them in.
78
84
  umask 077
79
- for key in internal-pck internal-ssk internal-sak; do
80
- if [ ! -s "/var/lib/stack-auth/$key" ]; then
81
- openssl rand -hex 32 > "/var/lib/stack-auth/$key"
82
- fi
83
- done
85
+ if [ -f /etc/stack-build.env ] && grep -q '^STACK_EMULATOR_BUILD_SNAPSHOT=1' /etc/stack-build.env 2>/dev/null; then
86
+ printf '%s' '00000000000000000000000000000000ffffffffffffffffffffffffffffffff' > /var/lib/stack-auth/internal-pck
87
+ printf '%s' '00000000000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' > /var/lib/stack-auth/internal-ssk
88
+ printf '%s' '00000000000000000000000000000000dddddddddddddddddddddddddddddddd' > /var/lib/stack-auth/internal-sak
89
+ else
90
+ for key in internal-pck internal-ssk internal-sak; do
91
+ if [ ! -s "/var/lib/stack-auth/$key" ]; then
92
+ openssl rand -hex 32 > "/var/lib/stack-auth/$key"
93
+ fi
94
+ done
95
+ fi
84
96
  INTERNAL_PCK="$(cat /var/lib/stack-auth/internal-pck)"
85
97
  INTERNAL_SSK="$(cat /var/lib/stack-auth/internal-ssk)"
86
98
  INTERNAL_SAK="$(cat /var/lib/stack-auth/internal-sak)"
@@ -92,13 +104,25 @@ write_files:
92
104
  HOST_SERVICES_HOST=10.0.2.2
93
105
  P="$STACK_EMULATOR_PORT_PREFIX"
94
106
 
107
+ # Snapshot-build mode: ship a deterministic placeholder CRON_SECRET so the
108
+ # baked VM contains a known-public value that rotate-secrets swaps out on
109
+ # every resume. Outside snapshot-build mode, leave CRON_SECRET unset so
110
+ # docker/local-emulator/entrypoint.sh generates a fresh random one.
111
+ EMULATOR_CRON_SECRET=""
112
+ if [ -f /etc/stack-build.env ] && grep -q '^STACK_EMULATOR_BUILD_SNAPSHOT=1' /etc/stack-build.env 2>/dev/null; then
113
+ EMULATOR_CRON_SECRET="00000000000000000000000000000000cccccccccccccccccccccccccccccccc"
114
+ fi
115
+
95
116
  {
96
117
  # Static vars from base config and runtime (e.g. API keys, feature flags)
97
118
  cat /mnt/stack-runtime/base.env
98
119
  cat /mnt/stack-runtime/runtime.env
99
- printf 'STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=%s\n' "$INTERNAL_PCK"
100
- printf 'STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=%s\n' "$INTERNAL_SSK"
120
+ printf 'STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=%s\n' "$INTERNAL_PCK"
121
+ printf 'STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY=%s\n' "$INTERNAL_SSK"
101
122
  printf 'STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=%s\n' "$INTERNAL_SAK"
123
+ if [ -n "$EMULATOR_CRON_SECRET" ]; then
124
+ printf 'CRON_SECRET=%s\n' "$EMULATOR_CRON_SECRET"
125
+ fi
102
126
 
103
127
  # Computed vars — depend on port prefix or deps host
104
128
  # Host-side ports (for browser URLs — browser runs on host, not in VM)
@@ -106,6 +130,11 @@ write_files:
106
130
  HP_DASHBOARD="$STACK_EMULATOR_DASHBOARD_HOST_PORT"
107
131
  HP_MINIO="$STACK_EMULATOR_MINIO_HOST_PORT"
108
132
  HP_INBUCKET="$STACK_EMULATOR_INBUCKET_HOST_PORT"
133
+ # Mock OAuth binds to this port inside the VM and the host forwards the
134
+ # same port through, so the OIDC issuer URL is reachable identically
135
+ # from the browser and from the backend. Falls back to ${P}14 for
136
+ # older ISOs that don't set it.
137
+ HP_MOCK_OAUTH="${STACK_EMULATOR_MOCK_OAUTH_HOST_PORT:-${P}14}"
109
138
 
110
139
  cat <<COMPUTED
111
140
  STACK_SKIP_MIGRATIONS=true
@@ -129,7 +158,8 @@ write_files:
129
158
  STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123
130
159
  STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${HP_DASHBOARD}/handler/email-verification
131
160
  STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
132
- STACK_OAUTH_MOCK_URL=http://localhost:${P}14
161
+ STACK_OAUTH_MOCK_URL=http://localhost:${HP_MOCK_OAUTH}
162
+ STACK_OAUTH_MOCK_PORT=${HP_MOCK_OAUTH}
133
163
  STACK_FREESTYLE_API_ENDPOINT=http://${DEPS_HOST}:8180
134
164
  STACK_STRIPE_MOCK_PORT=12111
135
165
  NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator
@@ -142,15 +172,46 @@ write_files:
142
172
  permissions: '0755'
143
173
  content: |
144
174
  #!/bin/bash
145
- set -euo pipefail
175
+ # Mount the host filesystem at /host. Two modes:
176
+ # (no args) — cold-boot: bind /host on itself, make it a shared
177
+ # mount point, then mount virtio-9p on top. The
178
+ # bind+shared step is what lets the docker bind
179
+ # mount (-v /host:/host:rshared) receive later
180
+ # propagation events.
181
+ # --post-resume — snapshot-resume: /host is already shared (set up
182
+ # at build time and preserved across the snapshot,
183
+ # plus the docker bind mount has rshared
184
+ # propagation). The host has just hot-plugged
185
+ # virtio-9p; mount it on /host and the new mount
186
+ # propagates into the running container.
187
+ set -uo pipefail
146
188
  mkdir -p /host
189
+
190
+ # Idempotent: bind /host on itself once so it becomes a mount point
191
+ # with its own propagation, then make it shared. mount --make-shared
192
+ # requires a mount point, hence the bind first.
147
193
  if ! mountpoint -q /host; then
148
- if ! mount -t 9p -o trans=virtio,version=9p2000.L hostfs /host; then
149
- echo "Failed to mount host filesystem at /host" >&2
150
- exit 1
194
+ mount --bind /host /host
195
+ fi
196
+ mount --make-shared /host
197
+
198
+ if [ "${1:-}" = "--post-resume" ]; then
199
+ if mount -t 9p -o trans=virtio,version=9p2000.L hostfs /host; then
200
+ exit 0
151
201
  fi
202
+ echo "post-resume 9p mount failed" >&2
203
+ exit 1
152
204
  fi
153
205
 
206
+ # Cold boot. In snapshot-build mode the host detaches virtfs (QEMU
207
+ # disallows migration while it's mounted), so the 9p mount may not be
208
+ # available — tolerate that and fall through to an empty /host.
209
+ if mount -t 9p -o trans=virtio,version=9p2000.L hostfs /host 2>/dev/null; then
210
+ exit 0
211
+ fi
212
+ echo "host filesystem unavailable; continuing with empty /host" >&2
213
+ exit 0
214
+
154
215
  - path: /usr/local/bin/run-stack-container
155
216
  permissions: '0755'
156
217
  content: |
@@ -190,7 +251,7 @@ write_files:
190
251
  -v stack-clickhouse-data:/data/clickhouse \
191
252
  -v stack-minio-data:/data/minio \
192
253
  -v stack-inbucket-data:/data/inbucket \
193
- -v /host:/host \
254
+ -v /host:/host:rshared \
194
255
  stack-local-emulator 2>&1 | tee -a "$host_log"
195
256
  else
196
257
  exec docker run \
@@ -204,7 +265,7 @@ write_files:
204
265
  -v stack-clickhouse-data:/data/clickhouse \
205
266
  -v stack-minio-data:/data/minio \
206
267
  -v stack-inbucket-data:/data/inbucket \
207
- -v /host:/host \
268
+ -v /host:/host:rshared \
208
269
  stack-local-emulator
209
270
  fi
210
271
 
@@ -441,8 +502,8 @@ write_files:
441
502
  --network host \
442
503
  --env-file /etc/stack-build.env \
443
504
  --env-file /etc/stack-build-computed.env \
444
- -e STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY="$SMOKE_PCK" \
445
- -e STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY="$SMOKE_SSK" \
505
+ -e STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY="$SMOKE_PCK" \
506
+ -e STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY="$SMOKE_SSK" \
446
507
  -e STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY="$SMOKE_SAK" \
447
508
  -e STACK_SKIP_MIGRATIONS=true \
448
509
  -e STACK_SKIP_SEED_SCRIPT=true \
@@ -522,6 +583,74 @@ write_files:
522
583
  fstrim -av 2>/dev/null || true
523
584
  log "slim-docker-image done."
524
585
 
586
+ - path: /usr/local/bin/wait-for-stack-ready
587
+ permissions: '0755'
588
+ content: |
589
+ #!/bin/bash
590
+ # Poll the stack container's backend + dashboard on the guest's own
591
+ # localhost until both respond healthy. Used at snapshot-build time to
592
+ # gate "emit STACK_SERVICES_READY" on the app actually being warm.
593
+ set -uo pipefail
594
+
595
+ TIMEOUT="${STACK_READY_TIMEOUT:-600}"
596
+ BACKEND_PORT="${STACK_READY_BACKEND_PORT:-8102}"
597
+ DASHBOARD_PORT="${STACK_READY_DASHBOARD_PORT:-8101}"
598
+
599
+ log() { /usr/local/bin/log-provision "wait-for-stack-ready: $*"; }
600
+
601
+ start=$SECONDS
602
+ next_heartbeat=$((start + 30))
603
+ log "waiting for backend:$BACKEND_PORT and dashboard:$DASHBOARD_PORT (timeout=${TIMEOUT}s)"
604
+ while true; do
605
+ backend_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:${BACKEND_PORT}/health?db=1" 2>/dev/null || true)
606
+ dashboard_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:${DASHBOARD_PORT}/handler/sign-in" 2>/dev/null || true)
607
+ if [ "$backend_code" = "200" ] && [ "$dashboard_code" = "200" ]; then
608
+ log "ready ($((SECONDS - start))s)"
609
+ exit 0
610
+ fi
611
+ if [ "$SECONDS" -ge "$next_heartbeat" ]; then
612
+ log "still waiting (backend=$backend_code dashboard=$dashboard_code, $((SECONDS - start))s elapsed)"
613
+ next_heartbeat=$((SECONDS + 30))
614
+ fi
615
+ if [ "$((SECONDS - start))" -ge "$TIMEOUT" ]; then
616
+ log "TIMEOUT after $((SECONDS - start))s (backend=$backend_code dashboard=$dashboard_code)"
617
+ docker ps -a 2>&1 | /usr/local/bin/log-provision-stream "wait-for-stack-ready: ps" || true
618
+ docker logs --tail 200 stack 2>&1 | /usr/local/bin/log-provision-stream "wait-for-stack-ready: stack" || true
619
+ systemctl status stack.service --no-pager -l 2>&1 | /usr/local/bin/log-provision-stream "wait-for-stack-ready: svc" || true
620
+ journalctl -u stack.service --no-pager -n 100 2>&1 | /usr/local/bin/log-provision-stream "wait-for-stack-ready: jrnl" || true
621
+ docker image ls 2>&1 | /usr/local/bin/log-provision-stream "wait-for-stack-ready: img" || true
622
+ exit 1
623
+ fi
624
+ sleep 2
625
+ done
626
+
627
+ - path: /usr/local/bin/trigger-fast-rotate
628
+ permissions: '0755'
629
+ content: |
630
+ #!/bin/bash
631
+ # Called via qemu-guest-agent on every snapshot resume. Reads fresh
632
+ # secrets from stdin (key=value lines, written by the host via QGA's
633
+ # guest-exec input-data) and execs rotate-secrets inside the stack
634
+ # container with those values exported.
635
+ set -euo pipefail
636
+
637
+ tmp="$(mktemp /var/run/stack-fresh-XXXXXX.env)"
638
+ cat > "$tmp"
639
+ chmod 0600 "$tmp"
640
+
641
+ # shellcheck disable=SC1090
642
+ set -a
643
+ source "$tmp"
644
+ set +a
645
+ rm -f "$tmp"
646
+
647
+ exec docker exec \
648
+ -e STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY \
649
+ -e STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY \
650
+ -e STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY \
651
+ -e CRON_SECRET \
652
+ stack /usr/local/bin/rotate-secrets
653
+
525
654
  - path: /etc/systemd/system/stack.service
526
655
  content: |
527
656
  [Unit]
@@ -591,6 +720,14 @@ write_files:
591
720
  systemctl disable --now ssh || true
592
721
  systemctl mask ssh || true
593
722
 
723
+ # qemu-guest-agent: used by the host to inject fresh secrets + trigger
724
+ # rotate-secrets after a snapshot resume. Must be running INSIDE the VM
725
+ # at snapshot capture time — the virtio-serial port's "open" state is
726
+ # part of the migrated device state. If QGA wasn't connected at capture,
727
+ # the resumed VM's port stays closed and the host can't reach it.
728
+ systemctl enable qemu-guest-agent || true
729
+ systemctl start qemu-guest-agent || true
730
+
594
731
  log_provision "installing emulator containers"
595
732
  bash /usr/local/bin/install-emulator-containers
596
733
 
@@ -603,6 +740,53 @@ write_files:
603
740
  log_provision "starting slim-docker-image"
604
741
  bash /usr/local/bin/slim-docker-image
605
742
 
743
+ # Capture mode: bring the stack container up, wait for full
744
+ # readiness, emit STACK_SERVICES_READY, then wait indefinitely for the
745
+ # host build script to capture VM state over QMP (stop + migrate + quit).
746
+ # The VM never shuts itself down in this path — the host tears it down
747
+ # once the savevm file has been written.
748
+ #
749
+ # CI never sets STACK_EMULATOR_CAPTURE_SAVEVM=1 (snapshots aren't
750
+ # portable across accelerators, so they're captured locally on first
751
+ # `stack emulator pull`). This branch only fires for opt-in local
752
+ # builds run with EMULATOR_CAPTURE_SAVEVM=1.
753
+ if [ -f /etc/stack-build.env ] && grep -q '^STACK_EMULATOR_CAPTURE_SAVEVM=1' /etc/stack-build.env 2>/dev/null; then
754
+ log_provision "capture mode: starting stack.service"
755
+ systemctl start stack.service || true
756
+
757
+ log_provision "waiting for backend + dashboard to be ready"
758
+ if ! /usr/local/bin/wait-for-stack-ready; then
759
+ log_provision "ERROR: stack services did not become ready"
760
+ exit 1
761
+ fi
762
+
763
+ # Ensure qemu-guest-agent is running so its virtio-serial port stays
764
+ # "open" in the snapshot — the host needs that port at runtime to
765
+ # trigger rotate-secrets.
766
+ log_provision "ensuring qemu-guest-agent is up"
767
+ systemctl restart qemu-guest-agent || true
768
+ sleep 2
769
+ if ! systemctl is-active --quiet qemu-guest-agent; then
770
+ log_provision "ERROR: qemu-guest-agent failed to start"
771
+ systemctl status qemu-guest-agent --no-pager -l 2>&1 | /usr/local/bin/log-provision-stream "qga"
772
+ exit 1
773
+ fi
774
+ log_provision "qemu-guest-agent active"
775
+
776
+ log_provision "services ready; signalling STACK_SERVICES_READY"
777
+ if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then
778
+ printf '%s\n' "STACK_SERVICES_READY" >> "$STACK_PROVISION_LOG_FILE"
779
+ fi
780
+ write_marker_to_consoles "STACK_SERVICES_READY"
781
+ sync || true
782
+
783
+ # Clear the EXIT trap so the cleanup path doesn't mark this as failed
784
+ # when the host powers us off via QMP quit.
785
+ trap - EXIT
786
+ # Block forever; host will issue qmp quit after migrate completes.
787
+ while true; do sleep 3600; done
788
+ fi
789
+
606
790
  log_provision "build pipeline complete"
607
791
  if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then
608
792
  printf '%s\n' "STACK_CLOUD_INIT_DONE" >> "$STACK_PROVISION_LOG_FILE"
@@ -68,3 +68,142 @@ make_iso_from_dir() {
68
68
  exit 1
69
69
  fi
70
70
  }
71
+
72
+ # Send one or more QMP commands over the monitor socket. Stdin is a stream of
73
+ # JSON objects; qmp_capabilities is always sent first to exit negotiation mode.
74
+ # Keep stdin open briefly after writing so socat doesn't close before QEMU
75
+ # responds — QMP replies in milliseconds so 0.5s is plenty.
76
+ #
77
+ # Callers: build-image.sh capture flow, run-emulator.sh cmd_capture.
78
+ qmp_session() {
79
+ local sock="$1"
80
+ local payload
81
+ payload="$(cat)"
82
+ ( printf '%s\n' "$payload"; sleep 0.5 ) | socat -t30 - "UNIX-CONNECT:${sock}"
83
+ }
84
+
85
+ # Drive the snapshot capture over QMP:
86
+ # 1. qmp_capabilities — exit negotiation mode.
87
+ # 2. stop — pause the VM so no more disk writes happen.
88
+ # 3. migrate-set-capabilities — enable mapped-ram + multifd for fast resume.
89
+ # 4. migrate to file:<path> — streams RAM/device state out.
90
+ # 5. Poll query-migrate until status=completed (or failed).
91
+ # 6. quit — terminate QEMU cleanly.
92
+ #
93
+ # Depends on log/err/warn being defined by the sourcing script.
94
+ capture_vm_state() {
95
+ local sock="$1"
96
+ local guest_path="$2"
97
+
98
+ if [ ! -S "$sock" ]; then
99
+ err "QMP monitor socket missing: $sock"
100
+ return 1
101
+ fi
102
+
103
+ log " QMP: stopping VM..."
104
+ {
105
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
106
+ printf '%s\n' '{"execute":"stop"}'
107
+ } | qmp_session "$sock" >/dev/null || {
108
+ err "QMP stop failed"
109
+ return 1
110
+ }
111
+
112
+ log " QMP: enabling mapped-ram + multifd for fast resume..."
113
+ # mapped-ram: writes each RAM page to a fixed offset in the output file
114
+ # (vs the legacy streamed format). This lets the target QEMU mmap the file
115
+ # and fault pages lazily — and combined with multifd, load RAM in parallel.
116
+ # multifd-channels=4 matches our pinned SMP so the channels don't starve
117
+ # each other on the target's 4 vCPUs.
118
+ local caps_cmd params_cmd
119
+ caps_cmd='{"execute":"migrate-set-capabilities","arguments":{"capabilities":[{"capability":"mapped-ram","state":true},{"capability":"multifd","state":true}]}}'
120
+ params_cmd='{"execute":"migrate-set-parameters","arguments":{"multifd-channels":4}}'
121
+ local setup_resp
122
+ setup_resp=$({
123
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
124
+ printf '%s\n' "$caps_cmd"
125
+ printf '%s\n' "$params_cmd"
126
+ } | qmp_session "$sock") || {
127
+ err "QMP capabilities setup failed"
128
+ return 1
129
+ }
130
+ if printf '%s' "$setup_resp" | grep -q '"error"[[:space:]]*:'; then
131
+ err "QMP capabilities returned error: $setup_resp"
132
+ return 1
133
+ fi
134
+
135
+ log " QMP: migrating RAM state to ${guest_path}..."
136
+ # Use file: migration (native QEMU) instead of exec: to avoid relying on a
137
+ # spawned shell finding zstd in PATH. Compressed as a separate host step
138
+ # after migrate completes.
139
+ local migrate_cmd
140
+ migrate_cmd=$(printf '{"execute":"migrate","arguments":{"uri":"file:%s"}}' "$guest_path")
141
+ local migrate_resp
142
+ migrate_resp=$({
143
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
144
+ printf '%s\n' "$migrate_cmd"
145
+ } | qmp_session "$sock") || {
146
+ err "QMP migrate failed"
147
+ return 1
148
+ }
149
+ if printf '%s' "$migrate_resp" | grep -q '"error"[[:space:]]*:'; then
150
+ err "QMP migrate returned error: $migrate_resp"
151
+ return 1
152
+ fi
153
+
154
+ # Poll migration status. Migration runs in the background after the
155
+ # migrate command returns; we watch for "completed" or "failed".
156
+ local migrate_timeout=600
157
+ local waited=0
158
+ local last_heartbeat=0
159
+ while [ "$waited" -lt "$migrate_timeout" ]; do
160
+ local status_line status
161
+ status_line=$({
162
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
163
+ printf '%s\n' '{"execute":"query-migrate"}'
164
+ } | qmp_session "$sock" 2>/dev/null || true)
165
+ status="$(printf '%s\n' "$status_line" | grep -o '"status"[[:space:]]*:[[:space:]]*"[a-z-]*"' | head -1 | sed -E 's/.*"([a-z-]+)".*/\1/')"
166
+ case "$status" in
167
+ completed)
168
+ log " QMP: migrate completed (${waited}s)"
169
+ break
170
+ ;;
171
+ failed|cancelled)
172
+ err " QMP: migrate ended with status=$status"
173
+ err " QMP response: $status_line"
174
+ return 1
175
+ ;;
176
+ active|setup|device|"")
177
+ # still running
178
+ if [ "$((waited - last_heartbeat))" -ge 30 ]; then
179
+ local transferred
180
+ transferred=$(printf '%s' "$status_line" | grep -o '"transferred"[[:space:]]*:[[:space:]]*[0-9]*' | head -1 | sed -E 's/.*:[[:space:]]*([0-9]+).*/\1/')
181
+ log " QMP: migrate in progress (${waited}s, status=${status:-init}, transferred=${transferred:-0})"
182
+ last_heartbeat=$waited
183
+ fi
184
+ ;;
185
+ *)
186
+ log " QMP: migrate status=$status (${waited}s)"
187
+ ;;
188
+ esac
189
+ sleep 2
190
+ waited=$((waited + 2))
191
+ done
192
+
193
+ if [ "$waited" -ge "$migrate_timeout" ]; then
194
+ err "QMP migrate timed out after ${migrate_timeout}s"
195
+ err "Last query-migrate response: $({
196
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
197
+ printf '%s\n' '{"execute":"query-migrate"}'
198
+ } | qmp_session "$sock" 2>/dev/null || true)"
199
+ return 1
200
+ fi
201
+
202
+ log " QMP: quitting VM..."
203
+ {
204
+ printf '%s\n' '{"execute":"qmp_capabilities"}'
205
+ printf '%s\n' '{"execute":"quit"}'
206
+ } | qmp_session "$sock" >/dev/null || true
207
+
208
+ return 0
209
+ }