diff --git a/CHANGELOG.md b/CHANGELOG.md index f1da1e9..fa9575c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Added VM config validation before Lima template rendering for VM names, users, sizing, code directories, host IPs, and port forwards. +- Internal: split `bin/dvm` into a small dispatcher plus sourced shell libraries under + `share/dvm/lib`. - Added `dvm cp` to copy files between the host and a DVM VM through Lima, with relative guest paths resolved under `DVM_CODE_DIR`. - Fixed `dvm cp` without copy options on macOS Bash 3.2. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fa42c2e..7c2ca6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,8 @@ # Contributing -DVM is intentionally small. Keep the wrapper boring and put setup behavior in recipes -or docs unless the wrapper truly has to bridge host config to Lima. +DVM is intentionally small. Keep `bin/dvm` as a small dispatcher, put host-side wrapper +helpers in `share/dvm/lib`, and put setup behavior in recipes or docs unless the +wrapper truly has to bridge host config to Lima. Good fits: diff --git a/README.md b/README.md index 74a1a25..827cec6 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Install the wrapper: This installs a small launcher into `~/.local/bin` and copies defaults into `~/.config/dvm` without overwriting existing files. The launcher runs each invocation -from a temporary snapshot of `bin/dvm`, so editing or pulling this repo cannot corrupt a -long-running `dvm apply`. Bundled recipes, the Lima template, and example VM configs -stay in the repo under `share/dvm`. +from a temporary snapshot of `bin/dvm` and its shell libraries, so editing or pulling +this repo cannot corrupt a long-running `dvm apply`. Bundled recipes, the Lima +template, and example VM configs stay in the repo under `share/dvm`. ## Commands diff --git a/bin/dvm b/bin/dvm index 02ba23d..043419a 100755 --- a/bin/dvm +++ b/bin/dvm @@ -18,1049 +18,27 @@ dvm_script_dir() { DVM_ROOT="${DVM_ROOT:-$(cd "$(dvm_script_dir)/.." && pwd)}" DVM_SHARE="${DVM_SHARE:-$DVM_ROOT/share/dvm}" DVM_CONFIG="${DVM_CONFIG:-$HOME/.config/dvm}" +DVM_LIB_DIR="${DVM_LIB_DIR:-$DVM_SHARE/lib}" -usage() { - cat <<'HELP' -usage: - dvm init [template] - dvm apply - dvm apply --all - dvm enter - dvm ssh -- - dvm cp [-r] [-v] [--backend auto|scp|rsync] - dvm logs [unit] [journalctl-args...] - dvm ssh-key - dvm gpg-key - dvm list - dvm stop - dvm rm --yes [--force] -HELP -} - -die() { - printf 'dvm: error: %s\n' "$*" >&2 +[ -d "$DVM_LIB_DIR" ] || { + printf 'dvm: error: missing library directory: %s\n' "$DVM_LIB_DIR" >&2 exit 1 } -safe_name() { - case "${1:-}" in - '' | *[!a-z0-9-]* | -*) return 1 ;; - [a-z]*) return 0 ;; - *) return 1 ;; - esac -} - -public_vm_name() { - local name="$1" - case "$name" in - dvm-*) name="${name#dvm-}" ;; - esac - safe_name "$name" || die "unsafe VM name: $1" - printf '%s\n' "$name" -} - -guest_term() { - case "${TERM:-}" in - '' | xterm-ghostty | ghostty) printf '%s\n' xterm-256color ;; - *) printf '%s\n' "$TERM" ;; - esac -} - -validate_identifier() { - local label="$1" - local value="$2" - case "$value" in - '' | [.-]* | *[!A-Za-z0-9._-]*) die "invalid $label: $value" ;; - esac -} - -validate_lima_name() { - case "$1" in - '' | *[!a-z0-9-]* | -*) die "invalid DVM_LIMA_NAME: $1" ;; - esac -} - -validate_positive_int() { - local label="$1" - local value="$2" - case "$value" in - '' | *[!0-9]*) die "invalid $label: $value" ;; - esac - [ "$value" -gt 0 ] || die "invalid $label: $value" -} - -validate_lima_size() { - local label="$1" - local value="$2" - case "$value" in - '' | [!0-9]* | *[!A-Za-z0-9._+-]*) die "invalid $label: $value" ;; - esac -} - -validate_guest_path() { - local label="$1" - local value="$2" - local newline carriage - newline=$'\n' - carriage=$'\r' - case "$value" in - '' | *"$newline"* | *"$carriage"* | *'"'* | *'`'* | *\\* | *'$'*) die "invalid $label: $value" ;; - esac -} - -validate_cloudflared_token() { - case "$1" in - *[!A-Za-z0-9._=-]*) die "invalid cloudflared token characters" ;; - esac -} - -dvm_endpoint_name() { - local name="$1" - case "$name" in - dvm-*) name="${name#dvm-}" ;; - esac - safe_name "$name" || return 1 - printf '%s\n' "$name" -} - -open_editor() { - local file="$1" - local editor="${EDITOR:-${VISUAL:-vi}}" - [ -n "$editor" ] || editor="vi" - # shellcheck disable=SC2086 - $editor "$file" -} - -init_vm() { - local name template src dst - name="${1:-}" - [ -n "$name" ] || die "init requires a VM name" - template="${2:-app}" - name="$(public_vm_name "$name")" - safe_name "$template" || die "unsafe template name: $template" - src="$DVM_SHARE/vms/$template.sh" - [ -f "$src" ] || die "missing VM template: $template" - dst="$DVM_CONFIG/vms/$name.sh" - if [ -e "$dst" ]; then - printf 'dvm: VM config already exists: %s\n' "$dst" >&2 - else - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" - printf 'dvm: created VM config: %s\n' "$dst" - fi - open_editor "$dst" -} - -recipe_file() { - local name="$1" - local path - path="$DVM_CONFIG/recipes/$name.sh" - if [ -f "$path" ]; then - printf '%s\n' "$path" - return 0 - fi - path="$DVM_SHARE/recipes/$name.sh" - if [ -f "$path" ]; then - printf '%s\n' "$path" - return 0 - fi - die "no recipe: $name" -} - -use() { - local name="${1:-}" - [ -n "$name" ] || die "use requires a recipe name" - recipe_file "$name" >/dev/null - DVM_RECIPES+=("$name") -} - -uses_recipe() { - local recipe - for recipe in "${DVM_RECIPES[@]}"; do - [ "$recipe" = "$1" ] && return 0 - done - return 1 -} - -load_vm() { - local name="$1" - local vm_file - name="$(public_vm_name "$name")" - - reset_vm_vars - - # shellcheck source=/dev/null - [ -f "$DVM_SHARE/config.sh" ] && source "$DVM_SHARE/config.sh" - # shellcheck source=/dev/null - [ -f "$DVM_CONFIG/config.sh" ] && source "$DVM_CONFIG/config.sh" - - DVM_NAME="$name" - DVM_LIMA_NAME="${DVM_LIMA_NAME:-dvm-$name}" - DVM_CODE_ROOT="${DVM_CODE_ROOT:-~/code}" - DVM_CODE_DIR="${DVM_CODE_DIR:-${DVM_CODE_ROOT%/}/$name}" - DVM_PORTS="${DVM_PORTS:-}" - DVM_ARCH="${DVM_ARCH:-default}" - DVM_AI_AGENT_USER="${DVM_AI_AGENT_USER:-dvm-agent}" - - vm_file="$DVM_CONFIG/vms/$name.sh" - [ -f "$vm_file" ] || die "missing VM config: $vm_file" - # shellcheck source=/dev/null - source "$vm_file" - - DVM_CODE_DIR="${DVM_CODE_DIR:-${DVM_CODE_ROOT%/}/$name}" - DVM_PORTS="${DVM_PORTS:-}" - DVM_HOST_IP="${DVM_HOST_IP:-127.0.0.1}" - DVM_LLAMA_SERVICE="${DVM_LLAMA_SERVICE:-dvm-llama.service}" - DVM_CLOUDFLARED_SERVICE="${DVM_CLOUDFLARED_SERVICE:-dvm-cloudflared.service}" - resolve_arch - validate_vm_config -} - -reset_vm_vars() { - local var - while IFS= read -r var; do - case "$var" in - DVM_ROOT | DVM_SHARE | DVM_CONFIG | DVM_FAKE_STATE) ;; - DVM_*) unset "$var" || true ;; - esac - done < <(compgen -A variable) - DVM_RECIPES=() -} - -resolve_arch() { - if [ "${DVM_ARCH:-default}" != "default" ]; then - return 0 - fi - case "$(uname -m)" in - arm64 | aarch64) DVM_ARCH="aarch64" ;; - x86_64 | amd64) DVM_ARCH="x86_64" ;; - *) die "cannot resolve DVM_ARCH=default for $(uname -m)" ;; - esac -} - -validate_vm_config() { - local item - validate_lima_name "$DVM_LIMA_NAME" - validate_identifier DVM_USER "$DVM_USER" - validate_identifier DVM_AI_AGENT_USER "$DVM_AI_AGENT_USER" - case "$DVM_ARCH" in - aarch64 | x86_64) ;; - *) die "invalid DVM_ARCH: $DVM_ARCH" ;; - esac - validate_positive_int DVM_CPUS "$DVM_CPUS" - validate_lima_size DVM_MEMORY "$DVM_MEMORY" - validate_lima_size DVM_DISK "$DVM_DISK" - validate_guest_path DVM_CODE_DIR "$DVM_CODE_DIR" - validate_host_ip "$DVM_HOST_IP" - for item in ${DVM_PORTS:-}; do - normalize_port "$item" >/dev/null - done -} - -render_ports() { - local item host_ip host_port guest_port - for item in ${DVM_PORTS:-}; do - IFS=: read -r host_ip host_port guest_port </dev/null || true) - printf '%s\n' "${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME" -} - -update_port_forwards() { - local actual desired dir expr - dir="$(vm_dir)" - desired="$(configured_ports_canonical | paste -sd' ' -)" - actual="$(ports_canonical_from_yaml "$dir/lima.yaml" | paste -sd' ' -)" - [ "$desired" = "$actual" ] && return 0 - printf 'dvm: updating port forwards for %s\n' "$DVM_LIMA_NAME" >&2 - expr="$(port_set_expr)" - limactl stop "$DVM_LIMA_NAME" >/dev/null 2>&1 || true - limactl edit --tty=false --set "$expr" --start "$DVM_LIMA_NAME" >/dev/null -} - -render_template() { - local template="$1" - if command -v envsubst >/dev/null 2>&1; then - envsubst <"$template" - else - perl -pe 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/exists $ENV{$1} ? $ENV{$1} : $&/ge' "$template" - fi -} - -vm_exists() { - vm_listed || vm_local_config_exists -} - -vm_listed() { - limactl list --format '{{.Name}}' 2>/dev/null | grep -Fxq "$DVM_LIMA_NAME" -} - -vm_local_config_exists() { - [ -f "${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME/lima.yaml" ] -} - -start_vm() { - if limactl start "$DVM_LIMA_NAME"; then - return 0 - fi - if ! vm_listed && vm_local_config_exists; then - die "Lima instance directory exists but limactl cannot start $DVM_LIMA_NAME; inspect or remove ${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME" - fi - return 1 -} - -ensure_vm() { - local create_output template tmp tmp_dir - if ! vm_exists; then - template="$DVM_CONFIG/lima.yaml.in" - [ -f "$template" ] || template="$DVM_SHARE/lima.yaml.in" - [ -f "$template" ] || die "missing Lima template" - tmp_dir="${TMPDIR:-/tmp}" - tmp="$(mktemp "${tmp_dir%/}/dvm-lima.XXXXXX")" - DVM_PORT_FORWARDS_YAML="$(render_ports)" - export DVM_NAME DVM_LIMA_NAME DVM_ARCH DVM_CPUS DVM_MEMORY DVM_DISK - export DVM_USER DVM_CODE_DIR DVM_PORT_FORWARDS_YAML - render_template "$template" >"$tmp" - if ! create_output="$(limactl create --name "$DVM_LIMA_NAME" --tty=false "$tmp" 2>&1)"; then - rm -f "$tmp" - case "$create_output" in - *"already exists"*) - update_port_forwards - ;; - *) - printf '%s\n' "$create_output" >&2 - return 1 - ;; - esac - else - rm -f "$tmp" - fi - else - update_port_forwards - fi - start_vm -} - -run_guest_apply() { - local cloudflared_token helper recipe path var - local -a args - args=() - cloudflared_token="" - if uses_recipe cloudflared; then - cloudflared_token="${DVM_CLOUDFLARED_TOKEN:-${CLOUDFLARED_TOKEN:-}}" - [ -z "$cloudflared_token" ] || validate_cloudflared_token "$cloudflared_token" - fi - while IFS= read -r var; do - case "$var" in - DVM_ROOT | DVM_SHARE | DVM_CONFIG | DVM_FAKE_STATE | DVM_LIMA_NAME | DVM_RECIPES | DVM_NO_BASELINE | DVM_PORT_FORWARDS_YAML) continue ;; - DVM_CLOUDFLARED_TOKEN | DVM_CLOUDFLARED_TOKEN_FILE) continue ;; - DVM_*) args+=("$var=${!var}") ;; - esac - done < <(compgen -A variable | sort) - - printf 'dvm: applying recipes for %s:' "$DVM_NAME" >&2 - if [ "${DVM_NO_BASELINE:-0}" != "1" ]; then - printf ' baseline' >&2 - fi - if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then - for recipe in "${DVM_RECIPES[@]}"; do - printf ' %s' "$recipe" >&2 - done - fi - printf '\n' >&2 - - { - cat <<'DVM_HOSTNAME' -# dvm hostname -if [ -n "${DVM_NAME:-}" ] && command -v hostnamectl >/dev/null 2>&1; then - current_hostname="$(hostname 2>/dev/null || true)" - if [ "$current_hostname" != "$DVM_NAME" ]; then - sudo hostnamectl set-hostname "$DVM_NAME" - fi -fi - -DVM_HOSTNAME - helper="$DVM_SHARE/recipes/_helpers.sh" - if [ -f "$helper" ]; then - printf '# dvm recipe helpers\n' - cat "$helper" - fi - if [ "${DVM_NO_BASELINE:-0}" != "1" ]; then - cat "$(recipe_file baseline)" - fi - if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then - for recipe in "${DVM_RECIPES[@]}"; do - path="$(recipe_file "$recipe")" - if [ "$recipe" = "cloudflared" ]; then - emit_cloudflared_token_file "$cloudflared_token" - fi - printf '\n# dvm recipe: %s\n' "$recipe" - cat "$path" - done - fi - cat <<'DVM_PROJECT_HOOK' - -# dvm project hook -dvm_code_dir="${DVM_CODE_DIR%/}" -case "$dvm_code_dir" in - "~") dvm_code_dir="$HOME" ;; - "~/"*) dvm_code_dir="$HOME/${dvm_code_dir#\~/}" ;; -esac -if [ -f "$dvm_code_dir/.dvm/apply.sh" ]; then - bash "$dvm_code_dir/.dvm/apply.sh" -fi -DVM_PROJECT_HOOK - } | limactl shell "$DVM_LIMA_NAME" env "${args[@]}" bash -s -} - -emit_cloudflared_token_file() { - local token="$1" - [ -n "$token" ] || return 0 - cat <<'DVM_CLOUDFLARED_TOKEN_SETUP' -# dvm cloudflared token file -dvm_cloudflared_token_file="$(mktemp "${TMPDIR:-/tmp}/dvm-cloudflared-token.XXXXXX")" -chmod 600 "$dvm_cloudflared_token_file" -cat >"$dvm_cloudflared_token_file" <<'DVM_CLOUDFLARED_TOKEN' -DVM_CLOUDFLARED_TOKEN_SETUP - printf '%s\n' "$token" - cat <<'DVM_CLOUDFLARED_TOKEN_SETUP' -DVM_CLOUDFLARED_TOKEN -export DVM_CLOUDFLARED_TOKEN_FILE="$dvm_cloudflared_token_file" - -DVM_CLOUDFLARED_TOKEN_SETUP -} - -apply_one() { - load_vm "$1" - ensure_vm - run_guest_apply -} - -apply_all() { - local file name ok failed - local -a files - ok=0 - failed=0 - shopt -s nullglob - files=("$DVM_CONFIG"/vms/*.sh) - shopt -u nullglob - [ "${#files[@]}" -gt 0 ] || die "no VM configs in $DVM_CONFIG/vms" - for file in "${files[@]}"; do - name="$(basename "$file" .sh)" - if (apply_one "$name"); then - ok=$((ok + 1)) - else - printf 'dvm: apply failed: %s\n' "$name" >&2 - failed=$((failed + 1)) - fi - done - printf 'dvm apply --all: %s ok, %s failed\n' "$ok" "$failed" - [ "$failed" -eq 0 ] -} - -# shellcheck disable=SC2016 -guest_cd_script=' -set -euo pipefail -code_dir="$1" -shift -case "$code_dir" in - "~") code_dir="$HOME" ;; - "~/"*) code_dir="$HOME/${code_dir#\~/}" ;; -esac -case "${TERM:-}" in - xterm-ghostty|ghostty) export TERM=xterm-256color ;; - ""|dumb) ;; - *) - if command -v infocmp >/dev/null 2>&1 && ! infocmp "$TERM" >/dev/null 2>&1; then - export TERM=xterm-256color - fi - ;; -esac -mkdir -p "$code_dir" -cd "$code_dir" -if [ "$#" -eq 0 ]; then - login_shell="$(getent passwd "$(id -un)" | cut -d: -f7 || true)" - [ -n "$login_shell" ] || login_shell="${SHELL:-/bin/bash}" - export SHELL="$login_shell" - exec "$login_shell" -l -fi -exec "$@" -' - -enter_vm() { - ssh_vm "$1" -} - -ssh_vm() { - local name="$1" - local term - shift || true - [ "${1:-}" != "--" ] || shift - load_vm "$name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" - start_vm - term="$(guest_term)" - limactl shell "$DVM_LIMA_NAME" env "TERM=$term" bash -c "$guest_cd_script" dvm-ssh "$DVM_CODE_DIR" "$@" -} - -guest_home_dir() { - printf '/home/%s\n' "$DVM_USER" -} - -guest_code_dir_abs() { - local code_dir home - home="$(guest_home_dir)" - code_dir="${DVM_CODE_DIR%/}" - case "$code_dir" in - '') printf '/\n' ;; - "~") printf '%s\n' "$home" ;; - \~/*) printf '%s/%s\n' "$home" "${code_dir#\~/}" ;; - /*) printf '%s\n' "$code_dir" ;; - *) printf '%s/%s\n' "$home" "$code_dir" ;; - esac -} - -guest_cp_path() { - local code_dir home path - path="$1" - home="$(guest_home_dir)" - code_dir="$(guest_code_dir_abs)" - case "$path" in - '' | '.') printf '%s\n' "$code_dir" ;; - "./"*) printf '%s/%s\n' "$code_dir" "${path#./}" ;; - /*) printf '%s\n' "$path" ;; - "~") printf '%s\n' "$home" ;; - \~/*) printf '%s/%s\n' "$home" "${path#\~/}" ;; - *) printf '%s/%s\n' "$code_dir" "$path" ;; - esac -} - -cp_fix_agent_acl() { - local target="$1" - shift || true - limactl shell "$DVM_LIMA_NAME" bash -s -- "$DVM_AI_AGENT_USER" "$DVM_USER" "$(guest_home_dir)" "$(guest_code_dir_abs)" "$target" "$@" <<'SCRIPT' -set -euo pipefail - -agent_user="$1" -vm_user="$2" -guest_home="$3" -code_dir="$4" -target="$5" -shift 5 - -command -v realpath >/dev/null 2>&1 || exit 0 -code_dir="$(realpath -m "$code_dir")" || exit 0 -target="$(realpath -m "$target")" || exit 0 -case "$target" in -"$code_dir" | "$code_dir"/*) ;; -*) exit 0 ;; -esac -id -u "$agent_user" >/dev/null 2>&1 || exit 0 -id -u "$vm_user" >/dev/null 2>&1 || vm_user="" -command -v setfacl >/dev/null 2>&1 || exit 0 - -if [ -d "$guest_home" ]; then - sudo setfacl -m "u:$agent_user:--x" "$guest_home" || true -fi -code_parent="$(dirname "$code_dir")" -if [ -d "$code_parent" ] && [ "$code_parent" != "/" ]; then - sudo setfacl -m "u:$agent_user:--x" "$code_parent" || true -fi -if [ -d "$code_dir" ]; then - sudo setfacl -m "u:$agent_user:rwx" "$code_dir" || true - [ -z "$vm_user" ] || sudo setfacl -m "u:$vm_user:rwx" "$code_dir" || true - sudo setfacl -d -m "u:$agent_user:rwx" "$code_dir" || true - [ -z "$vm_user" ] || sudo setfacl -d -m "u:$vm_user:rwx" "$code_dir" || true -fi - -grant_dir_defaults() { - [ -d "$1" ] || return 0 - case "$1" in - "$code_dir" | "$code_dir"/*) ;; - *) return 0 ;; - esac - sudo setfacl -d -m "u:$agent_user:rwx" "$1" || true - [ -z "$vm_user" ] || sudo setfacl -d -m "u:$vm_user:rwx" "$1" || true -} - -grant_path() { - [ -e "$1" ] || return 0 - sudo setfacl -R -m "u:$agent_user:rwx" "$1" || true - [ -z "$vm_user" ] || sudo setfacl -R -m "u:$vm_user:rwx" "$1" || true - if [ -d "$1" ]; then - sudo find "$1" -type d -exec setfacl -d -m "u:$agent_user:rwx" {} + || true - [ -z "$vm_user" ] || sudo find "$1" -type d -exec setfacl -d -m "u:$vm_user:rwx" {} + || true - else - grant_dir_defaults "$(dirname "$1")" - fi -} - -if [ -d "$target" ]; then - grant_dir_defaults "$target" -else - grant_dir_defaults "$(dirname "$target")" -fi - -if [ "$#" -eq 0 ] || [ ! -d "$target" ]; then - grant_path "$target" - exit 0 -fi - -for name in "$@"; do - case "$name" in - '' | '.' | '..' | */*) continue ;; - esac - grant_path "$target/$name" -done -SCRIPT -} - -cp_vm() { - local acl_name_count arg base copy_into_vm endpoint_count endpoint_name guest_target - local i name opts_count path source_count target_arg vm_name - local -a acl_names operands opts rewritten - acl_names=() - operands=() - opts=() - rewritten=() - acl_name_count=0 - copy_into_vm=0 - opts_count=0 - vm_name="" - while [ "$#" -gt 0 ]; do - case "$1" in - --) - shift - while [ "$#" -gt 0 ]; do - operands+=("$1") - shift - done - ;; - -r | --recursive | -v | --verbose) - opts+=("$1") - opts_count=$((opts_count + 1)) - ;; - --backend=*) - opts+=("$1") - opts_count=$((opts_count + 1)) - ;; - --backend) - [ "$#" -gt 1 ] || die "cp --backend requires a value" - opts+=("$1" "$2") - opts_count=$((opts_count + 2)) - shift - ;; - -*) die "unknown cp option: $1" ;; - *) operands+=("$1") ;; - esac - shift || true - done - [ "${#operands[@]}" -ge 2 ] || die "cp requires a source and target" - - endpoint_count=0 - for arg in "${operands[@]}"; do - case "$arg" in - *:*) - name="${arg%%:*}" - if endpoint_name="$(dvm_endpoint_name "$name")"; then - endpoint_count=$((endpoint_count + 1)) - if [ -n "$vm_name" ] && [ "$vm_name" != "$endpoint_name" ]; then - die "cp can only address one VM at a time" - fi - vm_name="$endpoint_name" - fi - ;; - esac - done - [ "$endpoint_count" -gt 0 ] || die "cp requires one side to use name:path" - [ "$endpoint_count" -lt "${#operands[@]}" ] || die "cp requires one host path and one VM path" - - load_vm "$vm_name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $vm_name first" - start_vm - limactl shell "$DVM_LIMA_NAME" mkdir -p "$(guest_code_dir_abs)" - - target_arg="${operands[$((${#operands[@]} - 1))]}" - case "$target_arg" in - *:*) - name="${target_arg%%:*}" - if endpoint_name="$(dvm_endpoint_name "$name")"; then - copy_into_vm=1 - guest_target="$(guest_cp_path "${target_arg#*:}")" - source_count=$((${#operands[@]} - 1)) - i=0 - while [ "$i" -lt "$source_count" ]; do - arg="${operands[$i]}" - base="${arg%/}" - base="${base##*/}" - if [ -n "$base" ]; then - acl_names+=("$base") - acl_name_count=$((acl_name_count + 1)) - fi - i=$((i + 1)) - done - fi - ;; - esac - - for arg in "${operands[@]}"; do - case "$arg" in - *:*) - name="${arg%%:*}" - if endpoint_name="$(dvm_endpoint_name "$name")"; then - path="${arg#*:}" - rewritten+=("$DVM_LIMA_NAME:$(guest_cp_path "$path")") - else - rewritten+=("$arg") - fi - ;; - *) rewritten+=("$arg") ;; - esac - done - if [ "$opts_count" -gt 0 ]; then - limactl copy "${opts[@]}" "${rewritten[@]}" - else - limactl copy "${rewritten[@]}" - fi - if [ "$copy_into_vm" = "1" ]; then - if [ "$acl_name_count" -gt 0 ]; then - cp_fix_agent_acl "$guest_target" "${acl_names[@]}" - else - cp_fix_agent_acl "$guest_target" - fi - fi -} - -default_log_unit() { - local recipe count unit - count=0 - unit="" - if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then - for recipe in "${DVM_RECIPES[@]}"; do - case "$recipe" in - cloudflared) - unit="$DVM_CLOUDFLARED_SERVICE" - count=$((count + 1)) - ;; - llama) - unit="$DVM_LLAMA_SERVICE" - count=$((count + 1)) - ;; - esac - done - fi - [ "$count" -eq 1 ] || return 1 - printf '%s\n' "$unit" -} - -logs_vm() { - local name unit - name="${1:-}" - [ -n "$name" ] || die "logs requires a VM name" - shift || true - load_vm "$name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" - start_vm - if [ "$#" -gt 0 ]; then - case "$1" in - -*) unit="$(default_log_unit)" || die "logs requires a unit when the VM has zero or multiple known service recipes" ;; - *) - unit="$1" - shift - ;; - esac - else - unit="$(default_log_unit)" || die "logs requires a unit when the VM has zero or multiple known service recipes" - fi - if [ "$#" -eq 0 ]; then - set -- --no-pager -n 100 - fi - limactl shell "$DVM_LIMA_NAME" sudo journalctl -u "$unit" "$@" -} - -ssh_key_vm() { - local name - name="${1:-}" - [ -n "$name" ] || die "ssh-key requires a VM name" - load_vm "$name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" - start_vm - limactl shell "$DVM_LIMA_NAME" env "DVM_NAME=$DVM_NAME" bash -s <<'DVM_SSH_KEY' -set -euo pipefail -if command -v dnf5 >/dev/null 2>&1; then - sudo dnf5 install -y git openssh-clients >/dev/null -fi -key="$HOME/.ssh/id_ed25519_dvm" -signing_key="$HOME/.ssh/id_ed25519_dvm_signing" -config="$HOME/.ssh/config" -mkdir -p "$HOME/.ssh" -chmod 700 "$HOME/.ssh" -if [ ! -f "$key" ]; then - ssh-keygen -t ed25519 -C "$DVM_NAME-dvm-github-access" -f "$key" -N "" -fi -if [ ! -f "$signing_key" ]; then - ssh-keygen -t ed25519 -C "$DVM_NAME-dvm-git-signing" -f "$signing_key" -N "" -fi -write_public_key() { - private_key="$1" - public_key="$private_key.pub" - tmp="$(mktemp "${public_key}.XXXXXX")" - if ssh-keygen -y -f "$private_key" >"$tmp" && mv "$tmp" "$public_key"; then - return 0 - fi - rm -f "$tmp" - return 1 -} -[ -s "$key.pub" ] || write_public_key "$key" -[ -s "$signing_key.pub" ] || write_public_key "$signing_key" -touch "$config" -chmod 600 "$config" -if ! grep -Eq "^[[:space:]]*IdentityFile[[:space:]]+$key([[:space:]]|$)" "$config"; then - { - printf "\nHost github.com\n" - printf " HostName github.com\n" - printf " User git\n" - printf " IdentityFile %s\n" "$key" - printf " IdentitiesOnly yes\n" - printf " AddKeysToAgent no\n" - } >>"$config" -fi -if command -v git >/dev/null 2>&1; then - git_config="$HOME/.config/git/config" - mkdir -p "$(dirname "$git_config")" - GIT_CONFIG_GLOBAL="$git_config" git config --global gpg.format ssh - GIT_CONFIG_GLOBAL="$git_config" git config --global user.signingkey "$signing_key.pub" - GIT_CONFIG_GLOBAL="$git_config" git config --global commit.gpgsign true -fi -printf 'GitHub access key public key (use as deploy key or account authentication key):\n' -cat "$key.pub" -printf '\nGit commit signing public key (add to GitHub account as SSH signing key):\n' -cat "$signing_key.pub" -DVM_SSH_KEY -} - -gpg_key_vm() { - local name - name="${1:-}" - [ -n "$name" ] || die "gpg-key requires a VM name" - load_vm "$name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" - start_vm - limactl shell "$DVM_LIMA_NAME" env "DVM_NAME=$DVM_NAME" bash -s <<'DVM_GPG_KEY' -set -euo pipefail -if command -v dnf5 >/dev/null 2>&1; then - sudo dnf5 install -y gnupg2 >/dev/null -fi -uid="$DVM_NAME dvm " -if ! gpg --list-secret-keys "$uid" >/dev/null 2>&1; then - gpg --batch --passphrase "" --quick-gen-key "$uid" ed25519 sign 1y -fi -gpg --armor --export "$uid" -gpg --with-colons --list-secret-keys "$uid" | awk -F: '$1 == "fpr" { print "fingerprint: " $10; exit }' -DVM_GPG_KEY -} - -dirty_check_vm() { - start_vm >/dev/null - limactl shell "$DVM_LIMA_NAME" bash -s -- "$DVM_CODE_DIR" <<'DVM_DIRTY_CHECK' -set -euo pipefail -code_dir="$1" -case "$code_dir" in - "~") code_dir="$HOME" ;; - "~/"*) code_dir="$HOME/${code_dir#\~/}" ;; -esac -[ -d "$code_dir" ] || exit 0 -command -v git >/dev/null 2>&1 || exit 0 -dirty=0 -while IFS= read -r git_entry; do - if [ -d "$git_entry" ]; then - repo="${git_entry%/.git}" - else - repo="$(dirname "$git_entry")" - fi - if ! git -C "$repo" diff --quiet || - ! git -C "$repo" diff --cached --quiet || - [ -n "$(git -C "$repo" ls-files --others --exclude-standard)" ]; then - printf 'dirty repository: %s\n' "$repo" >&2 - dirty=1 - fi -done < <(find "$code_dir" \( -type d -name .git -prune -print \) -o \( -type f -name .git -print \)) -exit "$dirty" -DVM_DIRTY_CHECK -} - -list_vms() { - limactl list | awk ' - NR == 1 { - printf "%-16s %-10s %-18s %-7s %-9s %-9s %s\n", $1, $2, $3, $4, $5, $6, $7 - next - } - $1 ~ /^dvm-/ { - sub(/^dvm-/, "", $1) - printf "%-16s %-10s %-18s %-7s %-9s %-9s %s\n", $1, $2, $3, $4, $5, $6, $7 - } - ' -} - -stop_vm() { - local name - name="${1:-}" - [ -n "$name" ] || die "stop requires a VM name" - load_vm "$name" - vm_exists || die "VM does not exist: $DVM_LIMA_NAME" - limactl stop "$DVM_LIMA_NAME" -} - -rm_vm() { - local force name orphan vm_file yes - name="${1:-}" - [ -n "$name" ] || die "rm requires a VM name" - shift || true - force=0 - orphan=0 - yes=0 - while [ "$#" -gt 0 ]; do - case "$1" in - --yes) yes=1 ;; - --force | -f) force=1 ;; - *) die "unknown rm option: $1" ;; - esac - shift - done - [ "$yes" = "1" ] || die "rm requires --yes" - name="$(public_vm_name "$name")" - vm_file="$DVM_CONFIG/vms/$name.sh" - if [ -f "$vm_file" ]; then - load_vm "$name" - else - DVM_LIMA_NAME="dvm-$name" - if vm_exists; then - orphan=1 - printf 'dvm: warning: deleting Lima VM without DVM config: %s (missing %s)\n' "$DVM_LIMA_NAME" "$vm_file" >&2 - fi - fi - if [ "$force" != "1" ] && vm_exists && [ -f "$vm_file" ]; then - dirty_check_vm || die "refusing to delete $DVM_LIMA_NAME; commit/stash changes or pass --force" - elif [ "$force" != "1" ] && [ "$orphan" = "1" ]; then - printf 'dvm: warning: dirty check skipped because DVM config is missing: %s\n' "$vm_file" >&2 - fi - limactl stop "$DVM_LIMA_NAME" >/dev/null 2>&1 || true - limactl delete "$DVM_LIMA_NAME" -} +# shellcheck source=share/dvm/lib/core.sh +source "$DVM_LIB_DIR/core.sh" +# shellcheck source=share/dvm/lib/ports.sh +source "$DVM_LIB_DIR/ports.sh" +# shellcheck source=share/dvm/lib/lima.sh +source "$DVM_LIB_DIR/lima.sh" +# shellcheck source=share/dvm/lib/apply.sh +source "$DVM_LIB_DIR/apply.sh" +# shellcheck source=share/dvm/lib/guest.sh +source "$DVM_LIB_DIR/guest.sh" +# shellcheck source=share/dvm/lib/keys.sh +source "$DVM_LIB_DIR/keys.sh" +# shellcheck source=share/dvm/lib/vm_admin.sh +source "$DVM_LIB_DIR/vm_admin.sh" main() { local cmd="${1:-help}" diff --git a/install.sh b/install.sh index 3ce29be..51b1d10 100755 --- a/install.sh +++ b/install.sh @@ -12,8 +12,8 @@ usage: ./install.sh [--name dvm] [--prefix ~/.local/bin] [--init] Installs a tiny DVM launcher. With --init, copies default config into ~/.config/dvm -without overwriting existing files. Bundled recipes, the Lima template, and example VM -configs stay in the repo under share/dvm. +without overwriting existing files. Bundled recipes, shell libraries, the Lima template, +and example VM configs stay in the repo under share/dvm. HELP } @@ -54,17 +54,24 @@ src="$DVM_ROOT/bin/dvm" printf 'dvm launcher: missing %s\n' "$src" >&2 exit 1 } +lib_src="$DVM_ROOT/share/dvm/lib" +[ -d "$lib_src" ] || { + printf 'dvm launcher: missing %s\n' "$lib_src" >&2 + exit 1 +} tmp_dir="${TMPDIR:-/tmp}" -tmp="$(mktemp "${tmp_dir%/}/dvm-run.XXXXXX")" +tmp_parent="$(mktemp -d "${tmp_dir%/}/dvm-run.XXXXXX")" cleanup() { - rm -f "$tmp" + rm -rf "$tmp_parent" } trap cleanup EXIT INT TERM -cp "$src" "$tmp" -chmod 0700 "$tmp" -DVM_ROOT="$DVM_ROOT" bash "$tmp" "$@" +mkdir -p "$tmp_parent/bin" "$tmp_parent/lib" +cp "$src" "$tmp_parent/bin/dvm" +cp -R "$lib_src/." "$tmp_parent/lib/" +chmod 0700 "$tmp_parent/bin/dvm" +DVM_ROOT="$DVM_ROOT" DVM_LIB_DIR="$tmp_parent/lib" bash "$tmp_parent/bin/dvm" "$@" LAUNCHER } >"$dst" chmod 0755 "$dst" @@ -81,6 +88,7 @@ init_config() { rel="${file#"$src"/}" case "$rel" in lima.yaml.in) ;; + lib/*) ;; recipes/*) ;; vms/*) ;; *) install_file "$file" "$DVM_CONFIG/$rel" ;; diff --git a/scripts/check.sh b/scripts/check.sh index cfd542d..79dcc14 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -16,7 +16,7 @@ shell_files | while IFS= read -r file; do done if command -v shellcheck >/dev/null 2>&1; then - shell_files | xargs shellcheck + shell_files | xargs shellcheck -x fi find . -path ./.git -prune -o -type f -print0 | diff --git a/share/dvm/lib/apply.sh b/share/dvm/lib/apply.sh new file mode 100644 index 0000000..473cb1e --- /dev/null +++ b/share/dvm/lib/apply.sh @@ -0,0 +1,118 @@ +# shellcheck shell=bash + +run_guest_apply() { + local cloudflared_token helper recipe path var + local -a args + args=() + cloudflared_token="" + if uses_recipe cloudflared; then + cloudflared_token="${DVM_CLOUDFLARED_TOKEN:-${CLOUDFLARED_TOKEN:-}}" + [ -z "$cloudflared_token" ] || validate_cloudflared_token "$cloudflared_token" + fi + while IFS= read -r var; do + case "$var" in + DVM_ROOT | DVM_SHARE | DVM_CONFIG | DVM_FAKE_STATE | DVM_LIMA_NAME | DVM_RECIPES | DVM_NO_BASELINE | DVM_PORT_FORWARDS_YAML) continue ;; + DVM_CLOUDFLARED_TOKEN | DVM_CLOUDFLARED_TOKEN_FILE) continue ;; + DVM_*) args+=("$var=${!var}") ;; + esac + done < <(compgen -A variable | sort) + + printf 'dvm: applying recipes for %s:' "$DVM_NAME" >&2 + if [ "${DVM_NO_BASELINE:-0}" != "1" ]; then + printf ' baseline' >&2 + fi + if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then + for recipe in "${DVM_RECIPES[@]}"; do + printf ' %s' "$recipe" >&2 + done + fi + printf '\n' >&2 + + { + cat <<'DVM_HOSTNAME' +# dvm hostname +if [ -n "${DVM_NAME:-}" ] && command -v hostnamectl >/dev/null 2>&1; then + current_hostname="$(hostname 2>/dev/null || true)" + if [ "$current_hostname" != "$DVM_NAME" ]; then + sudo hostnamectl set-hostname "$DVM_NAME" + fi +fi + +DVM_HOSTNAME + helper="$DVM_SHARE/recipes/_helpers.sh" + if [ -f "$helper" ]; then + printf '# dvm recipe helpers\n' + cat "$helper" + fi + if [ "${DVM_NO_BASELINE:-0}" != "1" ]; then + cat "$(recipe_file baseline)" + fi + if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then + for recipe in "${DVM_RECIPES[@]}"; do + path="$(recipe_file "$recipe")" + if [ "$recipe" = "cloudflared" ]; then + emit_cloudflared_token_file "$cloudflared_token" + fi + printf '\n# dvm recipe: %s\n' "$recipe" + cat "$path" + done + fi + cat <<'DVM_PROJECT_HOOK' + +# dvm project hook +dvm_code_dir="${DVM_CODE_DIR%/}" +case "$dvm_code_dir" in + "~") dvm_code_dir="$HOME" ;; + "~/"*) dvm_code_dir="$HOME/${dvm_code_dir#\~/}" ;; +esac +if [ -f "$dvm_code_dir/.dvm/apply.sh" ]; then + bash "$dvm_code_dir/.dvm/apply.sh" +fi +DVM_PROJECT_HOOK + } | limactl shell "$DVM_LIMA_NAME" env "${args[@]}" bash -s +} + +emit_cloudflared_token_file() { + local token="$1" + [ -n "$token" ] || return 0 + cat <<'DVM_CLOUDFLARED_TOKEN_SETUP' +# dvm cloudflared token file +dvm_cloudflared_token_file="$(mktemp "${TMPDIR:-/tmp}/dvm-cloudflared-token.XXXXXX")" +chmod 600 "$dvm_cloudflared_token_file" +cat >"$dvm_cloudflared_token_file" <<'DVM_CLOUDFLARED_TOKEN' +DVM_CLOUDFLARED_TOKEN_SETUP + printf '%s\n' "$token" + cat <<'DVM_CLOUDFLARED_TOKEN_SETUP' +DVM_CLOUDFLARED_TOKEN +export DVM_CLOUDFLARED_TOKEN_FILE="$dvm_cloudflared_token_file" + +DVM_CLOUDFLARED_TOKEN_SETUP +} + +apply_one() { + load_vm "$1" + ensure_vm + run_guest_apply +} + +apply_all() { + local file name ok failed + local -a files + ok=0 + failed=0 + shopt -s nullglob + files=("$DVM_CONFIG"/vms/*.sh) + shopt -u nullglob + [ "${#files[@]}" -gt 0 ] || die "no VM configs in $DVM_CONFIG/vms" + for file in "${files[@]}"; do + name="$(basename "$file" .sh)" + if (apply_one "$name"); then + ok=$((ok + 1)) + else + printf 'dvm: apply failed: %s\n' "$name" >&2 + failed=$((failed + 1)) + fi + done + printf 'dvm apply --all: %s ok, %s failed\n' "$ok" "$failed" + [ "$failed" -eq 0 ] +} diff --git a/share/dvm/lib/core.sh b/share/dvm/lib/core.sh new file mode 100644 index 0000000..77f4b62 --- /dev/null +++ b/share/dvm/lib/core.sh @@ -0,0 +1,240 @@ +# shellcheck shell=bash + +usage() { + cat <<'HELP' +usage: + dvm init [template] + dvm apply + dvm apply --all + dvm enter + dvm ssh -- + dvm cp [-r] [-v] [--backend auto|scp|rsync] + dvm logs [unit] [journalctl-args...] + dvm ssh-key + dvm gpg-key + dvm list + dvm stop + dvm rm --yes [--force] +HELP +} + +die() { + printf 'dvm: error: %s\n' "$*" >&2 + exit 1 +} + +safe_name() { + case "${1:-}" in + '' | *[!a-z0-9-]* | -*) return 1 ;; + [a-z]*) return 0 ;; + *) return 1 ;; + esac +} + +public_vm_name() { + local name="$1" + case "$name" in + dvm-*) name="${name#dvm-}" ;; + esac + safe_name "$name" || die "unsafe VM name: $1" + printf '%s\n' "$name" +} + +guest_term() { + case "${TERM:-}" in + '' | xterm-ghostty | ghostty) printf '%s\n' xterm-256color ;; + *) printf '%s\n' "$TERM" ;; + esac +} + +validate_identifier() { + local label="$1" + local value="$2" + case "$value" in + '' | [.-]* | *[!A-Za-z0-9._-]*) die "invalid $label: $value" ;; + esac +} + +validate_lima_name() { + case "$1" in + '' | *[!a-z0-9-]* | -*) die "invalid DVM_LIMA_NAME: $1" ;; + esac +} + +validate_positive_int() { + local label="$1" + local value="$2" + case "$value" in + '' | *[!0-9]*) die "invalid $label: $value" ;; + esac + [ "$value" -gt 0 ] || die "invalid $label: $value" +} + +validate_lima_size() { + local label="$1" + local value="$2" + case "$value" in + '' | [!0-9]* | *[!A-Za-z0-9._+-]*) die "invalid $label: $value" ;; + esac +} + +validate_guest_path() { + local label="$1" + local value="$2" + local newline carriage + newline=$'\n' + carriage=$'\r' + case "$value" in + '' | *"$newline"* | *"$carriage"* | *'"'* | *'`'* | *\\* | *'$'*) die "invalid $label: $value" ;; + esac +} + +validate_cloudflared_token() { + case "$1" in + *[!A-Za-z0-9._=-]*) die "invalid cloudflared token characters" ;; + esac +} + +dvm_endpoint_name() { + local name="$1" + case "$name" in + dvm-*) name="${name#dvm-}" ;; + esac + safe_name "$name" || return 1 + printf '%s\n' "$name" +} + +open_editor() { + local file="$1" + local editor="${EDITOR:-${VISUAL:-vi}}" + [ -n "$editor" ] || editor="vi" + # shellcheck disable=SC2086 + $editor "$file" +} + +init_vm() { + local name template src dst + name="${1:-}" + [ -n "$name" ] || die "init requires a VM name" + template="${2:-app}" + name="$(public_vm_name "$name")" + safe_name "$template" || die "unsafe template name: $template" + src="$DVM_SHARE/vms/$template.sh" + [ -f "$src" ] || die "missing VM template: $template" + dst="$DVM_CONFIG/vms/$name.sh" + if [ -e "$dst" ]; then + printf 'dvm: VM config already exists: %s\n' "$dst" >&2 + else + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + printf 'dvm: created VM config: %s\n' "$dst" + fi + open_editor "$dst" +} + +recipe_file() { + local name="$1" + local path + path="$DVM_CONFIG/recipes/$name.sh" + if [ -f "$path" ]; then + printf '%s\n' "$path" + return 0 + fi + path="$DVM_SHARE/recipes/$name.sh" + if [ -f "$path" ]; then + printf '%s\n' "$path" + return 0 + fi + die "no recipe: $name" +} + +use() { + local name="${1:-}" + [ -n "$name" ] || die "use requires a recipe name" + recipe_file "$name" >/dev/null + DVM_RECIPES+=("$name") +} + +uses_recipe() { + local recipe + for recipe in "${DVM_RECIPES[@]}"; do + [ "$recipe" = "$1" ] && return 0 + done + return 1 +} + +load_vm() { + local name="$1" + local vm_file + name="$(public_vm_name "$name")" + + reset_vm_vars + + # shellcheck source=/dev/null + [ -f "$DVM_SHARE/config.sh" ] && source "$DVM_SHARE/config.sh" + # shellcheck source=/dev/null + [ -f "$DVM_CONFIG/config.sh" ] && source "$DVM_CONFIG/config.sh" + + # shellcheck disable=SC2034 + DVM_NAME="$name" + DVM_LIMA_NAME="${DVM_LIMA_NAME:-dvm-$name}" + DVM_CODE_ROOT="${DVM_CODE_ROOT:-~/code}" + DVM_CODE_DIR="${DVM_CODE_DIR:-${DVM_CODE_ROOT%/}/$name}" + DVM_PORTS="${DVM_PORTS:-}" + DVM_ARCH="${DVM_ARCH:-default}" + DVM_AI_AGENT_USER="${DVM_AI_AGENT_USER:-dvm-agent}" + + vm_file="$DVM_CONFIG/vms/$name.sh" + [ -f "$vm_file" ] || die "missing VM config: $vm_file" + # shellcheck source=/dev/null + source "$vm_file" + + DVM_CODE_DIR="${DVM_CODE_DIR:-${DVM_CODE_ROOT%/}/$name}" + DVM_PORTS="${DVM_PORTS:-}" + DVM_HOST_IP="${DVM_HOST_IP:-127.0.0.1}" + DVM_LLAMA_SERVICE="${DVM_LLAMA_SERVICE:-dvm-llama.service}" + DVM_CLOUDFLARED_SERVICE="${DVM_CLOUDFLARED_SERVICE:-dvm-cloudflared.service}" + resolve_arch + validate_vm_config +} + +reset_vm_vars() { + local var + while IFS= read -r var; do + case "$var" in + DVM_ROOT | DVM_SHARE | DVM_CONFIG | DVM_FAKE_STATE) ;; + DVM_*) unset "$var" || true ;; + esac + done < <(compgen -A variable) + DVM_RECIPES=() +} + +resolve_arch() { + if [ "${DVM_ARCH:-default}" != "default" ]; then + return 0 + fi + case "$(uname -m)" in + arm64 | aarch64) DVM_ARCH="aarch64" ;; + x86_64 | amd64) DVM_ARCH="x86_64" ;; + *) die "cannot resolve DVM_ARCH=default for $(uname -m)" ;; + esac +} + +validate_vm_config() { + local item + validate_lima_name "$DVM_LIMA_NAME" + validate_identifier DVM_USER "$DVM_USER" + validate_identifier DVM_AI_AGENT_USER "$DVM_AI_AGENT_USER" + case "$DVM_ARCH" in + aarch64 | x86_64) ;; + *) die "invalid DVM_ARCH: $DVM_ARCH" ;; + esac + validate_positive_int DVM_CPUS "$DVM_CPUS" + validate_lima_size DVM_MEMORY "$DVM_MEMORY" + validate_lima_size DVM_DISK "$DVM_DISK" + validate_guest_path DVM_CODE_DIR "$DVM_CODE_DIR" + validate_host_ip "$DVM_HOST_IP" + for item in ${DVM_PORTS:-}; do + normalize_port "$item" >/dev/null + done +} diff --git a/share/dvm/lib/guest.sh b/share/dvm/lib/guest.sh new file mode 100644 index 0000000..ab72385 --- /dev/null +++ b/share/dvm/lib/guest.sh @@ -0,0 +1,321 @@ +# shellcheck shell=bash +# shellcheck disable=SC2016 + +guest_cd_script=' +set -euo pipefail +code_dir="$1" +shift +case "$code_dir" in + "~") code_dir="$HOME" ;; + "~/"*) code_dir="$HOME/${code_dir#\~/}" ;; +esac +case "${TERM:-}" in + xterm-ghostty|ghostty) export TERM=xterm-256color ;; + ""|dumb) ;; + *) + if command -v infocmp >/dev/null 2>&1 && ! infocmp "$TERM" >/dev/null 2>&1; then + export TERM=xterm-256color + fi + ;; +esac +mkdir -p "$code_dir" +cd "$code_dir" +if [ "$#" -eq 0 ]; then + login_shell="$(getent passwd "$(id -un)" | cut -d: -f7 || true)" + [ -n "$login_shell" ] || login_shell="${SHELL:-/bin/bash}" + export SHELL="$login_shell" + exec "$login_shell" -l +fi +exec "$@" +' + +enter_vm() { + ssh_vm "$1" +} + +ssh_vm() { + local name="$1" + local term + shift || true + [ "${1:-}" != "--" ] || shift + load_vm "$name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" + start_vm + term="$(guest_term)" + limactl shell "$DVM_LIMA_NAME" env "TERM=$term" bash -c "$guest_cd_script" dvm-ssh "$DVM_CODE_DIR" "$@" +} + +guest_home_dir() { + printf '/home/%s\n' "$DVM_USER" +} + +guest_code_dir_abs() { + local code_dir home + home="$(guest_home_dir)" + code_dir="${DVM_CODE_DIR%/}" + case "$code_dir" in + '') printf '/\n' ;; + "~") printf '%s\n' "$home" ;; + \~/*) printf '%s/%s\n' "$home" "${code_dir#\~/}" ;; + /*) printf '%s\n' "$code_dir" ;; + *) printf '%s/%s\n' "$home" "$code_dir" ;; + esac +} + +guest_cp_path() { + local code_dir home path + path="$1" + home="$(guest_home_dir)" + code_dir="$(guest_code_dir_abs)" + case "$path" in + '' | '.') printf '%s\n' "$code_dir" ;; + "./"*) printf '%s/%s\n' "$code_dir" "${path#./}" ;; + /*) printf '%s\n' "$path" ;; + "~") printf '%s\n' "$home" ;; + \~/*) printf '%s/%s\n' "$home" "${path#\~/}" ;; + *) printf '%s/%s\n' "$code_dir" "$path" ;; + esac +} + +cp_fix_agent_acl() { + local target="$1" + shift || true + limactl shell "$DVM_LIMA_NAME" bash -s -- "$DVM_AI_AGENT_USER" "$DVM_USER" "$(guest_home_dir)" "$(guest_code_dir_abs)" "$target" "$@" <<'SCRIPT' +set -euo pipefail + +agent_user="$1" +vm_user="$2" +guest_home="$3" +code_dir="$4" +target="$5" +shift 5 + +command -v realpath >/dev/null 2>&1 || exit 0 +code_dir="$(realpath -m "$code_dir")" || exit 0 +target="$(realpath -m "$target")" || exit 0 +case "$target" in +"$code_dir" | "$code_dir"/*) ;; +*) exit 0 ;; +esac +id -u "$agent_user" >/dev/null 2>&1 || exit 0 +id -u "$vm_user" >/dev/null 2>&1 || vm_user="" +command -v setfacl >/dev/null 2>&1 || exit 0 + +if [ -d "$guest_home" ]; then + sudo setfacl -m "u:$agent_user:--x" "$guest_home" || true +fi +code_parent="$(dirname "$code_dir")" +if [ -d "$code_parent" ] && [ "$code_parent" != "/" ]; then + sudo setfacl -m "u:$agent_user:--x" "$code_parent" || true +fi +if [ -d "$code_dir" ]; then + sudo setfacl -m "u:$agent_user:rwx" "$code_dir" || true + [ -z "$vm_user" ] || sudo setfacl -m "u:$vm_user:rwx" "$code_dir" || true + sudo setfacl -d -m "u:$agent_user:rwx" "$code_dir" || true + [ -z "$vm_user" ] || sudo setfacl -d -m "u:$vm_user:rwx" "$code_dir" || true +fi + +grant_dir_defaults() { + [ -d "$1" ] || return 0 + case "$1" in + "$code_dir" | "$code_dir"/*) ;; + *) return 0 ;; + esac + sudo setfacl -d -m "u:$agent_user:rwx" "$1" || true + [ -z "$vm_user" ] || sudo setfacl -d -m "u:$vm_user:rwx" "$1" || true +} + +grant_path() { + [ -e "$1" ] || return 0 + sudo setfacl -R -m "u:$agent_user:rwx" "$1" || true + [ -z "$vm_user" ] || sudo setfacl -R -m "u:$vm_user:rwx" "$1" || true + if [ -d "$1" ]; then + sudo find "$1" -type d -exec setfacl -d -m "u:$agent_user:rwx" {} + || true + [ -z "$vm_user" ] || sudo find "$1" -type d -exec setfacl -d -m "u:$vm_user:rwx" {} + || true + else + grant_dir_defaults "$(dirname "$1")" + fi +} + +if [ -d "$target" ]; then + grant_dir_defaults "$target" +else + grant_dir_defaults "$(dirname "$target")" +fi + +if [ "$#" -eq 0 ] || [ ! -d "$target" ]; then + grant_path "$target" + exit 0 +fi + +for name in "$@"; do + case "$name" in + '' | '.' | '..' | */*) continue ;; + esac + grant_path "$target/$name" +done +SCRIPT +} + +cp_vm() { + local acl_name_count arg base copy_into_vm endpoint_count endpoint_name guest_target + local i name opts_count path source_count target_arg vm_name + local -a acl_names operands opts rewritten + acl_names=() + operands=() + opts=() + rewritten=() + acl_name_count=0 + copy_into_vm=0 + opts_count=0 + vm_name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --) + shift + while [ "$#" -gt 0 ]; do + operands+=("$1") + shift + done + ;; + -r | --recursive | -v | --verbose) + opts+=("$1") + opts_count=$((opts_count + 1)) + ;; + --backend=*) + opts+=("$1") + opts_count=$((opts_count + 1)) + ;; + --backend) + [ "$#" -gt 1 ] || die "cp --backend requires a value" + opts+=("$1" "$2") + opts_count=$((opts_count + 2)) + shift + ;; + -*) die "unknown cp option: $1" ;; + *) operands+=("$1") ;; + esac + shift || true + done + [ "${#operands[@]}" -ge 2 ] || die "cp requires a source and target" + + endpoint_count=0 + for arg in "${operands[@]}"; do + case "$arg" in + *:*) + name="${arg%%:*}" + if endpoint_name="$(dvm_endpoint_name "$name")"; then + endpoint_count=$((endpoint_count + 1)) + if [ -n "$vm_name" ] && [ "$vm_name" != "$endpoint_name" ]; then + die "cp can only address one VM at a time" + fi + vm_name="$endpoint_name" + fi + ;; + esac + done + [ "$endpoint_count" -gt 0 ] || die "cp requires one side to use name:path" + [ "$endpoint_count" -lt "${#operands[@]}" ] || die "cp requires one host path and one VM path" + + load_vm "$vm_name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $vm_name first" + start_vm + limactl shell "$DVM_LIMA_NAME" mkdir -p "$(guest_code_dir_abs)" + + target_arg="${operands[$((${#operands[@]} - 1))]}" + case "$target_arg" in + *:*) + name="${target_arg%%:*}" + if endpoint_name="$(dvm_endpoint_name "$name")"; then + copy_into_vm=1 + guest_target="$(guest_cp_path "${target_arg#*:}")" + source_count=$((${#operands[@]} - 1)) + i=0 + while [ "$i" -lt "$source_count" ]; do + arg="${operands[$i]}" + base="${arg%/}" + base="${base##*/}" + if [ -n "$base" ]; then + acl_names+=("$base") + acl_name_count=$((acl_name_count + 1)) + fi + i=$((i + 1)) + done + fi + ;; + esac + + for arg in "${operands[@]}"; do + case "$arg" in + *:*) + name="${arg%%:*}" + if endpoint_name="$(dvm_endpoint_name "$name")"; then + path="${arg#*:}" + rewritten+=("$DVM_LIMA_NAME:$(guest_cp_path "$path")") + else + rewritten+=("$arg") + fi + ;; + *) rewritten+=("$arg") ;; + esac + done + if [ "$opts_count" -gt 0 ]; then + limactl copy "${opts[@]}" "${rewritten[@]}" + else + limactl copy "${rewritten[@]}" + fi + if [ "$copy_into_vm" = "1" ]; then + if [ "$acl_name_count" -gt 0 ]; then + cp_fix_agent_acl "$guest_target" "${acl_names[@]}" + else + cp_fix_agent_acl "$guest_target" + fi + fi +} + +default_log_unit() { + local recipe count unit + count=0 + unit="" + if [ "${#DVM_RECIPES[@]}" -gt 0 ]; then + for recipe in "${DVM_RECIPES[@]}"; do + case "$recipe" in + cloudflared) + unit="$DVM_CLOUDFLARED_SERVICE" + count=$((count + 1)) + ;; + llama) + unit="$DVM_LLAMA_SERVICE" + count=$((count + 1)) + ;; + esac + done + fi + [ "$count" -eq 1 ] || return 1 + printf '%s\n' "$unit" +} + +logs_vm() { + local name unit + name="${1:-}" + [ -n "$name" ] || die "logs requires a VM name" + shift || true + load_vm "$name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" + start_vm + if [ "$#" -gt 0 ]; then + case "$1" in + -*) unit="$(default_log_unit)" || die "logs requires a unit when the VM has zero or multiple known service recipes" ;; + *) + unit="$1" + shift + ;; + esac + else + unit="$(default_log_unit)" || die "logs requires a unit when the VM has zero or multiple known service recipes" + fi + if [ "$#" -eq 0 ]; then + set -- --no-pager -n 100 + fi + limactl shell "$DVM_LIMA_NAME" sudo journalctl -u "$unit" "$@" +} diff --git a/share/dvm/lib/keys.sh b/share/dvm/lib/keys.sh new file mode 100644 index 0000000..cfc2a1c --- /dev/null +++ b/share/dvm/lib/keys.sh @@ -0,0 +1,83 @@ +# shellcheck shell=bash + +ssh_key_vm() { + local name + name="${1:-}" + [ -n "$name" ] || die "ssh-key requires a VM name" + load_vm "$name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" + start_vm + limactl shell "$DVM_LIMA_NAME" env "DVM_NAME=$DVM_NAME" bash -s <<'DVM_SSH_KEY' +set -euo pipefail +if command -v dnf5 >/dev/null 2>&1; then + sudo dnf5 install -y git openssh-clients >/dev/null +fi +key="$HOME/.ssh/id_ed25519_dvm" +signing_key="$HOME/.ssh/id_ed25519_dvm_signing" +config="$HOME/.ssh/config" +mkdir -p "$HOME/.ssh" +chmod 700 "$HOME/.ssh" +if [ ! -f "$key" ]; then + ssh-keygen -t ed25519 -C "$DVM_NAME-dvm-github-access" -f "$key" -N "" +fi +if [ ! -f "$signing_key" ]; then + ssh-keygen -t ed25519 -C "$DVM_NAME-dvm-git-signing" -f "$signing_key" -N "" +fi +write_public_key() { + private_key="$1" + public_key="$private_key.pub" + tmp="$(mktemp "${public_key}.XXXXXX")" + if ssh-keygen -y -f "$private_key" >"$tmp" && mv "$tmp" "$public_key"; then + return 0 + fi + rm -f "$tmp" + return 1 +} +[ -s "$key.pub" ] || write_public_key "$key" +[ -s "$signing_key.pub" ] || write_public_key "$signing_key" +touch "$config" +chmod 600 "$config" +if ! grep -Eq "^[[:space:]]*IdentityFile[[:space:]]+$key([[:space:]]|$)" "$config"; then + { + printf "\nHost github.com\n" + printf " HostName github.com\n" + printf " User git\n" + printf " IdentityFile %s\n" "$key" + printf " IdentitiesOnly yes\n" + printf " AddKeysToAgent no\n" + } >>"$config" +fi +if command -v git >/dev/null 2>&1; then + git_config="$HOME/.config/git/config" + mkdir -p "$(dirname "$git_config")" + GIT_CONFIG_GLOBAL="$git_config" git config --global gpg.format ssh + GIT_CONFIG_GLOBAL="$git_config" git config --global user.signingkey "$signing_key.pub" + GIT_CONFIG_GLOBAL="$git_config" git config --global commit.gpgsign true +fi +printf 'GitHub access key public key (use as deploy key or account authentication key):\n' +cat "$key.pub" +printf '\nGit commit signing public key (add to GitHub account as SSH signing key):\n' +cat "$signing_key.pub" +DVM_SSH_KEY +} + +gpg_key_vm() { + local name + name="${1:-}" + [ -n "$name" ] || die "gpg-key requires a VM name" + load_vm "$name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm apply $name first" + start_vm + limactl shell "$DVM_LIMA_NAME" env "DVM_NAME=$DVM_NAME" bash -s <<'DVM_GPG_KEY' +set -euo pipefail +if command -v dnf5 >/dev/null 2>&1; then + sudo dnf5 install -y gnupg2 >/dev/null +fi +uid="$DVM_NAME dvm " +if ! gpg --list-secret-keys "$uid" >/dev/null 2>&1; then + gpg --batch --passphrase "" --quick-gen-key "$uid" ed25519 sign 1y +fi +gpg --armor --export "$uid" +gpg --with-colons --list-secret-keys "$uid" | awk -F: '$1 == "fpr" { print "fingerprint: " $10; exit }' +DVM_GPG_KEY +} diff --git a/share/dvm/lib/lima.sh b/share/dvm/lib/lima.sh new file mode 100644 index 0000000..6177b7a --- /dev/null +++ b/share/dvm/lib/lima.sh @@ -0,0 +1,88 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 + +vm_dir() { + local dir name + while IFS=$'\t' read -r name dir _; do + if [ "$name" = "$DVM_LIMA_NAME" ]; then + printf '%s\n' "${dir:-${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME}" + return 0 + fi + done < <(limactl list --format '{{.Name}} {{.Dir}}' 2>/dev/null || true) + printf '%s\n' "${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME" +} + +update_port_forwards() { + local actual desired dir expr + dir="$(vm_dir)" + desired="$(configured_ports_canonical | paste -sd' ' -)" + actual="$(ports_canonical_from_yaml "$dir/lima.yaml" | paste -sd' ' -)" + [ "$desired" = "$actual" ] && return 0 + printf 'dvm: updating port forwards for %s\n' "$DVM_LIMA_NAME" >&2 + expr="$(port_set_expr)" + limactl stop "$DVM_LIMA_NAME" >/dev/null 2>&1 || true + limactl edit --tty=false --set "$expr" --start "$DVM_LIMA_NAME" >/dev/null +} + +render_template() { + local template="$1" + if command -v envsubst >/dev/null 2>&1; then + envsubst <"$template" + else + perl -pe 's/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/exists $ENV{$1} ? $ENV{$1} : $&/ge' "$template" + fi +} + +vm_exists() { + vm_listed || vm_local_config_exists +} + +vm_listed() { + limactl list --format '{{.Name}}' 2>/dev/null | grep -Fxq "$DVM_LIMA_NAME" +} + +vm_local_config_exists() { + [ -f "${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME/lima.yaml" ] +} + +start_vm() { + if limactl start "$DVM_LIMA_NAME"; then + return 0 + fi + if ! vm_listed && vm_local_config_exists; then + die "Lima instance directory exists but limactl cannot start $DVM_LIMA_NAME; inspect or remove ${LIMA_HOME:-$HOME/.lima}/$DVM_LIMA_NAME" + fi + return 1 +} + +ensure_vm() { + local create_output template tmp tmp_dir + if ! vm_exists; then + template="$DVM_CONFIG/lima.yaml.in" + [ -f "$template" ] || template="$DVM_SHARE/lima.yaml.in" + [ -f "$template" ] || die "missing Lima template" + tmp_dir="${TMPDIR:-/tmp}" + tmp="$(mktemp "${tmp_dir%/}/dvm-lima.XXXXXX")" + DVM_PORT_FORWARDS_YAML="$(render_ports)" + export DVM_NAME DVM_LIMA_NAME DVM_ARCH DVM_CPUS DVM_MEMORY DVM_DISK + export DVM_USER DVM_CODE_DIR DVM_PORT_FORWARDS_YAML + render_template "$template" >"$tmp" + if ! create_output="$(limactl create --name "$DVM_LIMA_NAME" --tty=false "$tmp" 2>&1)"; then + rm -f "$tmp" + case "$create_output" in + *"already exists"*) + update_port_forwards + ;; + *) + printf '%s\n' "$create_output" >&2 + return 1 + ;; + esac + else + rm -f "$tmp" + fi + else + update_port_forwards + fi + start_vm +} diff --git a/share/dvm/lib/ports.sh b/share/dvm/lib/ports.sh new file mode 100644 index 0000000..f41b8ee --- /dev/null +++ b/share/dvm/lib/ports.sh @@ -0,0 +1,113 @@ +# shellcheck shell=bash + +render_ports() { + local item host_ip host_port guest_port + for item in ${DVM_PORTS:-}; do + IFS=: read -r host_ip host_port guest_port </dev/null + limactl shell "$DVM_LIMA_NAME" bash -s -- "$DVM_CODE_DIR" <<'DVM_DIRTY_CHECK' +set -euo pipefail +code_dir="$1" +case "$code_dir" in + "~") code_dir="$HOME" ;; + "~/"*) code_dir="$HOME/${code_dir#\~/}" ;; +esac +[ -d "$code_dir" ] || exit 0 +command -v git >/dev/null 2>&1 || exit 0 +dirty=0 +while IFS= read -r git_entry; do + if [ -d "$git_entry" ]; then + repo="${git_entry%/.git}" + else + repo="$(dirname "$git_entry")" + fi + if ! git -C "$repo" diff --quiet || + ! git -C "$repo" diff --cached --quiet || + [ -n "$(git -C "$repo" ls-files --others --exclude-standard)" ]; then + printf 'dirty repository: %s\n' "$repo" >&2 + dirty=1 + fi +done < <(find "$code_dir" \( -type d -name .git -prune -print \) -o \( -type f -name .git -print \)) +exit "$dirty" +DVM_DIRTY_CHECK +} + +list_vms() { + limactl list | awk ' + NR == 1 { + printf "%-16s %-10s %-18s %-7s %-9s %-9s %s\n", $1, $2, $3, $4, $5, $6, $7 + next + } + $1 ~ /^dvm-/ { + sub(/^dvm-/, "", $1) + printf "%-16s %-10s %-18s %-7s %-9s %-9s %s\n", $1, $2, $3, $4, $5, $6, $7 + } + ' +} + +stop_vm() { + local name + name="${1:-}" + [ -n "$name" ] || die "stop requires a VM name" + load_vm "$name" + vm_exists || die "VM does not exist: $DVM_LIMA_NAME" + limactl stop "$DVM_LIMA_NAME" +} + +rm_vm() { + local force name orphan vm_file yes + name="${1:-}" + [ -n "$name" ] || die "rm requires a VM name" + shift || true + force=0 + orphan=0 + yes=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --yes) yes=1 ;; + --force | -f) force=1 ;; + *) die "unknown rm option: $1" ;; + esac + shift + done + [ "$yes" = "1" ] || die "rm requires --yes" + name="$(public_vm_name "$name")" + vm_file="$DVM_CONFIG/vms/$name.sh" + if [ -f "$vm_file" ]; then + load_vm "$name" + else + DVM_LIMA_NAME="dvm-$name" + if vm_exists; then + orphan=1 + printf 'dvm: warning: deleting Lima VM without DVM config: %s (missing %s)\n' "$DVM_LIMA_NAME" "$vm_file" >&2 + fi + fi + if [ "$force" != "1" ] && vm_exists && [ -f "$vm_file" ]; then + dirty_check_vm || die "refusing to delete $DVM_LIMA_NAME; commit/stash changes or pass --force" + elif [ "$force" != "1" ] && [ "$orphan" = "1" ]; then + printf 'dvm: warning: dirty check skipped because DVM config is missing: %s\n' "$vm_file" >&2 + fi + limactl stop "$DVM_LIMA_NAME" >/dev/null 2>&1 || true + limactl delete "$DVM_LIMA_NAME" +} diff --git a/tests/smoke.sh b/tests/smoke.sh index f2945b0..c0a5425 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -169,6 +169,8 @@ grep -Fxq old-target "$TMP/old-dvm" [ -x "$TMP/install-bin/dvm" ] [ ! -L "$TMP/install-bin/dvm" ] grep -Fq 'dvm-run.' "$TMP/install-bin/dvm" +grep -Fq 'DVM_LIB_DIR="$tmp_parent/lib"' "$TMP/install-bin/dvm" +[ ! -e "$TMP/install-config/lib" ] "$TMP/install-bin/dvm" help >"$TMP/install-help.out" grep -Fq 'dvm init [template]' "$TMP/install-help.out"