Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 15 additions & 10 deletions bin/dvm
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [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
Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/lima.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions docs/security-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion share/dvm/lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 3 additions & 3 deletions share/dvm/lib/guest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions share/dvm/lib/vm_admin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down