docs(deploy): document Colima build swap helper (#967)

* docs(deploy): document Colima build swap helper

Explain when Apple Silicon Colima users should prepare temporary VM swap before manual image publishing, and cover the host guard with a focused test.

* fix(deploy): harden Colima swap helper

Address review feedback by validating swap overrides, passing remote shell values safely, preserving configured fallback sizes, and expanding behavior coverage.
This commit is contained in:
VanJay 2026-05-09 02:17:22 +08:00 committed by GitHub
parent ebe3513ed4
commit 34b5b85614
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 625 additions and 0 deletions

View file

@ -64,3 +64,48 @@ The script defaults to:
If `127.0.0.1:7890` is available and no proxy is already set, the script uses it
for registry access and passes `host.docker.internal:7890` into Docker builds. The
host-gateway alias is only added for builds that need this local proxy mapping.
### Colima swap helper for Apple Silicon
`deploy/scripts/prepare-colima-build-swap.sh` is for manual Docker image
publishing from an Apple Silicon macOS host that uses Colima as the Docker VM.
The helper is intentionally Apple Silicon-only because the failure mode it covers
is local arm64 Colima builds exhausting a small Linux VM while preparing
multi-arch images. It exits before touching Colima on non-macOS or
non-Apple-Silicon hosts.
Low-memory Colima VMs can run out of RAM during multi-arch image builds. The
helper checks the VM memory and swap status, then creates and enables a temporary
swap file only when the VM has no swap and less than 4 GiB of RAM. The 4 GiB
threshold is a conservative default for short-lived manual publishes on small
Colima profiles; raise `COLIMA_BUILD_SWAP_MEMORY_THRESHOLD_KIB` if larger builds
still OOM, or lower it if you only want swap for very small VMs.
Prefer increasing the Colima VM memory (`colima start --memory <GiB>` or the
profile config) when you want a persistent build machine. Use this helper when
you need a temporary, reversible boost for one manual publish without resizing
or recreating the VM.
Run it before a manual publish if Docker builds fail with out-of-memory errors,
or if `status` shows a small Colima VM with no swap. The swap remains active
until cleanup or VM restart, so use a shell trap for one-off sessions:
```bash
deploy/scripts/prepare-colima-build-swap.sh status
deploy/scripts/prepare-colima-build-swap.sh
trap 'deploy/scripts/prepare-colima-build-swap.sh cleanup' EXIT
deploy/scripts/publish-images.sh --image_tag latest
```
Useful overrides:
```bash
COLIMA_BUILD_SWAP_SIZE=6G deploy/scripts/prepare-colima-build-swap.sh
COLIMA_BUILD_SWAP_MEMORY_THRESHOLD_KIB=6291456 deploy/scripts/prepare-colima-build-swap.sh
COLIMA_BIN=/opt/homebrew/bin/colima deploy/scripts/prepare-colima-build-swap.sh status
COLIMA_BUILD_SWAP_CLEANUP_FORCE=1 COLIMA_BUILD_SWAPFILE=/custom-swapfile deploy/scripts/prepare-colima-build-swap.sh cleanup
```
`cleanup` removes the default helper path and the old helper path. If you set a
custom `COLIMA_BUILD_SWAPFILE`, cleanup refuses to remove it unless
`COLIMA_BUILD_SWAP_CLEANUP_FORCE=1` is also set.

View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="${1:-ensure}"
SWAP_SIZE="${COLIMA_BUILD_SWAP_SIZE:-4G}"
SWAP_FILE="${COLIMA_BUILD_SWAPFILE:-/swapfile-colima-build}"
DEFAULT_SWAP_FILE="/swapfile-colima-build"
LEGACY_SWAP_FILE="/swapfile-open-design-build"
MEMORY_THRESHOLD_KIB="${COLIMA_BUILD_SWAP_MEMORY_THRESHOLD_KIB:-4194304}"
CLEANUP_FORCE="${COLIMA_BUILD_SWAP_CLEANUP_FORCE:-0}"
log() {
printf '[prepare-colima-build-swap] %s\n' "$*" >&2
}
die() {
printf '[prepare-colima-build-swap] ERROR: %s\n' "$*" >&2
exit 1
}
host_os() {
uname -s
}
host_arch() {
uname -m
}
validate_config() {
[[ "$SWAP_SIZE" =~ ^[1-9][0-9]*[GgMm]$ ]] || die "COLIMA_BUILD_SWAP_SIZE must be an integer size ending in G or M"
[[ "$SWAP_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "COLIMA_BUILD_SWAPFILE must be an absolute path using only letters, digits, dot, underscore, dash, and slash"
[[ "$MEMORY_THRESHOLD_KIB" =~ ^[0-9]+$ ]] || die "COLIMA_BUILD_SWAP_MEMORY_THRESHOLD_KIB must be an integer KiB value"
[[ "$CLEANUP_FORCE" == "0" || "$CLEANUP_FORCE" == "1" ]] || die "COLIMA_BUILD_SWAP_CLEANUP_FORCE must be 0 or 1"
}
swap_size_mib() {
local size_value="${SWAP_SIZE%[GgMm]}"
local size_unit="${SWAP_SIZE: -1}"
case "$size_unit" in
G|g)
printf '%s' $((10#$size_value * 1024))
;;
M|m)
printf '%s' $((10#$size_value))
;;
esac
}
require_apple_silicon_macos() {
[[ "$(host_os)" == "Darwin" && "$(host_arch)" == "arm64" ]] || die "prepare-colima-build-swap requires Apple Silicon macOS"
}
resolve_colima_bin() {
if [[ -n "${COLIMA_BIN:-}" ]]; then
printf '%s' "$COLIMA_BIN"
return 0
fi
if [[ "$(host_arch)" == "arm64" && -x /opt/homebrew/bin/colima ]]; then
printf '%s' /opt/homebrew/bin/colima
return 0
fi
command -v colima 2>/dev/null || true
}
usage() {
cat <<'EOF'
Usage:
prepare-colima-build-swap.sh [ensure|status|cleanup]
Commands:
ensure Create and enable a Colima VM swap file if memory is low. Default.
status Print Colima VM memory/swap status.
cleanup Disable and remove the swap file created by this script.
Environment:
COLIMA_BUILD_SWAP_SIZE=4G
COLIMA_BUILD_SWAPFILE=/swapfile-colima-build
COLIMA_BUILD_SWAP_MEMORY_THRESHOLD_KIB=4194304
COLIMA_BUILD_SWAP_CLEANUP_FORCE=0
COLIMA_BIN=/opt/homebrew/bin/colima
Run this before manual multi-arch Docker publishing on Apple Silicon Colima:
prepare-colima-build-swap.sh
<your docker buildx / image publish command>
prepare-colima-build-swap.sh cleanup
EOF
}
require_colima() {
local colima_bin="$1"
[[ -n "$colima_bin" ]] || die "colima not found; install Colima or set COLIMA_BIN"
"$colima_bin" status >/dev/null || die "Colima is not running"
}
vm_status() {
local colima_bin="$1"
"$colima_bin" ssh -- sh -lc '
set -e
awk "/^MemTotal:|^SwapTotal:/ { print }" /proc/meminfo
swapon --show || true
'
}
ensure_swap() {
local colima_bin="$1"
local memory_total_kib
local swap_total_kib
memory_total_kib="$("$colima_bin" ssh -- awk '/^MemTotal:/ { print $2; exit }' /proc/meminfo)"
swap_total_kib="$("$colima_bin" ssh -- awk '/^SwapTotal:/ { print $2; exit }' /proc/meminfo)"
[[ "$memory_total_kib" =~ ^[0-9]+$ ]] || die "failed to read Colima MemTotal"
[[ "$swap_total_kib" =~ ^[0-9]+$ ]] || die "failed to read Colima SwapTotal"
if (( 10#$swap_total_kib > 0 )); then
log "Colima already has swap (${swap_total_kib}KiB); no changes made"
vm_status "$colima_bin"
return 0
fi
if (( 10#$memory_total_kib >= 10#$MEMORY_THRESHOLD_KIB )); then
log "Colima memory is ${memory_total_kib}KiB, at or above threshold ${MEMORY_THRESHOLD_KIB}KiB; no swap needed"
vm_status "$colima_bin"
return 0
fi
local swap_size_mib_value
swap_size_mib_value="$(swap_size_mib)"
log "enabling $SWAP_SIZE swap at $SWAP_FILE for low-memory Colima (${memory_total_kib}KiB RAM)"
"$colima_bin" ssh -- sh -lc '
set -e
swapfile="$1"
swap_size="$2"
swap_size_mib="$3"
if ! swapon --show=NAME --noheadings | grep -qx "$swapfile"; then
if [ ! -f "$swapfile" ]; then
sudo fallocate -l "$swap_size" "$swapfile" 2>/dev/null || sudo dd if=/dev/zero of="$swapfile" bs=1M count="$swap_size_mib" status=progress
sudo chmod 600 "$swapfile"
sudo mkswap "$swapfile" >/dev/null
fi
sudo swapon "$swapfile"
fi
' sh "$SWAP_FILE" "$SWAP_SIZE" "$swap_size_mib_value"
vm_status "$colima_bin"
}
cleanup_swap() {
local colima_bin="$1"
if [[ "$SWAP_FILE" != "$DEFAULT_SWAP_FILE" && "$CLEANUP_FORCE" != "1" ]]; then
die "refusing to cleanup custom swap path $SWAP_FILE; set COLIMA_BUILD_SWAP_CLEANUP_FORCE=1 to remove it"
fi
log "removing swap file $SWAP_FILE from Colima if present"
"$colima_bin" ssh -- sh -lc '
set -e
for swapfile in "$@"; do
if swapon --show=NAME --noheadings | grep -qx "$swapfile"; then
sudo swapoff "$swapfile"
fi
sudo rm -f "$swapfile"
done
' sh "$SWAP_FILE" "$LEGACY_SWAP_FILE"
vm_status "$colima_bin"
}
case "$ACTION" in
-h|--help|help)
usage
exit 0
;;
ensure|status|cleanup)
;;
*)
usage >&2
die "unknown command: $ACTION"
;;
esac
validate_config
require_apple_silicon_macos
COLIMA_BIN_RESOLVED="$(resolve_colima_bin)"
require_colima "$COLIMA_BIN_RESOLVED"
case "$ACTION" in
ensure)
ensure_swap "$COLIMA_BIN_RESOLVED"
;;
status)
vm_status "$COLIMA_BIN_RESOLVED"
;;
cleanup)
cleanup_swap "$COLIMA_BIN_RESOLVED"
;;
esac

View file

@ -0,0 +1,381 @@
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { test } from "node:test";
import assert from "node:assert/strict";
const repoRoot = path.resolve(import.meta.dirname, "../..");
const scriptPath = path.join(repoRoot, "deploy/scripts/prepare-colima-build-swap.sh");
type FakeHost = {
os: string;
arch: string;
};
type VmState = {
memTotalKiB?: number;
swapTotalKiB?: number;
fallocateFails?: boolean;
activeSwapFiles?: string[];
};
type RunOptions = {
args?: string[];
env?: Record<string, string>;
host?: FakeHost;
vm?: VmState;
};
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
async function runScript({
args = ["status"],
env = {},
host = { os: "Darwin", arch: "arm64" },
vm = {},
}: RunOptions = {}) {
const tempDir = await mkdtemp(path.join(tmpdir(), "prepare-colima-build-swap-"));
const binDir = path.join(tempDir, "bin");
const remoteBinDir = path.join(tempDir, "remote-bin");
await mkdir(binDir);
await mkdir(remoteBinDir);
const statePath = path.join(tempDir, "state.env");
const commandLogPath = path.join(tempDir, "commands.log");
await writeFile(
statePath,
[
`MEM_TOTAL_KIB=${vm.memTotalKiB ?? 2097152}`,
`SWAP_TOTAL_KIB=${vm.swapTotalKiB ?? 0}`,
`FALLOCATE_FAILS=${vm.fallocateFails ? "1" : "0"}`,
`ACTIVE_SWAP=${(vm.activeSwapFiles ?? []).join(":")}`,
"DD_COUNT=",
"FALLOCATE_SIZE=",
"MKS_SWAP=",
"SWAPON_TARGET=",
"SWAPOFF_TARGETS=",
"RM_TARGETS=",
].join("\n"),
);
await writeFile(
path.join(binDir, "uname"),
[
"#!/usr/bin/env bash",
"case \"$1\" in",
` -s) printf '%s\\n' ${shellQuote(host.os)} ;;`,
` -m) printf '%s\\n' ${shellQuote(host.arch)} ;;`,
" *) exit 2 ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
await writeFile(
path.join(remoteBinDir, "awk"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`source ${shellQuote(statePath)}`,
"query=\"$*\"",
"case \"$query\" in",
" *MemTotal*SwapTotal*)",
" printf 'MemTotal: %s kB\\n' \"$MEM_TOTAL_KIB\"",
" printf 'SwapTotal: %s kB\\n' \"$SWAP_TOTAL_KIB\"",
" ;;",
" *MemTotal*) printf '%s\\n' \"$MEM_TOTAL_KIB\" ;;",
" *SwapTotal*) printf '%s\\n' \"$SWAP_TOTAL_KIB\" ;;",
" *) exit 2 ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
await writeFile(
path.join(remoteBinDir, "swapon"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`source ${shellQuote(statePath)}`,
"case \"${1:-}\" in",
" --show|--show=NAME)",
" old_ifs=\"$IFS\"",
" IFS=:",
" for item in $ACTIVE_SWAP; do",
" [ -n \"$item\" ] && printf '%s\\n' \"$item\"",
" done",
" IFS=\"$old_ifs\"",
" ;;",
" *)",
" printf 'unexpected swapon args: %s\\n' \"$*\" >&2",
" exit 2",
" ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
await writeFile(
path.join(remoteBinDir, "sudo"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`state=${shellQuote(statePath)}`,
"set_state() {",
" key=\"$1\"",
" value=\"$2\"",
" tmp=\"$state.tmp\"",
" /usr/bin/awk -F= -v key=\"$key\" -v value=\"$value\" 'BEGIN { done=0 } $1 == key { print key \"=\" value; done=1; next } { print } END { if (!done) print key \"=\" value }' \"$state\" > \"$tmp\"",
" mv \"$tmp\" \"$state\"",
"}",
"append_state() {",
" key=\"$1\"",
" value=\"$2\"",
" current=\"$(/usr/bin/awk -F= -v key=\"$key\" '$1 == key { print substr($0, length(key) + 2); exit }' \"$state\")\"",
" if [ -n \"$current\" ]; then",
" set_state \"$key\" \"$current:$value\"",
" else",
" set_state \"$key\" \"$value\"",
" fi",
"}",
"source \"$state\"",
"case \"$1\" in",
" fallocate)",
" if [ \"$FALLOCATE_FAILS\" = 1 ]; then",
" exit 1",
" fi",
" set_state FALLOCATE_SIZE \"$3\"",
" ;;",
" dd)",
" count=\"\"",
" for arg in \"$@\"; do",
" case \"$arg\" in",
" count=*) count=\"${arg#count=}\" ;;",
" esac",
" done",
" set_state DD_COUNT \"$count\"",
" ;;",
" chmod)",
" ;;",
" mkswap)",
" set_state MKS_SWAP \"$2\"",
" ;;",
" swapon)",
" set_state SWAPON_TARGET \"$2\"",
" set_state ACTIVE_SWAP \"$2\"",
" set_state SWAP_TOTAL_KIB 1",
" ;;",
" swapoff)",
" append_state SWAPOFF_TARGETS \"$2\"",
" set_state ACTIVE_SWAP \"\"",
" set_state SWAP_TOTAL_KIB 0",
" ;;",
" rm)",
" shift",
" [ \"${1:-}\" = -f ] && shift",
" for target in \"$@\"; do",
" append_state RM_TARGETS \"$target\"",
" done",
" ;;",
" *)",
" printf 'unexpected sudo command: %s\\n' \"$*\" >&2",
" exit 2",
" ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
await writeFile(
path.join(binDir, "colima"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`log=${shellQuote(commandLogPath)}`,
`remote_bin=${shellQuote(remoteBinDir)}`,
"printf '%s\\n' \"$*\" >> \"$log\"",
"if [ \"$1\" = status ]; then",
" exit 0",
"fi",
"if [ \"$1\" != ssh ]; then",
" exit 2",
"fi",
"shift",
"if [ \"${1:-}\" = -- ]; then",
" shift",
"fi",
"case \"$1\" in",
" awk)",
" PATH=\"$remote_bin:$PATH\" \"$@\"",
" ;;",
" sh)",
" shift",
" [ \"$1\" = -lc ] || exit 2",
" shift",
" script=\"$1\"",
" shift",
" env PATH=\"$remote_bin:$PATH\" bash -c \"$script\" \"$@\"",
" ;;",
" *)",
" exit 2",
" ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
const processResult = await new Promise<{ code: number | null; stderr: string }>((resolve, reject) => {
const child = spawn("bash", [scriptPath, ...args], {
env: {
...process.env,
...env,
COLIMA_BIN: path.join(binDir, "colima"),
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
},
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
resolve({ code, stderr });
});
});
let colimaLog = "";
try {
colimaLog = await readFile(commandLogPath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const state = Object.fromEntries(
(await readFile(statePath, "utf8"))
.trim()
.split("\n")
.map((line) => {
const [key, ...value] = line.split("=");
return [key, value.join("=")];
}),
);
return { ...processResult, colimaLog, state };
}
test("prepare-colima-build-swap refuses Linux hosts before checking Colima", async () => {
const result = await runScript({ args: ["status"], host: { os: "Linux", arch: "x86_64" } });
assert.equal(result.code, 1);
assert.match(result.stderr, /requires Apple Silicon macOS/);
assert.equal(result.colimaLog, "");
});
test("prepare-colima-build-swap refuses Intel macOS hosts before checking Colima", async () => {
const result = await runScript({ args: ["status"], host: { os: "Darwin", arch: "x86_64" } });
assert.equal(result.code, 1);
assert.match(result.stderr, /requires Apple Silicon macOS/);
assert.equal(result.colimaLog, "");
});
test("prepare-colima-build-swap allows Apple Silicon macOS hosts to check Colima", async () => {
const result = await runScript({ args: ["status"] });
assert.equal(result.code, 0);
assert.match(result.colimaLog, /^status\nssh -- sh -lc/m);
});
test("ensure creates and enables swap for low-memory Colima without swap", async () => {
const result = await runScript({ args: ["ensure"] });
assert.equal(result.code, 0);
assert.equal(result.state.FALLOCATE_SIZE, "4G");
assert.equal(result.state.MKS_SWAP, "/swapfile-colima-build");
assert.equal(result.state.SWAPON_TARGET, "/swapfile-colima-build");
});
test("ensure uses the configured swap size when dd fallback is needed", async () => {
const result = await runScript({
args: ["ensure"],
env: { COLIMA_BUILD_SWAP_SIZE: "6G" },
vm: { fallocateFails: true },
});
assert.equal(result.code, 0);
assert.equal(result.state.DD_COUNT, "6144");
assert.equal(result.state.SWAPON_TARGET, "/swapfile-colima-build");
});
test("ensure is a no-op when Colima already has swap", async () => {
const result = await runScript({ args: ["ensure"], vm: { swapTotalKiB: 1048576 } });
assert.equal(result.code, 0);
assert.equal(result.state.FALLOCATE_SIZE, "");
assert.equal(result.state.SWAPON_TARGET, "");
});
test("ensure is a no-op when Colima memory is above the threshold", async () => {
const result = await runScript({ args: ["ensure"], vm: { memTotalKiB: 8388608 } });
assert.equal(result.code, 0);
assert.equal(result.state.FALLOCATE_SIZE, "");
assert.equal(result.state.SWAPON_TARGET, "");
});
test("cleanup removes the default and legacy swap paths", async () => {
const result = await runScript({
args: ["cleanup"],
vm: { activeSwapFiles: ["/swapfile-colima-build"], swapTotalKiB: 1048576 },
});
assert.equal(result.code, 0);
assert.equal(result.state.SWAPOFF_TARGETS, "/swapfile-colima-build");
assert.equal(result.state.RM_TARGETS, "/swapfile-colima-build:/swapfile-open-design-build");
});
test("cleanup refuses custom swap paths unless force is enabled", async () => {
const result = await runScript({
args: ["cleanup"],
env: { COLIMA_BUILD_SWAPFILE: "/custom-swapfile" },
vm: { activeSwapFiles: ["/custom-swapfile"], swapTotalKiB: 1048576 },
});
assert.equal(result.code, 1);
assert.match(result.stderr, /refusing to cleanup custom swap path/);
assert.equal(result.state.RM_TARGETS, "");
});
test("cleanup removes custom swap paths when force is enabled", async () => {
const result = await runScript({
args: ["cleanup"],
env: { COLIMA_BUILD_SWAPFILE: "/custom-swapfile", COLIMA_BUILD_SWAP_CLEANUP_FORCE: "1" },
vm: { activeSwapFiles: ["/custom-swapfile"], swapTotalKiB: 1048576 },
});
assert.equal(result.code, 0);
assert.equal(result.state.SWAPOFF_TARGETS, "/custom-swapfile");
assert.equal(result.state.RM_TARGETS, "/custom-swapfile:/swapfile-open-design-build");
});
test("invalid swap size overrides fail before checking Colima", async () => {
const result = await runScript({ args: ["status"], env: { COLIMA_BUILD_SWAP_SIZE: "4G; touch /tmp/pwned" } });
assert.equal(result.code, 1);
assert.match(result.stderr, /COLIMA_BUILD_SWAP_SIZE must be/);
assert.equal(result.colimaLog, "");
});
test("invalid swap file overrides fail before checking Colima", async () => {
const result = await runScript({ args: ["status"], env: { COLIMA_BUILD_SWAPFILE: "/tmp/swap'file" } });
assert.equal(result.code, 1);
assert.match(result.stderr, /COLIMA_BUILD_SWAPFILE must be/);
assert.equal(result.colimaLog, "");
});