docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer (#220)
## Summary Adds a full Debian 13 dev-VM setup procedure ([docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md)) + 10 modular idempotent setup scripts + a systemd template + a `.devcontainer/` spec, in preparation for the new dev VM (`10.100.201.21`) replacing the WSL-based workflow. Both IDE flows (VSCode Remote-SSH + Devcontainer) and both Node toolchains (nvm on host + devcontainer image) are available — devs pick per task. Adjacent context (does not ship here, planned follow-up): - **Preview infra on the GitLab VM (`10.100.201.10`)** — same `dev.compose.yml`, deployed by CI on `main`. Doc placeholder in §8.6. - **GitLab Runner migration** (act_runner Gitea → GitLab Runner Docker executor) — bundled with the Gitea → GitLab cutover. - **Private dotfiles repo** (`apf/dotfiles`) — `~/.zshrc`, `~/.p10k.zsh`, `~/.tmux.conf` versioned. `80-dotfiles.sh` is already structured to fall back to a `~/.dotfiles/` clone when present. No application-code changes. No CI gate impact (doc + scripts + devcontainer spec only). ## What lands | Path | Change | | --- | --- | | `docs/setup/01-dev-debian-vm-setup.md` | **New.** Step-by-step doc: workstation prep (SSH agent + VSCode Remote-SSH + fonts), bootstrap orchestrator, per-script effects, project clone, infra boot, IDE flow A / B / C, apf-ai-service .NET appendix, troubleshooting. | | `docs/setup/02-wsl-terminal-setup.md` | Renamed from `01-wsl-terminal-setup.md`. No content change. | | `docs/setup/03-dev-web-stack.md` | Renamed from `02-dev-web-stack.md`. No content change. | | `docs/setup/04-angular-nx-monorepo.md` | Renamed from `03-angular-nx-monorepo.md`. No content change. | | `docs/setup/README.md` | **New.** Index of the `docs/setup/` folder. | | `docs/setup/scripts/lib.sh` | **New.** Shared helpers — colour-coded log/ok/warn/err/skip, `apt_install` skipping already-installed, `ensure_line` idempotent append, `confirm` prompt. | | `docs/setup/scripts/bootstrap.sh` | **New.** Orchestrator running scripts 10..80 in order with confirmation prompts. | | `docs/setup/scripts/10-base-packages.sh` | **New.** apt update + base packages (curl, wget, git, build-essential, …). | | `docs/setup/scripts/20-zsh.sh` | **New.** zsh + Oh My Zsh (RUNZSH=no, no shell hijack) + Powerlevel10k + `zsh-autosuggestions` + `zsh-syntax-highlighting`. Patches `~/.zshrc` (theme, plugins, fzf hook). | | `docs/setup/scripts/30-cli-tools.sh` | **New.** `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`. Symlinks Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`) into `~/.local/bin` so notes/aliases.zsh works as-is. | | `docs/setup/scripts/40-node.sh` | **New.** nvm v0.40.1 + Node from `.nvmrc` (currently 24) + corepack enable + pnpm warmed from `package.json#packageManager`. | | `docs/setup/scripts/50-docker.sh` | **New.** Docker CE + compose plugin from docker.com apt repo, user added to `docker` group, `docker.service` enabled at boot. Pinned GPG key + repo line for Debian Trixie. | | `docs/setup/scripts/60-tuning.sh` | **New.** `fs.inotify.max_user_watches=524288` (Vite/Nx watch ceiling), optional 4 GB swapfile, optional hostname rename (only prompts on generic hostnames like `debian13`). | | `docs/setup/scripts/70-hardening.sh` | **New.** Best-effort UFW (`allow OpenSSH` only) + unattended-upgrades on security channel + fail2ban + sshd drop-in (`PermitRootLogin no`, `PasswordAuthentication no`, `AllowAgentForwarding yes`). Probes before applying, validates `sshd -t` before reloading, skips cleanly if infra already locked the box down. | | `docs/setup/scripts/80-dotfiles.sh` | **New.** Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh` (backs up an existing target). Copies `notes/gitconfig.txt` to `~/.gitconfig`, prompts for identity, applies via `git config --global user.name/email`. | | `docs/setup/systemd/apf-portal-infra@.service` | **New.** Template systemd unit auto-starting `./infra/local/dev.sh up` at boot. Install: `enable apf-portal-infra@$USER.service`. | | `.devcontainer/devcontainer.json` | **New.** VSCode Dev Container spec on `mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm`. `docker-outside-of-docker` feature + `--network=apf-portal-dev` so DNS to `postgres`/`redis`/`otel-collector` works. `initializeCommand` fails fast if `dev.sh up` hasn't been run yet. Forwarded ports labelled. Six dev extensions pre-installed. | | `.devcontainer/post-create.sh` | **New.** `corepack enable && pnpm install --frozen-lockfile`. | | `CLAUDE.md` | "Environment conventions" rewritten: documents the two envs (`local` / `development`) + hybrid sub-mode + the two IDE flows (Remote-SSH / Devcontainer), points at the new VM setup doc and the legacy WSL doc. | ## Key choices - **Idempotent scripts, individually runnable.** Each script probes before doing anything (apt package already installed? plugin already cloned? UFW already active? sshd drop-in already present?). Bootstrap is the orchestrator; each script also runs standalone. Re-running after a partial setup is safe and reports `↪ skip` for the no-op cases. - **No private key on the VM — SSH agent forwarding instead.** `~/.ssh/config` on the workstation carries `ForwardAgent yes`; the VM never holds long-lived secrets. Same future pattern for GPG signing (covered in §8.5 as appendix). `keychain` is installed by `30-cli-tools.sh` as a fallback for scenarios where agent forwarding is not available (CI runners, scripts). - **Hardening is "best-effort, probe-first".** Some infra teams ship pre-hardened VMs; this script doesn't fight that. UFW already active? Print rules and skip. unattended-upgrades already on? Skip. SSH already locked down? Skip. Each section validates before reloading so a misconfig can't take SSH offline. - **Devcontainer assumes infra-on-host.** The container runs on the VM but talks to postgres / redis / otel **on the same VM's host docker daemon** through the shared `apf-portal-dev` Compose network. `initializeCommand` fails fast with a clear message if `./infra/local/dev.sh up` hasn't been run yet — better than puzzling `ECONNREFUSED` errors at runtime. - **`60-tuning.sh` raises inotify to 524288.** Default Debian limit is 8K; Vite/Nx in this monorepo blow past that. The setting is persisted in `/etc/sysctl.d/99-apf-portal.conf` so it survives reboot. - **Hybrid mode (workstation IDE + VM infra) is a documented sub-mode.** SSH `LocalForward` directives on 5432/6379/4317/4318 expose the VM's infra services as `localhost:*` on the workstation. Latency cost: 5-15 ms per query, fine for daily work; for long-running flows, wrap in `tmux` or use `autossh`. ## Notes for the reviewer - **Renaming the existing setup docs (01→02, 02→03, 03→04) is the only "destructive" change.** `git log --follow` still works because of `git mv`. Diff shows up as renames, not delete-and-add. - **The brief asked for `bat eza fd-find ripgrep fzf zoxide docker keychain ncdu git`.** `git` is installed by `10-base-packages.sh` (every other script needs it). Everything else lives in `30-cli-tools.sh` + `50-docker.sh`. The "fullstack-dev extras" (`jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`) are additions I proposed in the lock-in question and that you greenlit (full scope) — easy to trim if any of them turn out to be unwanted. - **Both Node toolchains in parallel** — nvm on the VM (via `40-node.sh`) **and** devcontainer in the repo. Devs can use either; both read the same `.nvmrc` + `packageManager` pin so the version stays consistent. - **The systemd unit is a TEMPLATE** (`apf-portal-infra@.service`) — install once, enable per-user (`enable apf-portal-infra@$USER.service`). This is the right shape for a shared VM with multiple devs eventually, even if today only one user uses it. - **No PR-body Co-Authored-By trailer, no Generated-with-Claude footer**, per the project rule. ## Test plan Manual (no automated test exists for this kind of setup work): - [ ] On a fresh Debian 13 VM: `git clone …`, `./docs/setup/scripts/bootstrap.sh`, answer prompts, end up with zsh + Powerlevel10k + all the requested CLI tools + Node 24 + pnpm 10.33.4 + Docker on PATH. - [ ] `./infra/local/dev.sh up` boots successfully against the VM's local docker daemon. - [ ] `pnpm install` + `pnpm exec nx run-many -t lint test --parallel=3` passes on the VM. - [ ] VSCode Remote-SSH from a workstation: connect, open `~/Works/apf_portal`, run `pnpm exec nx serve portal-bff`, confirm reaches `postgres:5432`. - [ ] VSCode Dev Containers from the same workstation: `Reopen in Container`, image builds, `postCreateCommand` runs `pnpm install`, dev server reaches postgres through the `apf-portal-dev` network. - [ ] Hybrid mode: SSH tunnel from workstation, `pnpm exec nx serve portal-bff` locally, confirm postgres reachable via `localhost:5432`. - [x] `pnpm exec prettier --check` clean on the touched markdown files. - [x] Scripts pass `bash -n` (syntax check) — verified during writing. ## What's next - Validate by walking through this doc on the actual VM `10.100.201.21`. Any friction surfaced becomes a follow-up PR (`docs(setup): ...`). - Once the dev VM is operational, return to **ADR-0027 Implementation PR 1** (Region / Delegation / Structure Prisma schema + inline reference-data migration) — paused since the start of this PR. - Set up the **private dotfiles repo** (`apf/dotfiles`) as a small follow-up, then teach `80-dotfiles.sh` to prefer the dotfiles repo over `notes/`. - When migrating to GitLab: PR pair — (a) `git remote set-url` doc updates here, (b) `infra/gitlab-runners/` replacing `infra/ci-runners.compose.yml`. - Future: deploy the **shared preview infra** on `vm-gitlab` (10.100.201.10) — CI-driven, separate PR. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# apt update + install base packages every other script assumes.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
log "apt update"
|
||||
sudo apt-get update -qq
|
||||
|
||||
apt_install \
|
||||
curl wget git ca-certificates gnupg lsb-release \
|
||||
software-properties-common apt-transport-https \
|
||||
build-essential pkg-config
|
||||
|
||||
ok "Base packages ready."
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting).
|
||||
# Patches ~/.zshrc to set the theme, plugin list, fzf hook.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
apt_install zsh
|
||||
|
||||
# Make zsh the default shell for $USER (idempotent).
|
||||
current_shell="$(getent passwd "$USER" | cut -d: -f7)"
|
||||
zsh_path="$(command -v zsh)"
|
||||
if [[ "$current_shell" != "$zsh_path" ]]; then
|
||||
log "Setting zsh as default shell for $USER (sudo will prompt)."
|
||||
sudo chsh -s "$zsh_path" "$USER"
|
||||
ok "Default shell set to $zsh_path. Takes effect on next login."
|
||||
else
|
||||
skip "zsh already default shell."
|
||||
fi
|
||||
|
||||
# Oh My Zsh — install with RUNZSH=no to avoid spawning zsh during the script.
|
||||
OMZ_DIR="$HOME/.oh-my-zsh"
|
||||
if [[ -d "$OMZ_DIR" ]]; then
|
||||
skip "Oh My Zsh already installed."
|
||||
else
|
||||
log "Installing Oh My Zsh."
|
||||
RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
|
||||
ok "Oh My Zsh installed."
|
||||
fi
|
||||
|
||||
ZSH_CUSTOM_DIR="${ZSH_CUSTOM:-$OMZ_DIR/custom}"
|
||||
|
||||
clone_or_skip() {
|
||||
local name="$1" url="$2" dir="$3"
|
||||
if [[ -d "$dir" ]]; then
|
||||
skip "$name already cloned at $dir."
|
||||
else
|
||||
log "Cloning $name."
|
||||
git clone --depth=1 "$url" "$dir"
|
||||
ok "$name installed."
|
||||
fi
|
||||
}
|
||||
|
||||
# Powerlevel10k theme.
|
||||
clone_or_skip "Powerlevel10k" \
|
||||
"https://github.com/romkatv/powerlevel10k.git" \
|
||||
"$ZSH_CUSTOM_DIR/themes/powerlevel10k"
|
||||
|
||||
# Plugins.
|
||||
clone_or_skip "zsh-autosuggestions" \
|
||||
"https://github.com/zsh-users/zsh-autosuggestions.git" \
|
||||
"$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions"
|
||||
|
||||
clone_or_skip "zsh-syntax-highlighting" \
|
||||
"https://github.com/zsh-users/zsh-syntax-highlighting.git" \
|
||||
"$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting"
|
||||
|
||||
# Patch ~/.zshrc — theme + plugin list + fzf hook.
|
||||
ZSHRC="$HOME/.zshrc"
|
||||
[[ -f "$ZSHRC" ]] || touch "$ZSHRC"
|
||||
|
||||
if grep -q '^ZSH_THEME=' "$ZSHRC"; then
|
||||
if grep -q '^ZSH_THEME="powerlevel10k/powerlevel10k"' "$ZSHRC"; then
|
||||
skip "ZSH_THEME already powerlevel10k."
|
||||
else
|
||||
sed -i 's#^ZSH_THEME=.*#ZSH_THEME="powerlevel10k/powerlevel10k"#' "$ZSHRC"
|
||||
ok "ZSH_THEME → powerlevel10k/powerlevel10k."
|
||||
fi
|
||||
else
|
||||
echo 'ZSH_THEME="powerlevel10k/powerlevel10k"' >> "$ZSHRC"
|
||||
ok 'Set ZSH_THEME=powerlevel10k/powerlevel10k.'
|
||||
fi
|
||||
|
||||
PLUGINS_LINE='plugins=(git fzf zoxide direnv zsh-autosuggestions zsh-syntax-highlighting)'
|
||||
if grep -q '^plugins=' "$ZSHRC"; then
|
||||
if grep -q "^$PLUGINS_LINE" "$ZSHRC"; then
|
||||
skip "plugins=(...) already configured."
|
||||
else
|
||||
sed -i "s#^plugins=.*#$PLUGINS_LINE#" "$ZSHRC"
|
||||
ok "plugins=(...) updated."
|
||||
fi
|
||||
else
|
||||
echo "$PLUGINS_LINE" >> "$ZSHRC"
|
||||
ok "plugins=(...) added."
|
||||
fi
|
||||
|
||||
ensure_line '[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh' "$ZSHRC"
|
||||
|
||||
ok "zsh + Oh My Zsh + Powerlevel10k ready."
|
||||
log "Next: run 'exec zsh -l' then 'p10k configure' to pick a Powerlevel10k preset."
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Modern CLI tools — what notes/aliases.zsh assumes + a fullstack-dev kit.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
# Requested in the project brief.
|
||||
apt_install bat eza fd-find ripgrep fzf zoxide ncdu keychain
|
||||
|
||||
# Useful extras for a fullstack dev workflow.
|
||||
apt_install jq yq httpie make tree htop tmux direnv dnsutils unzip rsync
|
||||
|
||||
# Debian renames `bat`→`batcat` and `fd`→`fdfind` to avoid binary
|
||||
# conflicts. notes/aliases.zsh aliases the Debian names back, but a
|
||||
# direct binary on PATH is more robust (works for non-interactive
|
||||
# scripts and other shells). Symlink in ~/.local/bin.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for pair in "batcat:bat" "fdfind:fd"; do
|
||||
src="${pair%%:*}"
|
||||
dst="${pair##*:}"
|
||||
src_path="$(command -v "$src" 2>/dev/null || true)"
|
||||
if [[ -z "$src_path" ]]; then
|
||||
warn "$src not found on PATH — skipping symlink."
|
||||
continue
|
||||
fi
|
||||
if [[ -L "$HOME/.local/bin/$dst" && "$(readlink "$HOME/.local/bin/$dst")" == "$src_path" ]]; then
|
||||
skip "$HOME/.local/bin/$dst already linked to $src."
|
||||
elif [[ -e "$HOME/.local/bin/$dst" ]]; then
|
||||
skip "$HOME/.local/bin/$dst exists (not a managed symlink) — leaving as-is."
|
||||
else
|
||||
ln -s "$src_path" "$HOME/.local/bin/$dst"
|
||||
ok "Linked $HOME/.local/bin/$dst → $src."
|
||||
fi
|
||||
done
|
||||
|
||||
# Make sure ~/.local/bin is on PATH for both bash and zsh.
|
||||
for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
|
||||
[[ -f "$rcfile" ]] || touch "$rcfile"
|
||||
ensure_line 'export PATH="$HOME/.local/bin:$PATH"' "$rcfile"
|
||||
done
|
||||
|
||||
ok "CLI tools installed."
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# nvm + Node from the repo's .nvmrc + corepack + pnpm at the version pinned
|
||||
# in package.json's packageManager field.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
NVM_VERSION="v0.40.1"
|
||||
NVM_DIR="$HOME/.nvm"
|
||||
|
||||
if [[ -d "$NVM_DIR" ]]; then
|
||||
skip "nvm already installed at $NVM_DIR."
|
||||
else
|
||||
log "Installing nvm $NVM_VERSION."
|
||||
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" | bash
|
||||
ok "nvm installed."
|
||||
fi
|
||||
|
||||
# Source nvm so we can use it in this shell.
|
||||
export NVM_DIR
|
||||
# shellcheck disable=SC1091
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Read .nvmrc from the repo root.
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$REPO_ROOT" ]]; then
|
||||
err "Run from inside a git checkout of apf_portal (no repo root found)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$REPO_ROOT/.nvmrc" ]]; then
|
||||
warn "$REPO_ROOT/.nvmrc not found — installing Node LTS as fallback."
|
||||
nvm install --lts
|
||||
nvm alias default 'lts/*'
|
||||
else
|
||||
NODE_VER="$(<"$REPO_ROOT/.nvmrc")"
|
||||
log "Installing Node $NODE_VER (from $REPO_ROOT/.nvmrc)."
|
||||
nvm install "$NODE_VER"
|
||||
nvm alias default "$NODE_VER"
|
||||
ok "Node $(node --version) active."
|
||||
fi
|
||||
|
||||
# corepack — provides pnpm + yarn shims, reads packageManager from package.json.
|
||||
corepack enable
|
||||
ok "corepack enabled."
|
||||
|
||||
# Warm pnpm to download the pinned version.
|
||||
if [[ -f "$REPO_ROOT/package.json" ]]; then
|
||||
pushd "$REPO_ROOT" >/dev/null
|
||||
pnpm --version >/dev/null
|
||||
ok "pnpm $(pnpm --version) ready (from package.json packageManager)."
|
||||
popd >/dev/null
|
||||
fi
|
||||
|
||||
log "Reload your shell (exec zsh -l) so nvm + node + pnpm are on PATH."
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Docker CE + compose plugin from the docker.com apt repo. Adds the
|
||||
# current user to the `docker` group and enables the daemon at boot.
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
if command -v docker >/dev/null 2>&1 \
|
||||
&& docker compose version >/dev/null 2>&1; then
|
||||
skip "docker + compose plugin already present."
|
||||
else
|
||||
log "Installing Docker CE from docker.com apt repo."
|
||||
|
||||
# GPG key.
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
| sudo tee /etc/apt/keyrings/docker.asc >/dev/null
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
# Repo definition.
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
# shellcheck disable=SC1091
|
||||
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
|
||||
REPO_LINE="deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable"
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/docker.list ]] \
|
||||
&& grep -qxF "$REPO_LINE" /etc/apt/sources.list.d/docker.list; then
|
||||
skip "docker.list already configured for $CODENAME."
|
||||
else
|
||||
echo "$REPO_LINE" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
|
||||
ok "Wrote /etc/apt/sources.list.d/docker.list."
|
||||
fi
|
||||
|
||||
sudo apt-get update -qq
|
||||
apt_install docker-ce docker-ce-cli containerd.io \
|
||||
docker-buildx-plugin docker-compose-plugin
|
||||
ok "Docker installed."
|
||||
fi
|
||||
|
||||
# Add user to docker group.
|
||||
if id -nG "$USER" | grep -qw docker; then
|
||||
skip "User $USER already in docker group."
|
||||
else
|
||||
sudo usermod -aG docker "$USER"
|
||||
warn "Added $USER to docker group — log out and back in for it to apply."
|
||||
fi
|
||||
|
||||
# Enable docker.service at boot.
|
||||
if systemctl is-enabled docker.service >/dev/null 2>&1; then
|
||||
skip "docker.service already enabled at boot."
|
||||
else
|
||||
sudo systemctl enable --now docker.service
|
||||
ok "docker.service enabled and started."
|
||||
fi
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# System-level tuning for Node / Vite / Nx development on a Debian VM:
|
||||
# - inotify watches limit (default 8K is too low for monorepos)
|
||||
# - hostname (prompt only if generic)
|
||||
# - swapfile (4 GB, only if none exists)
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
# 1) inotify watches.
|
||||
SYSCTL_FILE="/etc/sysctl.d/99-apf-portal.conf"
|
||||
INOTIFY_LINE="fs.inotify.max_user_watches=524288"
|
||||
if sudo grep -qxF "$INOTIFY_LINE" "$SYSCTL_FILE" 2>/dev/null; then
|
||||
skip "inotify watches already configured in $SYSCTL_FILE."
|
||||
else
|
||||
echo "$INOTIFY_LINE" | sudo tee "$SYSCTL_FILE" >/dev/null
|
||||
sudo sysctl --system >/dev/null
|
||||
ok "fs.inotify.max_user_watches → 524288."
|
||||
fi
|
||||
|
||||
# 2) Hostname — only prompt if it looks generic.
|
||||
current_hostname="$(hostname)"
|
||||
case "$current_hostname" in
|
||||
debian|debian13|localhost|trixie|debian12)
|
||||
read -r -p "Current hostname '$current_hostname' looks generic. New hostname (blank to keep): " new_hostname
|
||||
if [[ -n "$new_hostname" ]]; then
|
||||
sudo hostnamectl set-hostname "$new_hostname"
|
||||
ok "Hostname set to '$new_hostname'. Will reflect on next login."
|
||||
else
|
||||
skip "Hostname kept as '$current_hostname'."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
skip "Hostname '$current_hostname' looks intentional — keeping."
|
||||
;;
|
||||
esac
|
||||
|
||||
# 3) Swap.
|
||||
if swapon --show | grep -q .; then
|
||||
skip "Swap already active: $(swapon --show --noheadings | head -1 | awk '{print $1, $3}')."
|
||||
else
|
||||
if confirm "No swap detected. Add a 4 GB swapfile at /swapfile?"; then
|
||||
sudo fallocate -l 4G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
ensure_line_sudo "/swapfile none swap sw 0 0" /etc/fstab
|
||||
ok "4 GB swapfile active."
|
||||
else
|
||||
skip "No swap configured (user choice)."
|
||||
fi
|
||||
fi
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Best-effort VM hardening — probes current state, only applies what's missing.
|
||||
# Skips cleanly when infra has already configured a step.
|
||||
# - UFW (allow SSH only)
|
||||
# - unattended-upgrades (security channel)
|
||||
# - fail2ban
|
||||
# - sshd: disable root login + password auth (always validates first)
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
log "Hardening pass — each section probes and skips if already done."
|
||||
|
||||
# ─── 1. UFW ────────────────────────────────────────────────────────────
|
||||
if ! command -v ufw >/dev/null; then
|
||||
apt_install ufw
|
||||
fi
|
||||
if sudo ufw status | head -1 | grep -q 'Status: active'; then
|
||||
skip "UFW already active. Current rules:"
|
||||
sudo ufw status numbered | sed 's/^/ /'
|
||||
else
|
||||
if confirm "Enable UFW with 'deny incoming, allow SSH'?"; then
|
||||
sudo ufw default deny incoming >/dev/null
|
||||
sudo ufw default allow outgoing >/dev/null
|
||||
sudo ufw allow OpenSSH >/dev/null
|
||||
sudo ufw --force enable
|
||||
ok "UFW enabled (SSH-only ingress)."
|
||||
else
|
||||
skip "UFW left disabled."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 2. unattended-upgrades ────────────────────────────────────────────
|
||||
apt_install unattended-upgrades
|
||||
AUTO_UP="/etc/apt/apt.conf.d/20auto-upgrades"
|
||||
if [[ -f "$AUTO_UP" ]] \
|
||||
&& sudo grep -q 'APT::Periodic::Update-Package-Lists "1"' "$AUTO_UP" \
|
||||
&& sudo grep -q 'APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UP"; then
|
||||
skip "unattended-upgrades already enabled."
|
||||
else
|
||||
cat <<'EOF' | sudo tee "$AUTO_UP" >/dev/null
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
EOF
|
||||
ok "unattended-upgrades enabled."
|
||||
fi
|
||||
|
||||
# ─── 3. fail2ban ───────────────────────────────────────────────────────
|
||||
apt_install fail2ban
|
||||
if systemctl is-active fail2ban.service >/dev/null 2>&1; then
|
||||
skip "fail2ban already running."
|
||||
else
|
||||
sudo systemctl enable --now fail2ban.service
|
||||
ok "fail2ban started + enabled at boot."
|
||||
fi
|
||||
|
||||
# ─── 4. sshd hardening ────────────────────────────────────────────────
|
||||
# Drop-in file rather than editing /etc/ssh/sshd_config directly.
|
||||
# We always validate with `sshd -t` before reloading the daemon.
|
||||
SSHD_DROPIN="/etc/ssh/sshd_config.d/99-apf-portal-hardening.conf"
|
||||
if sudo test -f "$SSHD_DROPIN"; then
|
||||
skip "sshd hardening drop-in already at $SSHD_DROPIN."
|
||||
else
|
||||
warn "About to disable password auth + root login on SSH."
|
||||
warn "Make sure your SSH key already works from your workstation."
|
||||
if confirm "Continue?"; then
|
||||
cat <<'EOF' | sudo tee "$SSHD_DROPIN" >/dev/null
|
||||
# Managed by docs/setup/scripts/70-hardening.sh.
|
||||
# To override or extend, drop another file in /etc/ssh/sshd_config.d/.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
UsePAM yes
|
||||
AllowAgentForwarding yes
|
||||
EOF
|
||||
if sudo sshd -t 2>/dev/null; then
|
||||
sudo systemctl reload ssh.service 2>/dev/null || sudo systemctl reload sshd.service
|
||||
ok "sshd reloaded with hardened config."
|
||||
else
|
||||
err "sshd config validation failed — removing $SSHD_DROPIN."
|
||||
sudo rm -f "$SSHD_DROPIN"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
skip "sshd hardening declined."
|
||||
fi
|
||||
fi
|
||||
|
||||
ok "Hardening pass complete."
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wire up the project-specific dotfiles:
|
||||
# - symlink notes/aliases.zsh → ~/.oh-my-zsh/custom/aliases.zsh
|
||||
# - copy notes/gitconfig.txt to ~/.gitconfig and overlay your identity
|
||||
set -euo pipefail
|
||||
# shellcheck source=./lib.sh
|
||||
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -z "$REPO_ROOT" ]]; then
|
||||
err "Run from inside a git checkout of apf_portal."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── 1. Aliases ───────────────────────────────────────────────────────
|
||||
SOURCE_ALIASES="$REPO_ROOT/notes/aliases.zsh"
|
||||
if [[ ! -f "$SOURCE_ALIASES" ]]; then
|
||||
err "$SOURCE_ALIASES not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OMZ_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}"
|
||||
if [[ ! -d "$OMZ_CUSTOM" ]]; then
|
||||
err "$OMZ_CUSTOM not found — run 20-zsh.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_ALIASES="$OMZ_CUSTOM/aliases.zsh"
|
||||
if [[ -L "$TARGET_ALIASES" \
|
||||
&& "$(readlink -f "$TARGET_ALIASES")" == "$(readlink -f "$SOURCE_ALIASES")" ]]; then
|
||||
skip "aliases.zsh symlink already correct."
|
||||
else
|
||||
if [[ -e "$TARGET_ALIASES" || -L "$TARGET_ALIASES" ]]; then
|
||||
backup="$TARGET_ALIASES.bak.$(date +%s)"
|
||||
mv "$TARGET_ALIASES" "$backup"
|
||||
warn "Existing $TARGET_ALIASES backed up to $backup."
|
||||
fi
|
||||
ln -s "$SOURCE_ALIASES" "$TARGET_ALIASES"
|
||||
ok "Linked $TARGET_ALIASES → $SOURCE_ALIASES."
|
||||
fi
|
||||
|
||||
# ─── 2. gitconfig — copy + identity overlay ───────────────────────────
|
||||
SOURCE_GITCONFIG="$REPO_ROOT/notes/gitconfig.txt"
|
||||
if [[ ! -f "$SOURCE_GITCONFIG" ]]; then
|
||||
err "$SOURCE_GITCONFIG not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_GITCONFIG="$HOME/.gitconfig"
|
||||
if [[ -f "$TARGET_GITCONFIG" ]] && ! confirm "$TARGET_GITCONFIG exists — overwrite (existing backed up)?"; then
|
||||
skip "Keeping existing $TARGET_GITCONFIG."
|
||||
else
|
||||
if [[ -f "$TARGET_GITCONFIG" ]]; then
|
||||
cp "$TARGET_GITCONFIG" "$TARGET_GITCONFIG.bak.$(date +%s)"
|
||||
fi
|
||||
|
||||
# Prompt for identity. Defaults: pre-existing config values, else blank.
|
||||
current_name="$(git config --global user.name 2>/dev/null || true)"
|
||||
current_email="$(git config --global user.email 2>/dev/null || true)"
|
||||
read -r -p "Git user.name [${current_name:-<required>}]: " new_name
|
||||
new_name="${new_name:-$current_name}"
|
||||
read -r -p "Git user.email [${current_email:-<required>}]: " new_email
|
||||
new_email="${new_email:-$current_email}"
|
||||
if [[ -z "$new_name" || -z "$new_email" ]]; then
|
||||
err "Both user.name and user.email are required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$SOURCE_GITCONFIG" "$TARGET_GITCONFIG"
|
||||
# `git config --global` finds and rewrites the [user] section
|
||||
# cleanly even when the source carries a different identity.
|
||||
git config --global user.name "$new_name"
|
||||
git config --global user.email "$new_email"
|
||||
|
||||
ok "Wrote $TARGET_GITCONFIG with identity $new_name <$new_email>."
|
||||
fi
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrator — runs every numbered setup script in order with a confirmation prompt.
|
||||
# Each script is also runnable individually:
|
||||
# ./docs/setup/scripts/40-node.sh
|
||||
# See docs/setup/01-dev-debian-vm-setup.md for the step-by-step doc.
|
||||
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=./lib.sh
|
||||
source "$HERE/lib.sh"
|
||||
|
||||
require_debian
|
||||
require_not_root
|
||||
|
||||
SCRIPTS=(
|
||||
10-base-packages.sh
|
||||
20-zsh.sh
|
||||
30-cli-tools.sh
|
||||
40-node.sh
|
||||
50-docker.sh
|
||||
60-tuning.sh
|
||||
70-hardening.sh
|
||||
80-dotfiles.sh
|
||||
)
|
||||
|
||||
log "Bootstrap will run, in order:"
|
||||
for s in "${SCRIPTS[@]}"; do
|
||||
printf ' - %s\n' "$s"
|
||||
done
|
||||
echo
|
||||
|
||||
if ! confirm "Continue?"; then
|
||||
log "Aborted before any change."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for s in "${SCRIPTS[@]}"; do
|
||||
echo
|
||||
log "═══ $s ═══"
|
||||
if confirm "Run $s now?"; then
|
||||
"$HERE/$s"
|
||||
ok "$s done"
|
||||
else
|
||||
skip "$s"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
ok "Bootstrap complete."
|
||||
log "Next steps:"
|
||||
printf ' - exec zsh -l # reload shell to pick up zsh + PATH\n'
|
||||
printf ' - p10k configure # only if you skipped during 20-zsh.sh\n'
|
||||
printf ' - cp infra/local/.env.example infra/local/.env && $EDITOR infra/local/.env\n'
|
||||
printf ' - ./infra/local/dev.sh up\n'
|
||||
printf ' - pnpm install\n'
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for docs/setup/scripts/*.sh — sourced from each script.
|
||||
# Idempotency utilities + nice logging.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
C_GREEN=$'\033[1;32m'
|
||||
C_YELLOW=$'\033[1;33m'
|
||||
C_RED=$'\033[1;31m'
|
||||
C_BLUE=$'\033[1;34m'
|
||||
C_DIM=$'\033[2m'
|
||||
C_RESET=$'\033[0m'
|
||||
else
|
||||
C_GREEN= C_YELLOW= C_RED= C_BLUE= C_DIM= C_RESET=
|
||||
fi
|
||||
|
||||
log() { printf '%s[%s]%s %s\n' "$C_BLUE" "$(date +%H:%M:%S)" "$C_RESET" "$*"; }
|
||||
ok() { printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
||||
warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
|
||||
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
|
||||
skip() { printf '%s↪ skip%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
|
||||
|
||||
require_debian() {
|
||||
if ! grep -q '^ID=debian' /etc/os-release 2>/dev/null; then
|
||||
err "This script targets Debian (read from /etc/os-release)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_not_root() {
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
err "Run as your regular user (sudo is invoked where needed)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="${1:-Proceed?}"
|
||||
read -r -p "$prompt [y/N] " ans
|
||||
[[ "$ans" =~ ^[yY]$ ]]
|
||||
}
|
||||
|
||||
# Install apt packages, skipping those already present. Idempotent.
|
||||
apt_install() {
|
||||
local missing=()
|
||||
for pkg in "$@"; do
|
||||
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
|
||||
missing+=("$pkg")
|
||||
fi
|
||||
done
|
||||
if (( ${#missing[@]} == 0 )); then
|
||||
skip "All packages already present: $*"
|
||||
return 0
|
||||
fi
|
||||
log "apt install: ${missing[*]}"
|
||||
sudo apt-get install -y "${missing[@]}"
|
||||
ok "Installed: ${missing[*]}"
|
||||
}
|
||||
|
||||
# Append a line to a file iff not already present (exact match). Idempotent.
|
||||
ensure_line() {
|
||||
local line="$1"
|
||||
local file="$2"
|
||||
if grep -qxF -- "$line" "$file" 2>/dev/null; then
|
||||
skip "Already in $(basename "$file"): $line"
|
||||
else
|
||||
echo "$line" >> "$file"
|
||||
ok "Appended to $file: $line"
|
||||
fi
|
||||
}
|
||||
|
||||
# Same but with sudo (system files).
|
||||
ensure_line_sudo() {
|
||||
local line="$1"
|
||||
local file="$2"
|
||||
if sudo grep -qxF -- "$line" "$file" 2>/dev/null; then
|
||||
skip "Already in $(basename "$file"): $line"
|
||||
else
|
||||
echo "$line" | sudo tee -a "$file" >/dev/null
|
||||
ok "Appended to $file: $line"
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user