diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e50d15..9c5ba46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Fixed per-VM `DVM_CODE_ROOT` overrides so the default `DVM_CODE_DIR` is computed + after VM config is sourced. +- Fixed remaining user-facing docs and diagnostics that still used old command names + after the `sync` / `sh` / `log` rename. - Renamed commands for shorter typing: `apply` → `sync`, `enter` → `sh`, `logs` → `log`, `list` → `ls`. The per-project hook moved from `.dvm/apply.sh` to `.dvm/sync.sh`; rename the file in projects that use it. @@ -19,6 +23,18 @@ - Moved `DVM_CHEZMOI_REPO`, `DVM_CHEZMOI_SIGNING_KEY`, and `DVM_CHEZMOI_DEPLOY_KEY` to global config; per-VM config only opts in via `use chezmoi` (per-VM override still works through the source order). +- Tightened CLI arg validation: `init`, `sync`, `sh`, `ssh-key`, `gpg-key`, `ls`, and + `stop` now reject unexpected extra arguments with a clear error rather than + silently ignoring them. +- Tightened `dvm rm` dirty check: when `git` is not installed in the guest, the + check now exits with status 2 and refuses to delete the VM unless `--force` is + passed. Previously the check exited cleanly when `git` was absent, allowing + silent deletion of un-checked code directories. +- Extended `dvm rm` dirty check to also count files under `DVM_CODE_DIR` that are + not enclosed by any `.git` tree; the count and a sample of paths are printed and + deletion is refused unless `--force` is passed. Previously only dirty Git repos + blocked deletion; loose data files (databases, notes, downloads) could be lost + silently. - Added a `bat` recipe that installs bat from Fedora and runs `bat cache --build`. - Added VM config validation before Lima template rendering for VM names, users, sizing, code directories, host IPs, and port forwards. diff --git a/SECURITY.md b/SECURITY.md index 524686e..7dd4166 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,12 +25,12 @@ Defaults: - `~` in DVM config means the guest user's home - AI tools run inside the VM through `dvm-agent` when the recipe is used - public dotfiles use HTTPS by default -- Cloudflare tokens are passed to apply explicitly, staged through a mode `0600` guest +- Cloudflare tokens are passed to sync explicitly, staged through a mode `0600` guest temp file, and written inside the VM - forwarded ports bind to `127.0.0.1` unless config says otherwise - `dvm rm --yes` checks nested Git repos before deleting unless `--force` is used -Most apply-time DVM environment values are visible to host process listings while +Most sync-time DVM environment values are visible to host process listings while `limactl` runs. Do not put secrets in general `DVM_*` config. The bundled cloudflared token handoff is special-cased so `CLOUDFLARED_TOKEN` and `DVM_CLOUDFLARED_TOKEN` are not passed as `limactl shell env` arguments. diff --git a/bin/dvm b/bin/dvm index dedff31..330de5e 100755 --- a/bin/dvm +++ b/bin/dvm @@ -44,23 +44,28 @@ main() { local cmd="${1:-help}" [ "$#" -eq 0 ] || shift case "$cmd" in - init) init_vm "$@" ;; + init) + case "$#" in + 1 | 2) init_vm "$@" ;; + *) die "init takes [template]" ;; + esac + ;; sync) case "${1:-}" in - --all) apply_all ;; + --all) [ "$#" -eq 1 ] || die "sync --all takes no other arguments"; apply_all ;; '') die "sync requires a VM name or --all" ;; - *) apply_one "$1" ;; + *) [ "$#" -eq 1 ] || die "sync takes one VM name"; apply_one "$1" ;; esac ;; - sh) [ -n "${1:-}" ] || die "sh requires a VM name"; enter_vm "$1" ;; + sh) [ "$#" -eq 1 ] || die "sh takes one VM name"; enter_vm "$1" ;; ssh) [ -n "${1:-}" ] || die "ssh requires a VM name"; ssh_vm "$@" ;; cp) cp_vm "$@" ;; - log) logs_vm "$@" ;; - ssh-key) ssh_key_vm "$@" ;; - gpg-key) gpg_key_vm "$@" ;; - ls) list_vms ;; - stop) stop_vm "$@" ;; - rm) rm_vm "$@" ;; + log) [ "$#" -ge 1 ] || die "log requires a VM name"; logs_vm "$@" ;; + ssh-key) [ "$#" -eq 1 ] || die "ssh-key takes one VM name"; ssh_key_vm "$@" ;; + gpg-key) [ "$#" -eq 1 ] || die "gpg-key takes one VM name"; gpg_key_vm "$@" ;; + ls) [ "$#" -eq 0 ] || die "ls takes no arguments"; list_vms ;; + stop) [ "$#" -eq 1 ] || die "stop takes one VM name"; stop_vm "$@" ;; + rm) [ "$#" -ge 1 ] || die "rm requires a VM name"; rm_vm "$@" ;; help | -h | --help) usage ;; *) usage >&2; die "unknown command: $cmd" ;; esac diff --git a/docs/config.md b/docs/config.md index 3d6a41b..9d9e22b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -119,7 +119,7 @@ them from per-VM configs. Service recipes (`llama`, `cloudflared`) are not in `DVM_LLAMA_MODELS_SHA256`, `DVM_LLAMA_REFRESH`: llama model settings. - `DVM_CLOUDFLARED_SERVICE`, `DVM_CLOUDFLARED_TOKEN`: cloudflared service settings. The bundled cloudflared recipe receives the token through a mode `0600` guest temp - file during `apply`, so it is not passed as a `limactl shell env` argument. + file during `sync`, so it is not passed as a `limactl shell env` argument. - `DVM_NO_BASELINE=1`: skip the implicit `baseline` recipe. Service VMs use this to avoid dev-tool setup; recipes selected by that VM must install their own dependencies such as `git`, `curl`, `jq`, `tar`, or `unzip`. diff --git a/docs/lima.md b/docs/lima.md index c02cc77..c01a256 100644 --- a/docs/lima.md +++ b/docs/lima.md @@ -103,7 +103,7 @@ failed state after confirming the logs: dvm ssh app -- sudo systemctl reset-failed cloud-final.service cloud-init-main.service ``` -If Lima briefly misses an existing instance during `apply`, `ssh`, `enter`, or other DVM +If Lima briefly misses an existing instance during `sync`, `ssh`, `sh`, or other DVM commands, DVM also checks the local Lima instance directory before deciding the VM is missing. If that local directory exists but `limactl start` cannot start it, DVM reports the likely stale instance directory path so you can inspect or remove it. diff --git a/docs/security-standards.md b/docs/security-standards.md index 94e6bc8..8c9a82f 100644 --- a/docs/security-standards.md +++ b/docs/security-standards.md @@ -22,9 +22,9 @@ small, but they are the bar for changes. - The VM-local GPG helper creates an unencrypted, one-year signing key for disposable VM use; do not treat it as a long-lived identity key. - Prefer repo-scoped deploy keys and service-scoped tokens. -- Pass Cloudflare tokens only when applying the cloudflared VM, or fetch them from - macOS Keychain in your shell before apply. -- Do not put secrets in general `DVM_*` config. Most apply-time DVM values are visible +- Pass Cloudflare tokens only when syncing the cloudflared VM, or fetch them from + macOS Keychain in your shell before sync. +- Do not put secrets in general `DVM_*` config. Most sync-time DVM values are visible to host process listings while `limactl` runs. - The bundled cloudflared token handoff is special-cased: `CLOUDFLARED_TOKEN` and `DVM_CLOUDFLARED_TOKEN` are staged through a mode `0600` guest temp file instead of diff --git a/share/dvm/lib/core.sh b/share/dvm/lib/core.sh index 9771223..0de68b9 100644 --- a/share/dvm/lib/core.sh +++ b/share/dvm/lib/core.sh @@ -224,7 +224,6 @@ load_vm() { 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}" diff --git a/share/dvm/lib/guest.sh b/share/dvm/lib/guest.sh index 90184a2..93ee7bd 100644 --- a/share/dvm/lib/guest.sh +++ b/share/dvm/lib/guest.sh @@ -298,21 +298,21 @@ default_log_unit() { logs_vm() { local name unit name="${1:-}" - [ -n "$name" ] || die "logs requires a VM name" + [ -n "$name" ] || die "log requires a VM name" shift || true load_vm "$name" vm_exists || die "VM does not exist: $DVM_LIMA_NAME; run dvm sync $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="$(default_log_unit)" || die "log 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" + unit="$(default_log_unit)" || die "log requires a unit when the VM has zero or multiple known service recipes" fi if [ "$#" -eq 0 ]; then set -- --no-pager -n 100 diff --git a/share/dvm/lib/vm_admin.sh b/share/dvm/lib/vm_admin.sh index ff9dced..4cab505 100644 --- a/share/dvm/lib/vm_admin.sh +++ b/share/dvm/lib/vm_admin.sh @@ -10,7 +10,10 @@ case "$code_dir" in "~/"*) code_dir="$HOME/${code_dir#\~/}" ;; esac [ -d "$code_dir" ] || exit 0 -command -v git >/dev/null 2>&1 || exit 0 +if ! command -v git >/dev/null 2>&1; then + printf 'dvm: dirty check skipped: git not installed in VM\n' >&2 + exit 2 +fi dirty=0 while IFS= read -r git_entry; do if [ -d "$git_entry" ]; then @@ -25,6 +28,29 @@ while IFS= read -r git_entry; do dirty=1 fi done < <(find "$code_dir" \( -type d -name .git -prune -print \) -o \( -type f -name .git -print \)) + +orphan_count=0 +orphan_sample=() +while IFS= read -r f; do + orphan_count=$((orphan_count + 1)) + if [ "${#orphan_sample[@]}" -lt 5 ]; then + orphan_sample+=("$f") + fi +done < <(find "$code_dir" \ + \( -type d -exec test -e {}/.git \; -prune \) -o \ + \( -type d -name .git -prune \) -o \ + \( -type f -print \) 2>/dev/null) + +if [ "$orphan_count" -gt 0 ]; then + printf 'dvm: %d file(s) outside any git repository under %s:\n' "$orphan_count" "$code_dir" >&2 + for f in "${orphan_sample[@]}"; do + printf ' %s\n' "$f" >&2 + done + if [ "$orphan_count" -gt "${#orphan_sample[@]}" ]; then + printf ' ... (%d more)\n' "$((orphan_count - ${#orphan_sample[@]}))" >&2 + fi + dirty=1 +fi exit "$dirty" DVM_DIRTY_CHECK } @@ -80,7 +106,14 @@ rm_vm() { 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" + local rc=0 + dirty_check_vm || rc=$? + case "$rc" in + 0) ;; + 1) die "refusing to delete $DVM_LIMA_NAME; commit/stash changes, move untracked files, or pass --force" ;; + 2) die "refusing to delete $DVM_LIMA_NAME; dirty check incomplete (see warning above), pass --force to skip" ;; + *) die "refusing to delete $DVM_LIMA_NAME; dirty check failed with status $rc, pass --force to skip" ;; + esac elif [ "$force" != "1" ] && [ "$orphan" = "1" ]; then printf 'dvm: warning: dirty check skipped because DVM config is missing: %s\n' "$vm_file" >&2 fi diff --git a/tests/smoke.sh b/tests/smoke.sh index d2265fa..d9e99ed 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -58,6 +58,15 @@ DVM_CODE_DIR="~/code/second" use python VM +cat >"$TMP/config/vms/rooted.sh" <<'VM' +DVM_CPUS=2 +DVM_MEMORY=4GiB +DVM_DISK=20GiB +DVM_CODE_ROOT="~/work" + +use python +VM + cat >"$TMP/config/vms/cloudflared.sh" <<'VM' DVM_CPUS=2 DVM_MEMORY=2GiB @@ -192,6 +201,21 @@ status="$?" set -e [ "$status" -ne 0 ] grep -Fq 'missing VM template: missing-template' "$TMP/init-bad.err" + +set +e +"$ROOT/bin/dvm" sync app trailing-garbage >/dev/null 2>"$TMP/sync-extra.err" +status="$?" +set -e +[ "$status" -ne 0 ] +grep -Fq 'sync takes one VM name' "$TMP/sync-extra.err" + +set +e +"$ROOT/bin/dvm" ls extra >/dev/null 2>"$TMP/ls-extra.err" +status="$?" +set -e +[ "$status" -ne 0 ] +grep -Fq 'ls takes no arguments' "$TMP/ls-extra.err" + rm -f "$TMP/config/vms/newapp.sh" "$TMP/config/vms/llama.sh" "$ROOT/bin/dvm" sync app 2>"$TMP/apply.err" @@ -244,6 +268,10 @@ grep -Fq 'dvm project hook' "$TMP/state/guest.sh" grep -Fq 'hostPort: 3000' "$TMP/state/lima.yaml" bash -n "$TMP/state/guest.sh" +: >"$TMP/state/log" +"$ROOT/bin/dvm" sync rooted +grep -Fq 'DVM_CODE_DIR=~/work/rooted' "$TMP/state/log" + : >"$TMP/state/log" CLOUDFLARED_TOKEN="smoke.Token_123=-" "$ROOT/bin/dvm" sync cloudflared grep -Fq 'dvm-cloudflared-token.' "$TMP/state/guest.sh"