From a564c258cfd183f2c409b7340d9dfe4ea1b5b459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:36:32 +0800 Subject: [PATCH 01/18] ci: sync crate versions with the release tag clap's `#[command(version)]` reads CARGO_PKG_VERSION, which is baked in at compile time from Cargo.toml, so `--version` reported whatever the crates were last set to rather than the tag the binary shipped under. `scripts/release/set-version.sh` rewrites every workspace member, the `exedev-core` path-dependency requirement, and Cargo.lock (the release build runs with --locked). The release workflow runs it before the build step. `scripts/release/sync-homebrew-tap.sh` regenerates the tap formula from the published release assets, checking the archive payload against the member names the formula installs. --- .github/workflows/release.yml | 13 ++ scripts/release/set-version.sh | 95 +++++++++++++++ scripts/release/sync-homebrew-tap.sh | 171 +++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100755 scripts/release/set-version.sh create mode 100755 scripts/release/sync-homebrew-tap.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 245147b..2c9947b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,19 @@ jobs: ;; esac + # Must run before the build: clap bakes CARGO_PKG_VERSION in at compile time, so a + # binary built ahead of this step reports whatever the crates were last set to + # rather than the tag it ships under. + - name: Sync crate versions with release tag + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + tag="${{ inputs.tag_name }}" + fi + scripts/release/set-version.sh "${tag}" + - name: Build optimized release binaries run: cargo build --profile dist --locked --target ${{ matrix.target }} -p exedev-ctl -p exedev-k8s diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh new file mode 100755 index 0000000..2ca8b99 --- /dev/null +++ b/scripts/release/set-version.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sets every workspace crate to the release version. +# +# clap's `#[command(version)]` reads CARGO_PKG_VERSION, which is baked in at compile +# time from Cargo.toml. The release tag never reaches the binary on its own, so without +# this step `exedev-ctl --version` reports whatever the crates happened to be set to +# when the tag was cut, and disagrees with the version users installed. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +VERSION="${1:-${RELEASE_TAG:-}}" + +# Workspace members whose [package] version is the release version. +MEMBERS=(core cli k8s_cli) +# Keys under [workspace.dependencies] that resolve to a member by path. Their version +# requirement has to keep accepting the member, which stops holding across a minor bump. +PATH_DEP_KEYS=(exedev-core) + +if [[ -z "$VERSION" ]]; then + echo "usage: $(basename "$0") " >&2 + echo "Accepts either 0.1.22 or v0.1.22; RELEASE_TAG is used when no argument is given." >&2 + exit 1 +fi + +VERSION="${VERSION#v}" + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "not a semantic version: $VERSION" >&2 + exit 1 +fi + +set_package_version() { + local file="$1" + awk -v ver="$VERSION" ' + /^\[/ { section = $0 } + section == "[package]" && !replaced && /^version[[:space:]]*=/ { + print "version = \"" ver "\"" + replaced = 1 + next + } + { print } + END { exit replaced ? 0 : 1 } + ' "$file" > "$file.tmp" +} + +set_path_dep_version() { + local file="$1" key="$2" + awk -v key="$key" -v ver="$VERSION" ' + index($0, key "=") == 1 || index($0, key " =") == 1 { + if (sub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" ver "\"")) replaced = 1 + } + { print } + END { exit replaced ? 0 : 1 } + ' "$file" > "$file.tmp" +} + +for member in "${MEMBERS[@]}"; do + manifest="$REPO_ROOT/$member/Cargo.toml" + if [[ ! -f "$manifest" ]]; then + echo "workspace member has no manifest: $manifest" >&2 + exit 1 + fi + if ! set_package_version "$manifest"; then + rm -f "$manifest.tmp" + echo "no [package] version to replace in $manifest" >&2 + exit 1 + fi + mv "$manifest.tmp" "$manifest" +done + +for key in "${PATH_DEP_KEYS[@]}"; do + if ! set_path_dep_version "$REPO_ROOT/Cargo.toml" "$key"; then + rm -f "$REPO_ROOT/Cargo.toml.tmp" + echo "no versioned '$key' entry to replace in $REPO_ROOT/Cargo.toml" >&2 + exit 1 + fi + mv "$REPO_ROOT/Cargo.toml.tmp" "$REPO_ROOT/Cargo.toml" +done + +# The release build runs with --locked, which fails outright when Cargo.lock still +# carries the old member versions. Refresh it here rather than leaving the build to +# discover the mismatch. +(cd "$REPO_ROOT" && cargo update --workspace --quiet) + +echo "Set workspace version: $VERSION" +for member in "${MEMBERS[@]}"; do + echo " $member/Cargo.toml" +done +for key in "${PATH_DEP_KEYS[@]}"; do + echo " Cargo.toml [workspace.dependencies] $key" +done +echo " Cargo.lock" diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh new file mode 100755 index 0000000..11bb9b1 --- /dev/null +++ b/scripts/release/sync-homebrew-tap.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +FORMULA_NAME="${FORMULA_NAME:-exedev-cli}" +FORMULA_CLASS="${FORMULA_CLASS:-ExedevCli}" +FORMULA_DESC="${FORMULA_DESC:-Unofficial CLI for exe.dev}" +FORMULA_LICENSE="${FORMULA_LICENSE:-OSL-3.0}" +REPO_SLUG="${REPO_SLUG:-lollipopkit/exedev-cli}" +TAP_REPO_PATH="${TAP_REPO_PATH:-$HOME/proj/homebrew-tap}" +TAP_FORMULA_PATH="${TAP_FORMULA_PATH:-}" +EXPLICIT_TAP_FORMULA_PATH="${TAP_FORMULA_PATH:-}" +RELEASE_TAG="${1:-${RELEASE_TAG:-}}" + +# Keep in sync with the `Package release archive` step in .github/workflows/release.yml: +# the archive name and the binaries it carries are defined there, and a formula that +# guesses either one installs nothing. +ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-exedev-clis}" +BINARIES=(exedev-ctl exedev-k8s) +DOCS=(README.md README.zh-CN.md fleet.example.yaml .env.example) +PLATFORMS=(macos-arm64 macos-amd64 linux-arm64 linux-amd64) + +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +if [[ -z "$RELEASE_TAG" ]]; then + if ! command -v gh >/dev/null 2>&1; then + echo "RELEASE_TAG is required when gh is unavailable" >&2 + exit 1 + fi + RELEASE_TAG="$(gh release view --repo "$REPO_SLUG" --json tagName -q .tagName)" +fi + +VERSION="${RELEASE_TAG#v}" + +if [[ -z "$TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" ]]; then + # homebrew-core files its formulae under the first character of their name — + # `Formula/e/exedev-cli.rb` — while a flat personal tap keeps them directly under + # `Formula`. Writing to the layout the repo does not use produces a file nothing + # installs from, and the release then reports a tap update that never reached anyone. + FORMULA_SHARD_DIR="$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}" + if [[ -d "$FORMULA_SHARD_DIR" ]]; then + TAP_FORMULA_PATH="$FORMULA_SHARD_DIR/${FORMULA_NAME}.rb" + else + TAP_FORMULA_PATH="$TAP_REPO_PATH/Formula/${FORMULA_NAME}.rb" + fi +fi + +if [[ -z "$TAP_FORMULA_PATH" ]]; then + echo "TAP_REPO_PATH or TAP_FORMULA_PATH is required" >&2 + exit 1 +fi + +if [[ -z "$EXPLICIT_TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" && ! -d "$TAP_REPO_PATH" ]]; then + echo "TAP_REPO_PATH does not exist: $TAP_REPO_PATH" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +# Parallel to PLATFORMS by index: macOS ships bash 3.2, which has no associative arrays. +SHAS=() +for platform in "${PLATFORMS[@]}"; do + archive="${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" + url="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG/$archive" + if ! curl -fsSL -o "$WORK_DIR/$archive" "$url"; then + echo "failed to download release asset: $url" >&2 + echo "Check that the release exists and publishes every platform in PLATFORMS." >&2 + exit 1 + fi + SHAS+=("$(sha256_of "$WORK_DIR/$archive")") +done + +sha_for() { + local target="$1" index=0 + for platform in "${PLATFORMS[@]}"; do + if [[ "$platform" == "$target" ]]; then + echo "${SHAS[$index]}" + return 0 + fi + index=$((index + 1)) + done + echo "unknown platform: $target" >&2 + return 1 +} + +# The formula's `install` block names each file directly, so a renamed or dropped +# archive member fails at install time on the user's machine rather than here. +# Check the payload against the release we just downloaded instead. +tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-macos-arm64.tar.gz" > "$WORK_DIR/members.txt" +for member in "${BINARIES[@]}" "${DOCS[@]}"; do + if ! grep -qx "\./$member" "$WORK_DIR/members.txt"; then + echo "release archive does not contain expected member: $member" >&2 + exit 1 + fi +done + +url_for() { + echo "https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${1}.tar.gz" +} + +quoted_list() { + local out="" + for item in "$@"; do + [[ -n "$out" ]] && out+=", " + out+="\"$item\"" + done + echo "$out" +} + +mkdir -p "$(dirname "$TAP_FORMULA_PATH")" +cat > "$TAP_FORMULA_PATH" < Date: Mon, 10 Aug 2026 22:36:45 +0800 Subject: [PATCH 02/18] feat: align command surface with the latest exe.dev docs Checked against https://exe.dev/llms-full.txt on 2026-08-10. exedev-ctl gains the commands and flags that upstream documents but this CLI could only reach through `exec`: - `pool new/list/delete` and `new --pool` for team reserved capacity - `share add/remove --root` and `share receive-email --reply-policy` - `integrations test`, `integrations catalog`, `list --usage`, `--readonly`, and the time-boxed `--for`/`--until` grants on `attach` - `team settings auto-join` and tax-ID fields on `team billing update` - `billing credits usage/transactions/buy`, `billing payment`, `billing update`, and `billing statement` `billing provider link` and `exe0-to-exe1` are deliberately left to `exec`: both take a token as an argument, and a typed wrapper would only make it easier to leak one into shell history and the process list. `new --command` is removed; it is no longer in the upstream option list for `new`, so forwarding it only produced a server-side error. The dangerous-command guard now also covers `pool delete`, `billing credits buy`, `billing payment remove`, `share access allow`, `team settings auto-join on`, and `share add --root`, which grants SSH, Terminal, and Shelley access rather than the web-only share it resembles. Server-side `--yes` forwarding moves from two hardcoded call sites to one declaration, and `exec` is excluded so raw passthrough stays verbatim. exedev-k8s reached nodes at a hardcoded `.exe.xyz`. Upstream now documents that `ssh_dest` may carry a username prefix such as `vm+bloggy@exe.dev` when a VM hostname cannot route SSH directly, so bootstrap read the wrong destination for those VMs. It now takes the destination from the `ls` response, falling back to the hostname, and re-reads the list after creating VMs so new ones contribute theirs. --- README.md | 8 +- README.zh-CN.md | 8 +- cli/README.md | 15 +- cli/README.zh-CN.md | 14 +- cli/src/cli.rs | 218 +++++++++++- cli/src/cli_command.rs | 368 +++++++++++++++++++-- core/src/shell.rs | 23 ++ docs/exe-dev-api-reference.md | 28 +- docs/exedev-automation.md | 5 + k8s_cli/README.md | 9 +- k8s_cli/README.zh-CN.md | 7 +- k8s_cli/src/manager/mod.rs | 126 ++++--- k8s_cli/src/manager/parsing.rs | 64 +++- k8s_cli/src/manager/process.rs | 49 ++- k8s_cli/src/manager/tests.rs | 35 +- skills/exedev-ctl/SKILL.md | 5 +- skills/exedev-ctl/references/exedev-ctl.md | 53 +++ 17 files changed, 926 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index c432d94..52d5911 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,12 @@ exedev-ctl domain add p1-a-1 app.example.com exedev-ctl rm p1-a-1 ``` -Dangerous operations such as `rm`, public share changes, and support-root grants -ask for confirmation by default. Use `--yes` only in reviewed automation. +Dangerous operations ask for confirmation by default: deletions (`rm`, +`pool delete`, `domain rm`), access widening (public shares, share links, +`share add --root`, `share access allow`, `team settings auto-join on`, +support-root grants), and spending (`billing capacity`, `billing credits buy`). +`--yes` is a global flag on both CLIs that skips these prompts; use it only in +reviewed automation. `exedev-k8s destroy` always confirms, even with `--yes`. Detailed documentation: diff --git a/README.zh-CN.md b/README.zh-CN.md index 9a89deb..5c1cd1e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,8 +43,12 @@ exedev-ctl domain add p1-a-1 app.example.com exedev-ctl rm p1-a-1 ``` -`rm`、public share 变更、support-root grant 等危险操作默认需要确认。只有在 -automation 已经审阅过 action plan 后才使用 `--yes`。 +危险操作默认需要确认:删除(`rm`、`pool delete`、`domain rm`)、扩大访问权限 +(public share、share link、`share add --root`、`share access allow`、 +`team settings auto-join on`、support-root grant)、以及花钱(`billing capacity`、 +`billing credits buy`)。`--yes` 是两个 CLI 的 global flag,用于跳过这些确认, +只有在 automation 已经审阅过 action plan 后才使用。`exedev-k8s destroy` 始终需要 +确认,即使传入 `--yes`。 详细文档: diff --git a/cli/README.md b/cli/README.md index cc0fb4c..9a7ed3d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -159,10 +159,19 @@ These commands require local SSH access to exe.dev. The CLI covers the top-level commands from the exe.dev CLI Reference: ```text -help doc ls new rm restart rename tag stat cp resize share domain team whoami -ssh-key set-region integrations billing shelley browser ssh grant-support-root -exit exec +help doc ls new rm restart rename tag comment stat cp resize share domain team +pool invite whoami ssh-key set-region integrations billing shelley browser ssh +grant-support-root exit exec ``` `exec` is the fallback command for future exe.dev commands that do not yet have a typed wrapper. + +Two documented commands are intentionally left to `exec`, because each takes a +secret as an argument and a typed wrapper would only make it easier to leak it +into shell history and the process list: + +```sh +exedev-ctl exec -- billing provider link aws --token=... +exedev-ctl exec -- exe0-to-exe1 "$TOKEN" +``` diff --git a/cli/README.zh-CN.md b/cli/README.zh-CN.md index 9040db8..20f10d9 100644 --- a/cli/README.zh-CN.md +++ b/cli/README.zh-CN.md @@ -157,9 +157,17 @@ ssh exe.dev ... CLI 覆盖 exe.dev CLI Reference 中的 top-level commands: ```text -help doc ls new rm restart rename tag stat cp resize share domain team whoami -ssh-key set-region integrations billing shelley browser ssh grant-support-root -exit exec +help doc ls new rm restart rename tag comment stat cp resize share domain team +pool invite whoami ssh-key set-region integrations billing shelley browser ssh +grant-support-root exit exec ``` `exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。 + +有两个已文档化的 command 不提供 typed wrapper:它们都以 argument 传递 secret, +包装后只会更容易把 secret 写进 shell history 和进程表。 + +```sh +exedev-ctl exec -- billing provider link aws --token=... +exedev-ctl exec -- exe0-to-exe1 "$TOKEN" +``` diff --git a/cli/src/cli.rs b/cli/src/cli.rs index c6455ae..3e1f3e2 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -13,10 +13,14 @@ pub(crate) struct Cli { #[arg(long, global = true, value_enum, default_value_t = Transport::Ssh)] pub(crate) transport: Transport, - #[arg(long, global = true)] + #[arg(long, global = true, help = "Print raw JSON instead of human output")] pub(crate) json: bool, - #[arg(long, global = true)] + #[arg( + long, + global = true, + help = "Skip the confirmation prompt for dangerous commands" + )] pub(crate) yes: bool, #[command(subcommand)] @@ -61,6 +65,8 @@ pub(crate) enum Commands { Domain(DomainCmd), /// View and manage your team. Team(TeamCmd), + /// Manage your team's VM pools (reserved capacity slices). + Pool(PoolCmd), /// Manage your invite link and rewards. Invite(InviteCmd), /// Show current user information. @@ -112,8 +118,6 @@ pub(crate) struct LsCmd { #[derive(Debug, Args)] pub(crate) struct NewCmd { - #[arg(long)] - pub(crate) command: Option, #[arg(long)] pub(crate) comment: Option, #[arg(long)] @@ -132,6 +136,9 @@ pub(crate) struct NewCmd { pub(crate) name: Option, #[arg(long)] pub(crate) no_email: bool, + /// Create the VM in one of your team's pools (see `pool list`). + #[arg(long)] + pub(crate) pool: Option, #[arg(long)] pub(crate) prompt: Option, #[arg(long)] @@ -252,17 +259,25 @@ pub(crate) struct ShareVmCmd { #[derive(Debug, Args)] pub(crate) struct ShareAddCmd { pub(crate) vm: String, + /// An email address, or `team` to share with the whole team. pub(crate) target: String, #[arg(long)] pub(crate) message: Option, #[arg(long)] pub(crate) qr: bool, + /// Grant shell (SSH, Terminal, Shelley) access instead of web-only. + #[arg(long)] + pub(crate) root: bool, } #[derive(Debug, Args)] pub(crate) struct ShareRemoveCmd { pub(crate) vm: String, + /// An email address, or `team` to revoke the team share. pub(crate) target: String, + /// Downgrade shell access to web-only instead of revoking access. + #[arg(long)] + pub(crate) root: bool, } #[derive(Debug, Args)] @@ -274,7 +289,11 @@ pub(crate) struct ShareRemoveLinkCmd { #[derive(Debug, Args)] pub(crate) struct ShareReceiveEmailCmd { pub(crate) vm: String, + /// One of on, off. pub(crate) state: Option, + /// Restrict who the VM may email: all, known, owner, none. + #[arg(long)] + pub(crate) reply_policy: Option, } #[derive(Debug, Args)] @@ -379,11 +398,12 @@ pub(crate) struct TeamBillingCmd { #[derive(Debug, Subcommand)] pub(crate) enum TeamBillingSubcommand { /// Update team billing information. - Update(TeamBillingUpdateCmd), + Update(BillingContactCmd), } +/// Billing contact fields shared by `billing update` and `team billing update`. #[derive(Debug, Args)] -pub(crate) struct TeamBillingUpdateCmd { +pub(crate) struct BillingContactCmd { #[arg(long)] pub(crate) name: Option, #[arg(long)] @@ -402,6 +422,12 @@ pub(crate) struct TeamBillingUpdateCmd { pub(crate) address_postal_code: Option, #[arg(long)] pub(crate) address_country: Option, + /// Tax ID type shown on invoices (e.g. eu_vat, pl_nip, us_ein). + #[arg(long)] + pub(crate) tax_id_type: Option, + /// Tax ID value shown on invoices. + #[arg(long)] + pub(crate) tax_id_value: Option, } #[derive(Debug, Args)] @@ -447,6 +473,9 @@ pub(crate) enum TeamSettingsSubcommand { /// Set who can share team VMs. #[command(name = "vm-sharing")] VmSharing(TeamVmSharingCmd), + /// Allow users from your email domain to join this team on signup. + #[command(name = "auto-join")] + AutoJoin(TeamAutoJoinCmd), } #[derive(Debug, Args)] @@ -455,6 +484,12 @@ pub(crate) struct TeamVmSharingCmd { pub(crate) value: String, } +#[derive(Debug, Args)] +pub(crate) struct TeamAutoJoinCmd { + /// One of on, off. + pub(crate) value: String, +} + #[derive(Debug, Args)] pub(crate) struct TeamVmCmd { #[command(subcommand)] @@ -477,6 +512,45 @@ pub(crate) struct TeamVmLsCmd { pub(crate) pattern: Option, } +#[derive(Debug, Args)] +pub(crate) struct PoolCmd { + #[command(subcommand)] + pub(crate) command: PoolSubcommand, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum PoolSubcommand { + /// Create a pool: reserved capacity for your team's VMs. + New(PoolNewCmd), + /// List your team's pools. + #[command(alias = "ls")] + List, + /// Delete a pool (refused while it has VMs; --force detaches them). + Delete(PoolDeleteCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct PoolNewCmd { + pub(crate) name: String, + /// Number of CPUs reserved for the pool. + #[arg(long)] + pub(crate) cpus: String, + /// Region code for the pool. + #[arg(long)] + pub(crate) region: String, + /// Maximum number of VMs in the pool (exe.dev default 100). + #[arg(long)] + pub(crate) max_vms: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct PoolDeleteCmd { + pub(crate) name: String, + /// Detach the pool's VMs instead of refusing to delete it. + #[arg(long)] + pub(crate) force: bool, +} + #[derive(Debug, Args)] pub(crate) struct InviteCmd { #[command(subcommand)] @@ -568,14 +642,30 @@ pub(crate) struct IntegrationsCmd { #[derive(Debug, Subcommand)] pub(crate) enum IntegrationsSubcommand { - List, + List(IntegrationsListCmd), Setup(IntegrationSetupCmd), Add(IntegrationAddCmd), Edit(IntegrationEditCmd), Remove(NameCmd), + /// Test an integration's credential (connection check). + Test(NameCmd), Attach(IntegrationAttachCmd), - Detach(IntegrationAttachCmd), + Detach(IntegrationDetachCmd), Rename(IntegrationRenameCmd), + /// Browse the catalog of ready-made service integrations. + Catalog(IntegrationsCatalogCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct IntegrationsListCmd { + /// Include per-VM usage (lastUsedAt, usedByVMs); requires --json. + #[arg(long)] + pub(crate) usage: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct IntegrationsCatalogCmd { + pub(crate) search_term: Option, } #[derive(Debug, Args)] @@ -616,10 +706,16 @@ pub(crate) struct IntegrationAddCmd { pub(crate) no_auth: bool, #[arg(long)] pub(crate) peer: bool, + /// Restrict the integration to read access (github only). + #[arg(long)] + pub(crate) readonly: bool, #[arg(long)] pub(crate) repository: Option, #[arg(long)] pub(crate) target: Option, + /// Time-box every --attach to a duration from now (e.g. 2h, 45m). + #[arg(long = "for")] + pub(crate) for_duration: Option, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub(crate) args: Vec, } @@ -643,6 +739,9 @@ pub(crate) struct IntegrationEditCmd { pub(crate) header: Vec, #[arg(long)] pub(crate) no_auth: bool, + /// Restrict the integration to read access (github only). + #[arg(long)] + pub(crate) readonly: bool, #[arg(long)] pub(crate) repository: Option, #[arg(long)] @@ -663,6 +762,22 @@ pub(crate) struct NameCmd { #[derive(Debug, Args)] pub(crate) struct IntegrationAttachCmd { pub(crate) name: String, + /// One of vm:, tag:, auto:all. + pub(crate) spec: String, + #[arg(long)] + pub(crate) team: bool, + /// Time-box the attachment to a duration from now (e.g. 2h, 45m). + #[arg(long = "for")] + pub(crate) for_duration: Option, + /// Time-box the attachment until an RFC3339 instant. + #[arg(long)] + pub(crate) until: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct IntegrationDetachCmd { + pub(crate) name: String, + /// One of vm:, tag:, auto:all. pub(crate) spec: String, #[arg(long)] pub(crate) team: bool, @@ -682,6 +797,9 @@ pub(crate) struct BillingCmd { pub(crate) command: BillingSubcommand, } +// `Update` carries every billing contact field; clap needs it unboxed, and the +// enum is built once per invocation, so the size difference does not matter. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Subcommand)] pub(crate) enum BillingSubcommand { /// Show your current plan and resource limits. @@ -689,17 +807,23 @@ pub(crate) enum BillingSubcommand { /// Show resource usage against your plan. Usage(BillingUsageCmd), /// Show Shelley credit balances. - Credits, + Credits(BillingCreditsCmd), /// Show invite rewards you've earned. Rewards, /// Change your subscription capacity. Capacity, + /// Show and manage your payment methods. + Payment(BillingPaymentCmd), /// Open the billing page. Manage, + /// Update your billing contact information. + Update(BillingContactCmd), /// Show invoices. Invoices, /// Show receipts for credit purchases. Receipts, + /// Open a consolidated credit purchase statement. + Statement(BillingStatementCmd), } #[derive(Debug, Args)] @@ -709,6 +833,82 @@ pub(crate) struct BillingUsageCmd { pub(crate) range: Option, } +#[derive(Debug, Args)] +pub(crate) struct BillingCreditsCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum BillingCreditsSubcommand { + /// Show Shelley (LLM) credit spend by model, day, or VM. + Usage(BillingCreditsUsageCmd), + /// Show your credit purchases and gifts. + Transactions(BillingCreditsTransactionsCmd), + /// Buy Shelley credits with your personal card. + Buy(BillingCreditsBuyCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct BillingCreditsUsageCmd { + /// Calendar month to report, as YYYY-MM. + #[arg(long)] + pub(crate) month: Option, + /// Group spend by model, day, or box. + #[arg(long)] + pub(crate) group: Option, + /// Break each group down: models under a day or VM, VMs under a model. + #[arg(long)] + pub(crate) detail: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct BillingCreditsTransactionsCmd { + /// How many transactions to show, 1-100. + #[arg(long)] + pub(crate) limit: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct BillingCreditsBuyCmd { + /// Amount in dollars. + pub(crate) dollars: String, + /// Retry key so a repeated purchase of the same amount charges once. + #[arg(long)] + pub(crate) idempotency_key: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct BillingPaymentCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum BillingPaymentSubcommand { + /// List all payment methods on file. + List, + /// Remove a saved payment method. + Remove(BillingPaymentRefCmd), + /// Make a saved card the default payment method. + Default(BillingPaymentRefCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct BillingPaymentRefCmd { + pub(crate) reference: String, +} + +#[derive(Debug, Args)] +pub(crate) struct BillingStatementCmd { + /// Start of the period, YYYY-MM-DD. + #[arg(long = "from")] + pub(crate) from_date: Option, + /// End of the period, YYYY-MM-DD. + #[arg(long = "to")] + pub(crate) to_date: Option, +} + #[derive(Debug, Args)] pub(crate) struct ShelleyCmd { #[command(subcommand)] diff --git a/cli/src/cli_command.rs b/cli/src/cli_command.rs index 106b648..981591e 100644 --- a/cli/src/cli_command.rs +++ b/cli/src/cli_command.rs @@ -7,6 +7,15 @@ pub(crate) struct BuiltCommand { pub(crate) fallback_ssh: bool, } +/// exe.dev commands that prompt for confirmation server-side. +/// +/// Neither `ssh exe.dev ` nor `POST /exec` allocates a pty, so that +/// prompt can never be answered. `--yes` is forwarded for them and the local +/// dangerous-command guard is what actually asks the user; see +/// `exedev_core::shell::guard_dangerous_command`. +const SERVER_CONFIRM_COMMANDS: [&[&str]; 2] = + [&["team", "disable"], &["billing", "credits", "buy"]]; + pub(crate) fn build_command(command: &Commands) -> Result { let mut words = Vec::new(); let mut fallback_ssh = false; @@ -30,7 +39,6 @@ pub(crate) fn build_command(command: &Commands) -> Result { } Commands::New(cmd) => { words.push("new".into()); - push_flag_value(&mut words, "--command", cmd.command.as_ref()); push_flag_value(&mut words, "--comment", cmd.comment.as_ref()); push_flag_value(&mut words, "--cpu", cmd.cpu.as_ref()); push_flag_value(&mut words, "--disk", cmd.disk.as_ref()); @@ -46,6 +54,7 @@ pub(crate) fn build_command(command: &Commands) -> Result { if cmd.no_email { words.push("--no-email".into()); } + push_flag_value(&mut words, "--pool", cmd.pool.as_ref()); if cmd.prompt.as_deref() == Some("/dev/stdin") || cmd.setup_script.as_deref() == Some("/dev/stdin") { @@ -102,6 +111,7 @@ pub(crate) fn build_command(command: &Commands) -> Result { Commands::Share(cmd) => build_share_command(&mut words, &cmd.command), Commands::Domain(cmd) => build_domain_command(&mut words, &cmd.command)?, Commands::Team(cmd) => build_team_command(&mut words, cmd.command.as_ref()), + Commands::Pool(cmd) => build_pool_command(&mut words, &cmd.command), Commands::Invite(cmd) => build_invite_command(&mut words, &cmd.command), Commands::Whoami => words.push("whoami".into()), Commands::SshKey(cmd) => build_ssh_key_command(&mut words, &cmd.command), @@ -134,15 +144,30 @@ pub(crate) fn build_command(command: &Commands) -> Result { ]); } Commands::Exit => words.push("exit".into()), + // `exec` is a raw passthrough, so it is left exactly as the user typed it. Commands::Exec(cmd) => words.extend(cmd.command.clone()), } + if !matches!(command, Commands::Exec(_)) && needs_server_confirmation(&words) { + words.push("--yes".into()); + } + Ok(BuiltCommand { words, fallback_ssh, }) } +fn needs_server_confirmation(words: &[String]) -> bool { + SERVER_CONFIRM_COMMANDS.iter().any(|command| { + words.len() >= command.len() + && words + .iter() + .zip(command.iter()) + .all(|(word, expected)| word == expected) + }) +} + fn build_share_command(words: &mut Vec, command: &ShareSubcommand) { words.push("share".into()); match command { @@ -164,9 +189,15 @@ fn build_share_command(words: &mut Vec, command: &ShareSubcommand) { if cmd.qr { words.push("--qr".into()); } + if cmd.root { + words.push("--root".into()); + } } ShareSubcommand::Remove(cmd) => { words.extend(["remove".into(), cmd.vm.clone(), cmd.target.clone()]); + if cmd.root { + words.push("--root".into()); + } } ShareSubcommand::AddLink(cmd) => { words.extend(["add-link".into(), cmd.vm.clone()]); @@ -180,6 +211,7 @@ fn build_share_command(words: &mut Vec, command: &ShareSubcommand) { ShareSubcommand::ReceiveEmail(cmd) => { words.extend(["receive-email".into(), cmd.vm.clone()]); push_opt(words, cmd.state.as_ref()); + push_flag_value(words, "--reply-policy", cmd.reply_policy.as_ref()); } ShareSubcommand::Access(cmd) => { words.extend(["access".into(), cmd.action.clone(), cmd.vm.clone()]); @@ -222,11 +254,7 @@ fn build_team_command(words: &mut Vec, command: Option<&TeamSubcommand>) return; }; match command { - TeamSubcommand::Disable => { - // /exec has no pty, so the server-side confirmation prompt cannot - // be answered; the local dangerous-command guard already confirmed. - words.extend(["disable".into(), "--yes".into()]); - } + TeamSubcommand::Disable => words.push("disable".into()), TeamSubcommand::Members => words.push("members".into()), TeamSubcommand::Add(cmd) => words.extend(["add".into(), cmd.email.clone()]), TeamSubcommand::Remove(cmd) => words.extend(["remove".into(), cmd.email.clone()]), @@ -238,19 +266,7 @@ fn build_team_command(words: &mut Vec, command: Option<&TeamSubcommand>) words.push("billing".into()); if let Some(TeamBillingSubcommand::Update(update)) = &cmd.command { words.push("update".into()); - push_flag_value(words, "--name", update.name.as_ref()); - push_flag_value(words, "--business-name", update.business_name.as_ref()); - push_flag_value(words, "--phone", update.phone.as_ref()); - push_flag_value(words, "--address-line1", update.address_line1.as_ref()); - push_flag_value(words, "--address-line2", update.address_line2.as_ref()); - push_flag_value(words, "--address-city", update.address_city.as_ref()); - push_flag_value(words, "--address-state", update.address_state.as_ref()); - push_flag_value( - words, - "--address-postal-code", - update.address_postal_code.as_ref(), - ); - push_flag_value(words, "--address-country", update.address_country.as_ref()); + push_billing_contact_flags(words, update); } } TeamSubcommand::Transfer(cmd) => { @@ -272,8 +288,14 @@ fn build_team_command(words: &mut Vec, command: Option<&TeamSubcommand>) } TeamSubcommand::Settings(cmd) => { words.push("settings".into()); - if let Some(TeamSettingsSubcommand::VmSharing(sharing)) = &cmd.command { - words.extend(["vm-sharing".into(), sharing.value.clone()]); + match &cmd.command { + Some(TeamSettingsSubcommand::VmSharing(sharing)) => { + words.extend(["vm-sharing".into(), sharing.value.clone()]); + } + Some(TeamSettingsSubcommand::AutoJoin(auto_join)) => { + words.extend(["auto-join".into(), auto_join.value.clone()]); + } + None => {} } } TeamSubcommand::Vm(cmd) => { @@ -290,6 +312,43 @@ fn build_team_command(words: &mut Vec, command: Option<&TeamSubcommand>) } } +fn push_billing_contact_flags(words: &mut Vec, contact: &BillingContactCmd) { + push_flag_value(words, "--name", contact.name.as_ref()); + push_flag_value(words, "--business-name", contact.business_name.as_ref()); + push_flag_value(words, "--phone", contact.phone.as_ref()); + push_flag_value(words, "--address-line1", contact.address_line1.as_ref()); + push_flag_value(words, "--address-line2", contact.address_line2.as_ref()); + push_flag_value(words, "--address-city", contact.address_city.as_ref()); + push_flag_value(words, "--address-state", contact.address_state.as_ref()); + push_flag_value( + words, + "--address-postal-code", + contact.address_postal_code.as_ref(), + ); + push_flag_value(words, "--address-country", contact.address_country.as_ref()); + push_flag_value(words, "--tax-id-type", contact.tax_id_type.as_ref()); + push_flag_value(words, "--tax-id-value", contact.tax_id_value.as_ref()); +} + +fn build_pool_command(words: &mut Vec, command: &PoolSubcommand) { + words.push("pool".into()); + match command { + PoolSubcommand::New(cmd) => { + words.extend(["new".into(), cmd.name.clone()]); + push_flag_value(words, "--cpus", Some(&cmd.cpus)); + push_flag_value(words, "--region", Some(&cmd.region)); + push_flag_value(words, "--max-vms", cmd.max_vms.as_ref()); + } + PoolSubcommand::List => words.push("list".into()), + PoolSubcommand::Delete(cmd) => { + words.extend(["delete".into(), cmd.name.clone()]); + if cmd.force { + words.push("--force".into()); + } + } + } +} + fn build_invite_command(words: &mut Vec, command: &InviteSubcommand) { words.push("invite".into()); match command { @@ -331,7 +390,12 @@ fn build_ssh_key_command(words: &mut Vec, command: &SshKeySubcommand) { fn build_integrations_command(words: &mut Vec, command: &IntegrationsSubcommand) { words.push("integrations".into()); match command { - IntegrationsSubcommand::List => words.push("list".into()), + IntegrationsSubcommand::List(cmd) => { + words.push("list".into()); + if cmd.usage { + words.push("--usage".into()); + } + } IntegrationsSubcommand::Setup(cmd) => { words.extend(["setup".into(), cmd.integration_type.clone()]); if cmd.disconnect { @@ -372,8 +436,12 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub if cmd.peer { words.push("--peer".into()); } + if cmd.readonly { + words.push("--readonly".into()); + } push_flag_value(words, "--repository", cmd.repository.as_ref()); push_flag_value(words, "--target", cmd.target.as_ref()); + push_flag_value(words, "--for", cmd.for_duration.as_ref()); words.extend(cmd.args.clone()); } IntegrationsSubcommand::Edit(cmd) => { @@ -396,6 +464,9 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub if cmd.no_auth { words.push("--no-auth".into()); } + if cmd.readonly { + words.push("--readonly".into()); + } push_flag_value(words, "--repository", cmd.repository.as_ref()); push_flag_value(words, "--target", cmd.target.as_ref()); push_flag_value(words, "--webhook-url", cmd.webhook_url.as_ref()); @@ -407,11 +478,19 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub words.push("--team".into()); } } + IntegrationsSubcommand::Test(cmd) => { + words.extend(["test".into(), cmd.name.clone()]); + if cmd.team { + words.push("--team".into()); + } + } IntegrationsSubcommand::Attach(cmd) => { words.extend(["attach".into(), cmd.name.clone(), cmd.spec.clone()]); if cmd.team { words.push("--team".into()); } + push_flag_value(words, "--for", cmd.for_duration.as_ref()); + push_flag_value(words, "--until", cmd.until.as_ref()); } IntegrationsSubcommand::Detach(cmd) => { words.extend(["detach".into(), cmd.name.clone(), cmd.spec.clone()]); @@ -425,6 +504,10 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub words.push("--team".into()); } } + IntegrationsSubcommand::Catalog(cmd) => { + words.push("catalog".into()); + push_opt(words, cmd.search_term.as_ref()); + } } } @@ -436,12 +519,55 @@ fn build_billing_command(words: &mut Vec, command: &BillingSubcommand) { words.push("usage".into()); push_flag_value(words, "--range", cmd.range.as_ref()); } - BillingSubcommand::Credits => words.push("credits".into()), + BillingSubcommand::Credits(cmd) => { + words.push("credits".into()); + match &cmd.command { + Some(BillingCreditsSubcommand::Usage(usage)) => { + words.push("usage".into()); + push_flag_value(words, "--month", usage.month.as_ref()); + push_flag_value(words, "--group", usage.group.as_ref()); + if usage.detail { + words.push("--detail".into()); + } + } + Some(BillingCreditsSubcommand::Transactions(transactions)) => { + words.push("transactions".into()); + push_flag_value(words, "--limit", transactions.limit.as_ref()); + } + Some(BillingCreditsSubcommand::Buy(buy)) => { + words.extend(["buy".into(), buy.dollars.clone()]); + push_flag_value(words, "--idempotency-key", buy.idempotency_key.as_ref()); + } + None => {} + } + } BillingSubcommand::Rewards => words.push("rewards".into()), BillingSubcommand::Capacity => words.push("capacity".into()), + BillingSubcommand::Payment(cmd) => { + words.push("payment".into()); + match &cmd.command { + Some(BillingPaymentSubcommand::List) => words.push("list".into()), + Some(BillingPaymentSubcommand::Remove(payment)) => { + words.extend(["remove".into(), payment.reference.clone()]); + } + Some(BillingPaymentSubcommand::Default(payment)) => { + words.extend(["default".into(), payment.reference.clone()]); + } + None => {} + } + } BillingSubcommand::Manage => words.push("manage".into()), + BillingSubcommand::Update(cmd) => { + words.push("update".into()); + push_billing_contact_flags(words, cmd); + } BillingSubcommand::Invoices => words.push("invoices".into()), BillingSubcommand::Receipts => words.push("receipts".into()), + BillingSubcommand::Statement(cmd) => { + words.push("statement".into()); + push_flag_value(words, "--from", cmd.from_date.as_ref()); + push_flag_value(words, "--to", cmd.to_date.as_ref()); + } } } @@ -737,6 +863,202 @@ mod tests { let built = command_from(&["exedev-ctl", "billing", "capacity"]); assert_eq!(shell_join(&built.words), "billing capacity"); + + let built = command_from(&["exedev-ctl", "billing", "credits"]); + assert_eq!(shell_join(&built.words), "billing credits"); + + let built = command_from(&[ + "exedev-ctl", + "billing", + "credits", + "usage", + "--group", + "box", + "--detail", + ]); + assert_eq!( + shell_join(&built.words), + "billing credits usage --group box --detail" + ); + + let built = command_from(&["exedev-ctl", "billing", "credits", "transactions"]); + assert_eq!(shell_join(&built.words), "billing credits transactions"); + + let built = command_from(&["exedev-ctl", "billing", "payment", "default", "4f1c2a9b"]); + assert_eq!(shell_join(&built.words), "billing payment default 4f1c2a9b"); + + let built = command_from(&[ + "exedev-ctl", + "billing", + "statement", + "--from", + "2026-01-01", + "--to", + "2026-06-30", + ]); + assert_eq!( + shell_join(&built.words), + "billing statement --from 2026-01-01 --to 2026-06-30" + ); + + let built = command_from(&[ + "exedev-ctl", + "billing", + "update", + "--tax-id-type", + "eu_vat", + "--tax-id-value", + "DE123", + ]); + assert_eq!( + shell_join(&built.words), + "billing update --tax-id-type eu_vat --tax-id-value DE123" + ); + } + + #[test] + fn billing_credits_buy_forwards_yes() { + let built = command_from(&["exedev-ctl", "billing", "credits", "buy", "25"]); + assert_eq!(shell_join(&built.words), "billing credits buy 25 --yes"); + + let built = command_from(&[ + "exedev-ctl", + "billing", + "credits", + "buy", + "100", + "--idempotency-key", + "retry-1", + ]); + assert_eq!( + shell_join(&built.words), + "billing credits buy 100 --idempotency-key retry-1 --yes" + ); + } + + #[test] + fn server_confirmation_leaves_neighbouring_commands_alone() { + let built = command_from(&["exedev-ctl", "billing", "credits", "transactions"]); + assert_eq!(shell_join(&built.words), "billing credits transactions"); + + let built = command_from(&["exedev-ctl", "team", "members"]); + assert_eq!(shell_join(&built.words), "team members"); + } + + #[test] + fn raw_exec_is_passed_through_unchanged() { + let built = command_from(&["exedev-ctl", "exec", "--", "team", "disable"]); + assert_eq!(shell_join(&built.words), "team disable"); + } + + #[test] + fn builds_pool_commands() { + let built = command_from(&[ + "exedev-ctl", + "pool", + "new", + "builders", + "--cpus", + "16", + "--region", + "fra", + "--max-vms", + "20", + ]); + assert_eq!( + shell_join(&built.words), + "pool new builders --cpus 16 --region fra --max-vms 20" + ); + + let built = command_from(&["exedev-ctl", "pool", "ls"]); + assert_eq!(shell_join(&built.words), "pool list"); + + let built = command_from(&["exedev-ctl", "pool", "delete", "builders", "--force"]); + assert_eq!(shell_join(&built.words), "pool delete builders --force"); + + let built = command_from(&[ + "exedev-ctl", + "new", + "--name", + "p1-a-1", + "--pool", + "builders", + ]); + assert_eq!( + shell_join(&built.words), + "new --name p1-a-1 --pool builders" + ); + } + + #[test] + fn builds_share_root_and_reply_policy() { + let built = command_from(&["exedev-ctl", "share", "add", "mybox", "team", "--root"]); + assert_eq!(shell_join(&built.words), "share add mybox team --root"); + + let built = command_from(&["exedev-ctl", "share", "remove", "mybox", "team", "--root"]); + assert_eq!(shell_join(&built.words), "share remove mybox team --root"); + + let built = command_from(&[ + "exedev-ctl", + "share", + "receive-email", + "mybox", + "on", + "--reply-policy", + "known", + ]); + assert_eq!( + shell_join(&built.words), + "share receive-email mybox on --reply-policy known" + ); + } + + #[test] + fn builds_integrations_grant_commands() { + let built = command_from(&["exedev-ctl", "integrations", "list", "--usage"]); + assert_eq!(shell_join(&built.words), "integrations list --usage"); + + let built = command_from(&["exedev-ctl", "int", "test", "myproxy", "--team"]); + assert_eq!(shell_join(&built.words), "integrations test myproxy --team"); + + let built = command_from(&["exedev-ctl", "int", "catalog", "stripe"]); + assert_eq!(shell_join(&built.words), "integrations catalog stripe"); + + let built = command_from(&[ + "exedev-ctl", + "int", + "attach", + "gmail", + "vm:dev1", + "--for", + "2h", + ]); + assert_eq!( + shell_join(&built.words), + "integrations attach gmail vm:dev1 --for 2h" + ); + + let built = command_from(&[ + "exedev-ctl", + "int", + "add", + "github", + "--name", + "repo", + "--repository", + "octocat/hello", + "--readonly", + ]); + assert_eq!( + shell_join(&built.words), + "integrations add github --name repo --readonly --repository octocat/hello" + ); + } + + #[test] + fn builds_team_auto_join() { + let built = command_from(&["exedev-ctl", "team", "settings", "auto-join", "off"]); + assert_eq!(shell_join(&built.words), "team settings auto-join off"); } #[test] diff --git a/core/src/shell.rs b/core/src/shell.rs index fc05dcf..6d16508 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -45,6 +45,7 @@ fn is_dangerous(command: &str) -> bool { "share set-public ", "share add-link ", "share add-share-link ", + "share access allow ", "grant-support-root ", "ssh-key remove ", "integrations remove ", @@ -55,13 +56,24 @@ fn is_dangerous(command: &str) -> bool { "team role ", "team transfer ", "team disable", + "team settings auto-join on", "domain rm ", + "pool delete ", "billing capacity", + "billing credits buy ", + "billing payment remove ", ]; prefixes .iter() .any(|prefix| normalized == prefix.trim_end() || normalized.starts_with(prefix)) || normalized.starts_with("tag -d ") + || grants_shell_access(normalized) +} + +/// `share add --root` grants SSH, Terminal, and Shelley access, +/// which is strictly more powerful than the web-only share it looks like. +fn grants_shell_access(command: &str) -> bool { + command.starts_with("share add ") && command.split_whitespace().any(|word| word == "--root") } #[cfg(test)] @@ -92,8 +104,19 @@ mod tests { assert!(is_dangerous("domain rm vm1 app.example.com")); assert!(is_dangerous("integrations edit myproxy --target x")); assert!(is_dangerous("billing capacity")); + assert!(is_dangerous("billing credits buy 100 --yes")); + assert!(is_dangerous("billing payment remove 4f1c2a9b8d3e")); + assert!(is_dangerous("pool delete builders --force")); + assert!(is_dangerous("share access allow mybox")); + assert!(is_dangerous("team settings auto-join on")); + assert!(is_dangerous("share add mybox a@b.c --root")); assert!(!is_dangerous("ls")); assert!(!is_dangerous("team members")); assert!(!is_dangerous("domain ls -a")); + assert!(!is_dangerous("share add mybox a@b.c")); + assert!(!is_dangerous("share remove mybox a@b.c --root")); + assert!(!is_dangerous("team settings auto-join off")); + assert!(!is_dangerous("billing credits usage --group=day")); + assert!(!is_dangerous("pool list")); } } diff --git a/docs/exe-dev-api-reference.md b/docs/exe-dev-api-reference.md index c4318d6..7d7b40f 100644 --- a/docs/exe-dev-api-reference.md +++ b/docs/exe-dev-api-reference.md @@ -1,7 +1,7 @@ # exe.dev API Reference Notes This file records the exe.dev API documentation that this repository depends -on. It was last checked on 2026-07-10 from these source pages: +on. It was last checked on 2026-08-10 from these source pages: - - @@ -11,7 +11,10 @@ on. It was last checked on 2026-07-10 from these source pages: Each page is also available as raw markdown by appending `.md` to the URL, for example . The full command list lives -in the CLI reference at . +in the CLI reference at . +The whole documentation set is published as a single file at +, which is the fastest way to diff this +repository against upstream. ## API shape @@ -43,11 +46,19 @@ Example `ls --json` VM object shape: "region": "lon", "region_display": "London, UK", "ssh_dest": "bloggy.exe.xyz", + "ssh_host": "bloggy.exe.xyz", "status": "running", "vm_name": "bloggy" } ``` +`ssh_dest` is a ready-to-use `ssh`/`scp` destination. It may carry a username +prefix such as `vm+bloggy@exe.dev` when the VM hostname cannot route SSH +directly, so automation must not assume the destination is always +`.exe.xyz`. Tools that need the parts separately read `ssh_host` (the +network host to dial) and `ssh_user` (the username the routing requires; +absent when any username works). + Minimal HTTPS request: ```sh @@ -299,12 +310,17 @@ This repository uses the exe.dev HTTPS command API through - `exedev-ctl` wraps the exe.dev command surface, including `ls`, `new`, `rm`, `restart`, `rename`, `tag`, `comment`, `stat`, `cp`, `resize`, `share` (show, port, set-public, set-private, add, remove, add-link, remove-link, - receive-email, access), `domain` (add, ls, rm), `team`, `invite`, `whoami`, - `ssh-key`, `set-region`, `integrations`, `billing`, `shelley`, `browser`, and - raw `exec`. + receive-email, access), `domain` (add, ls, rm), `team`, `pool`, `invite`, + `whoami`, `ssh-key`, `set-region`, `integrations`, `billing`, `shelley`, + `browser`, and raw `exec`. +- Two documented commands are deliberately left to `exec`, because both take a + secret as a positional argument or flag value and would otherwise land in + shell history and the process list: `billing provider link --token=...` and + `exe0-to-exe1 `. Pipe those through `ssh exe.dev` instead. - `exedev-k8s` uses exe.dev VM commands as the infrastructure layer for k3s fleet bootstrapping; against exe.dev itself it only needs `ls`, `new`, and - `rm` (node provisioning happens over direct SSH to the VMs). + `rm` (node provisioning happens over direct SSH to the VMs). It reads + `ssh_dest` from `ls` to reach each node, falling back to `.exe.xyz`. For local scripts and manual debugging, prefer: diff --git a/docs/exedev-automation.md b/docs/exedev-automation.md index 24ccc5f..7e0493c 100644 --- a/docs/exedev-automation.md +++ b/docs/exedev-automation.md @@ -139,6 +139,11 @@ List VMs: ssh exe.dev ls --json ``` +Each VM object carries `ssh_dest`, the destination to use for `ssh` and `scp`. +It is usually `.exe.xyz`, but it may carry a username prefix such as +`vm+p1-a-1@exe.dev`, so bootstrap flows read it instead of building the +hostname themselves. + Create a VM: ```sh diff --git a/k8s_cli/README.md b/k8s_cli/README.md index 712b3e5..10ca880 100644 --- a/k8s_cli/README.md +++ b/k8s_cli/README.md @@ -221,9 +221,12 @@ Expected result: `test-ex-p1-a-1` and `test-ex-p2-b-1` are `Ready`, and confirmation unless `--yes` is passed. `destroy` always asks for confirmation, even when global `--yes` is present. -`bootstrap` creates missing VMs, installs Tailscale and k3s through local -`ssh exe.dev ssh ...`, applies labels and taints with `kubectl`, and -optionally runs `kubectl apply -f `. +`bootstrap` creates missing VMs, installs Tailscale and k3s over direct SSH to +each VM, applies labels and taints with `kubectl`, and optionally runs +`kubectl apply -f `. The SSH destination comes from `ssh_dest` in the +exe.dev `ls` response, so VMs whose hostname cannot route SSH directly are +reached through the username-prefixed destination exe.dev reports; VMs missing +from `ls` fall back to `.exe.xyz`. The generated kubeconfig and k3s token for new clusters are stored under: diff --git a/k8s_cli/README.zh-CN.md b/k8s_cli/README.zh-CN.md index f77ced2..f30ba98 100644 --- a/k8s_cli/README.zh-CN.md +++ b/k8s_cli/README.zh-CN.md @@ -201,8 +201,11 @@ kubectl --kubeconfig .exedev-k8s/exedev-test-minimal/kubeconfig get nodes -o wid `plan` 是 read-only。`bootstrap` 会打印 planned actions,并在没有传入 `--yes` 时请求确认。`destroy` 始终需要确认,即使传入全局 `--yes`。 -`bootstrap` 会创建缺失 VMs,通过本地 `ssh exe.dev ssh ...` 安装 Tailscale -和 k3s,用 `kubectl` 应用 labels/taints,并可选运行 `kubectl apply -f `。 +`bootstrap` 会创建缺失 VMs,通过直连 VM 的 SSH 安装 Tailscale 和 k3s,用 +`kubectl` 应用 labels/taints,并可选运行 `kubectl apply -f `。SSH +destination 取自 exe.dev `ls` 返回的 `ssh_dest`,因此 hostname 无法直接路由 SSH +的 VM 会使用 exe.dev 报告的带 username 前缀的 destination;`ls` 中不存在的 VM +回退到 `.exe.xyz`。 新 cluster 生成的 kubeconfig 和 k3s token 会保存到: diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index ca7dc7a..e129c2b 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -33,8 +33,8 @@ use kubectl::{ KUBECTL_PROBE_REQUEST_TIMEOUT, kubectl_apply, kubectl_capture, kubectl_capture_with_timeout, kubectl_run_owned, }; -use parsing::{parse_kubernetes_nodes, parse_vm_names}; -use process::{ensure_tool, remote_capture, remote_run, verify_vm_access}; +use parsing::{parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names}; +use process::{SshTargets, ensure_tool, remote_capture, remote_run, verify_vm_access}; use scripts::{ k3s_agent_install_command, k3s_server_install_command, remote_bootstrap_script, remote_privileged_script, tailscale_install_command, @@ -77,17 +77,37 @@ async fn run_bootstrap(endpoint: &str, yes: bool, cmd: BootstrapCmd) -> Result<( require_env(K3S_TOKEN_ENV)?; } let include_control_plane = cmd.mode == ClusterMode::New; - let current = fetch_current_vms(endpoint).await?; - print_bootstrap_plan(&plan, cmd.mode, ¤t, cmd.manifests.as_deref()); + let inventory = fetch_inventory(endpoint).await?; + print_bootstrap_plan(&plan, cmd.mode, &inventory.names, cmd.manifests.as_deref()); confirm("Run this bootstrap plan?", yes)?; let client = exe_client(endpoint)?; - create_missing_vms(&client, &plan, include_control_plane, ¤t, &cmd.fleet).await?; - let new_cluster_access = - bootstrap_k3s(&plan, cmd.mode, &ts_authkey, cmd.kubeconfig.as_deref()).await?; + create_missing_vms( + &client, + &plan, + include_control_plane, + &inventory, + &cmd.fleet, + ) + .await?; + // Re-read the VM list so VMs created above contribute their SSH destination. + let inventory = fetch_inventory(endpoint).await?; + let new_cluster_access = bootstrap_k3s( + &plan, + cmd.mode, + &ts_authkey, + cmd.kubeconfig.as_deref(), + &inventory.ssh_targets, + ) + .await?; let kubeconfig = kubeconfig_for_bootstrap(&plan, cmd.mode, cmd.kubeconfig.as_deref()); - wait_for_kubernetes_api(kubeconfig.as_deref(), new_cluster_access.as_ref()).await?; + wait_for_kubernetes_api( + kubeconfig.as_deref(), + new_cluster_access.as_ref(), + &inventory.ssh_targets, + ) + .await?; wait_for_kubernetes_nodes(&plan, include_control_plane, kubeconfig.as_deref()).await?; apply_node_metadata(&plan, include_control_plane, kubeconfig.as_deref()).await?; if let Some(manifests) = cmd.manifests { @@ -150,10 +170,23 @@ fn exe_client(endpoint: &str) -> Result { Ok(ExeDevClient::new(endpoint.to_string(), api_key)) } -async fn fetch_current_vms(endpoint: &str) -> Result> { +/// The exe.dev VMs this account can see, and how to reach each over SSH. +struct VmInventory { + names: BTreeSet, + ssh_targets: SshTargets, +} + +async fn fetch_inventory(endpoint: &str) -> Result { let client = exe_client(endpoint)?; let response = client.exec("ls").await?; - parse_vm_names(&response).context("failed to parse exe.dev ls response") + Ok(VmInventory { + names: parse_vm_names(&response).context("failed to parse exe.dev ls response")?, + ssh_targets: SshTargets::new(parse_ssh_destinations(&response)), + }) +} + +async fn fetch_current_vms(endpoint: &str) -> Result> { + Ok(fetch_inventory(endpoint).await?.names) } fn print_bootstrap_plan( @@ -281,11 +314,11 @@ async fn create_missing_vms( client: &ExeDevClient, plan: &FleetPlan, include_control_plane: bool, - current: &BTreeSet, + inventory: &VmInventory, fleet_path: &Path, ) -> Result<()> { for node in plan.bootstrap_nodes(include_control_plane) { - if current.contains(&node.name) { + if inventory.names.contains(&node.name) { continue; } let command = exe_new_command(node); @@ -297,7 +330,7 @@ async fn create_missing_vms( output::warn("exe.dev:"), output::vm(&node.name) ); - verify_vm_access(&node.name, fleet_path).await?; + verify_vm_access(&inventory.ssh_targets, &node.name, fleet_path).await?; println!( "{} verified SSH access to {}; continuing", output::success("exe.dev:"), @@ -316,6 +349,7 @@ async fn bootstrap_k3s( mode: ClusterMode, ts_authkey: &str, kubeconfig_arg: Option<&Path>, + targets: &SshTargets, ) -> Result> { match mode { ClusterMode::New => { @@ -323,8 +357,9 @@ async fn bootstrap_k3s( .control_plane() .context("fleet has no control-plane node")?; let mut token = read_or_create_k3s_token(&plan.cluster_name)?; - install_tailscale(&control.name, ts_authkey).await?; - let control_ip = remote_capture(&control.name, "tailscale ip -4 | head -n1").await?; + install_tailscale(targets, &control.name, ts_authkey).await?; + let control_ip = + remote_capture(targets, &control.name, "tailscale ip -4 | head -n1").await?; let control_ip = control_ip.trim(); if control_ip.is_empty() { bail!("failed to detect Tailscale IPv4 for {}", control.name); @@ -332,11 +367,11 @@ async fn bootstrap_k3s( let control_ip_addr = control_ip.parse::().with_context(|| { format!("invalid Tailscale IPv4 for {}: {control_ip}", control.name) })?; - install_k3s_server(&control.name, &token, control_ip, control_ip).await?; + install_k3s_server(targets, &control.name, &token, control_ip, control_ip).await?; let k3s_url = format!("https://{control_ip}:6443"); - token = fetch_k3s_node_token(&control.name).await?; + token = fetch_k3s_node_token(targets, &control.name).await?; write_secret_file(&generated_token_path(&plan.cluster_name), &token)?; - let kubeconfig = fetch_kubeconfig(&control.name, control_ip).await?; + let kubeconfig = fetch_kubeconfig(targets, &control.name, control_ip).await?; let kubeconfig_path = kubeconfig_arg .map(Path::to_path_buf) .unwrap_or_else(|| generated_kubeconfig_path(&plan.cluster_name)); @@ -352,9 +387,9 @@ async fn bootstrap_k3s( .iter() .filter(|node| node.role != NodeRole::ControlPlane) { - install_tailscale(&node.name, ts_authkey).await?; - let node_ip = fetch_tailscale_ip(&node.name).await?; - install_k3s_agent(&node.name, &k3s_url, &token, &node_ip).await?; + install_tailscale(targets, &node.name, ts_authkey).await?; + let node_ip = fetch_tailscale_ip(targets, &node.name).await?; + install_k3s_agent(targets, &node.name, &k3s_url, &token, &node_ip).await?; } Ok(Some(NewClusterAccess { control_name: control.name.clone(), @@ -375,35 +410,47 @@ async fn bootstrap_k3s( .iter() .filter(|node| node.role != NodeRole::ControlPlane) { - install_tailscale(&node.name, ts_authkey).await?; - let node_ip = fetch_tailscale_ip(&node.name).await?; - install_k3s_agent(&node.name, &k3s_url, &token, &node_ip).await?; + install_tailscale(targets, &node.name, ts_authkey).await?; + let node_ip = fetch_tailscale_ip(targets, &node.name).await?; + install_k3s_agent(targets, &node.name, &k3s_url, &token, &node_ip).await?; } Ok(None) } } } -async fn install_tailscale(vm: &str, authkey: &str) -> Result<()> { +async fn install_tailscale(targets: &SshTargets, vm: &str, authkey: &str) -> Result<()> { let command = tailscale_install_command(authkey); let script = remote_bootstrap_script(&command); - remote_run(vm, &script).await + remote_run(targets, vm, &script).await } -async fn install_k3s_server(vm: &str, token: &str, tls_san: &str, node_ip: &str) -> Result<()> { +async fn install_k3s_server( + targets: &SshTargets, + vm: &str, + token: &str, + tls_san: &str, + node_ip: &str, +) -> Result<()> { let command = k3s_server_install_command(vm, token, tls_san, node_ip); let script = remote_bootstrap_script(&command); - remote_run(vm, &script).await + remote_run(targets, vm, &script).await } -async fn install_k3s_agent(vm: &str, k3s_url: &str, token: &str, node_ip: &str) -> Result<()> { +async fn install_k3s_agent( + targets: &SshTargets, + vm: &str, + k3s_url: &str, + token: &str, + node_ip: &str, +) -> Result<()> { let command = k3s_agent_install_command(vm, k3s_url, token, node_ip); let script = remote_bootstrap_script(&command); - remote_run(vm, &script).await + remote_run(targets, vm, &script).await } -async fn fetch_tailscale_ip(vm: &str) -> Result { - let ip = remote_capture(vm, "tailscale ip -4 | head -n1").await?; +async fn fetch_tailscale_ip(targets: &SshTargets, vm: &str) -> Result { + let ip = remote_capture(targets, vm, "tailscale ip -4 | head -n1").await?; let ip = ip.trim(); if ip.is_empty() { bail!("failed to detect Tailscale IPv4 for {vm}"); @@ -413,9 +460,9 @@ async fn fetch_tailscale_ip(vm: &str) -> Result { Ok(ip.to_string()) } -async fn fetch_kubeconfig(vm: &str, control_ip: &str) -> Result { +async fn fetch_kubeconfig(targets: &SshTargets, vm: &str, control_ip: &str) -> Result { let script = remote_privileged_script("${SUDO} cat /etc/rancher/k3s/k3s.yaml"); - let kubeconfig = remote_capture(vm, &script).await?; + let kubeconfig = remote_capture(targets, vm, &script).await?; Ok(kubeconfig .replace( "https://127.0.0.1:6443", @@ -427,9 +474,9 @@ async fn fetch_kubeconfig(vm: &str, control_ip: &str) -> Result { )) } -async fn fetch_k3s_node_token(vm: &str) -> Result { +async fn fetch_k3s_node_token(targets: &SshTargets, vm: &str) -> Result { let script = remote_privileged_script("${SUDO} cat /var/lib/rancher/k3s/server/node-token"); - remote_capture(vm, &script) + remote_capture(targets, vm, &script) .await .map(|token| token.trim().to_string()) .with_context(|| format!("failed to fetch k3s node token from {vm}")) @@ -438,6 +485,7 @@ async fn fetch_k3s_node_token(vm: &str) -> Result { async fn wait_for_kubernetes_api( kubeconfig: Option<&Path>, new_cluster_access: Option<&NewClusterAccess>, + targets: &SshTargets, ) -> Result<()> { println!( "{}", @@ -466,7 +514,7 @@ async fn wait_for_kubernetes_api( } if let Some(access) = new_cluster_access { let local_detail = local_kubernetes_api_detail(access.control_ip); - let remote_detail = diagnose_control_plane(&access.control_name) + let remote_detail = diagnose_control_plane(targets, &access.control_name) .await .unwrap_or_else(|err| format!("failed to collect remote diagnostics: {err}")); bail!( @@ -492,7 +540,7 @@ fn tailscale_policy_hint() -> &'static str { "Tailscale policy hint: ensure workers can reach the control-plane on tcp:6443, for example tag:server -> tag:server tcp:6443, and ensure your local kubectl client can reach the control-plane on tcp:6443." } -async fn diagnose_control_plane(control_name: &str) -> Result { +async fn diagnose_control_plane(targets: &SshTargets, control_name: &str) -> Result { let script = remote_privileged_script( r#" echo "k3s readyz from control-plane:" @@ -518,7 +566,7 @@ tailscale ip -4 2>&1 || true tailscale status --self 2>&1 || true "#, ); - remote_capture(control_name, &script).await + remote_capture(targets, control_name, &script).await } async fn wait_for_kubernetes_nodes( diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 4ec0a5a..0014f29 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -9,6 +9,9 @@ pub(super) struct KubernetesNode { pub(super) taints: BTreeSet, } +/// JSON keys that can hold a VM name, most specific first. +const VM_NAME_KEYS: [&str; 5] = ["name", "vm", "vmname", "vmName", "vm_name"]; + pub(super) fn parse_vm_names(response: &str) -> Result> { let trimmed = response.trim(); if trimmed.is_empty() { @@ -39,7 +42,7 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { } } Value::Object(object) => { - for key in ["name", "vm", "vmname", "vmName", "vm_name"] { + for key in VM_NAME_KEYS { if let Some(name) = object.get(key).and_then(Value::as_str) { names.insert(name.to_string()); return; @@ -55,6 +58,65 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { } } +/// Map VM name to the SSH destination reported by `exe.dev ls`. +/// +/// exe.dev hostnames usually route SSH directly, but `ssh_dest` may carry a +/// username prefix (for example `vm+bloggy@exe.dev`) when they do not. VMs whose +/// destination cannot be read are left out, and the caller falls back to the +/// `.exe.xyz` hostname. +pub(super) fn parse_ssh_destinations(response: &str) -> BTreeMap { + let mut destinations = BTreeMap::new(); + if let Ok(value) = serde_json::from_str::(response.trim()) { + collect_ssh_destinations(&value, &mut destinations); + } + destinations +} + +fn collect_ssh_destinations(value: &Value, destinations: &mut BTreeMap) { + match value { + Value::Array(items) => { + for item in items { + collect_ssh_destinations(item, destinations); + } + } + Value::Object(object) => { + let name = VM_NAME_KEYS + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)); + if let Some(name) = name { + if let Some(destination) = ssh_destination_from_object(object) { + destinations.insert(name.to_string(), destination); + } + return; + } + for key in ["vms", "items", "data"] { + if let Some(child) = object.get(key) { + collect_ssh_destinations(child, destinations); + } + } + } + _ => {} + } +} + +fn ssh_destination_from_object(object: &serde_json::Map) -> Option { + let text = |key: &str| { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }; + if let Some(dest) = text("ssh_dest").or_else(|| text("sshDest")) { + return Some(dest.to_string()); + } + let host = text("ssh_host").or_else(|| text("sshHost"))?; + match text("ssh_user").or_else(|| text("sshUser")) { + Some(user) => Some(format!("{user}@{host}")), + None => Some(host.to_string()), + } +} + pub(super) fn parse_vm_names_from_text(text: &str) -> BTreeSet { text.lines() .map(str::trim) diff --git a/k8s_cli/src/manager/process.rs b/k8s_cli/src/manager/process.rs index df51afc..0a503ad 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -2,7 +2,7 @@ use crate::output; use anyhow::{Context, Result, bail}; use dialoguer::Confirm; use exedev_core::shell; -use std::{path::Path, process::Stdio}; +use std::{collections::BTreeMap, path::Path, process::Stdio}; use tokio::io::AsyncWriteExt; use tokio::process::Command as TokioCommand; use tokio::time::{Duration, sleep}; @@ -28,9 +28,28 @@ pub(super) struct RemoteCommandOutput { status: i32, } -pub(super) async fn remote_run(vm: &str, script: &str) -> Result<()> { +/// SSH destinations for fleet VMs, keyed by VM name. +#[derive(Debug, Default)] +pub(super) struct SshTargets(BTreeMap); + +impl SshTargets { + pub(super) fn new(destinations: BTreeMap) -> Self { + Self(destinations) + } + + /// The destination reported by exe.dev, or the `.exe.xyz` hostname when + /// exe.dev did not report one (for example a VM outside this account's `ls`). + pub(super) fn dest(&self, vm: &str) -> String { + self.0 + .get(vm) + .cloned() + .unwrap_or_else(|| format!("{vm}.exe.xyz")) + } +} + +pub(super) async fn remote_run(targets: &SshTargets, vm: &str, script: &str) -> Result<()> { loop { - let output = remote_command_output(vm, script).await?; + let output = remote_command_output(targets, vm, script).await?; if !output.stdout.is_empty() { print!("{}", output.stdout); if !output.stdout.ends_with('\n') { @@ -73,8 +92,8 @@ fn confirm_tailnet_lock_retry(vm: &str) -> Result { .context("failed to read Tailnet Lock confirmation") } -pub(super) async fn remote_capture(vm: &str, script: &str) -> Result { - let output = remote_command_output(vm, script).await?; +pub(super) async fn remote_capture(targets: &SshTargets, vm: &str, script: &str) -> Result { + let output = remote_command_output(targets, vm, script).await?; if output.status != 0 { let detail = [output.stdout.trim(), output.stderr.trim()] .into_iter() @@ -95,16 +114,24 @@ pub(super) async fn remote_capture(vm: &str, script: &str) -> Result { Ok(output.stdout) } -pub(super) async fn remote_command_output(vm: &str, script: &str) -> Result { +pub(super) async fn remote_command_output( + targets: &SshTargets, + vm: &str, + script: &str, +) -> Result { let wrapped_script = remote_status_script(vm, script); - let args = remote_ssh_args(vm); + let args = remote_ssh_args(&targets.dest(vm)); let refs = args.iter().map(String::as_str).collect::>(); let output = capture_remote_ssh_output(&refs, &wrapped_script).await?; parse_remote_command_output(vm, output) } -pub(super) async fn verify_vm_access(vm: &str, fleet_path: &Path) -> Result<()> { - remote_run(vm, "true").await.with_context(|| { +pub(super) async fn verify_vm_access( + targets: &SshTargets, + vm: &str, + fleet_path: &Path, +) -> Result<()> { + remote_run(targets, vm, "true").await.with_context(|| { format!( "VM name {vm} is unavailable but SSH access could not be verified; recover with `exedev-k8s destroy --fleet {} --all-planned`, or choose another vmPrefix", fleet_path.display() @@ -295,7 +322,7 @@ pub(super) fn display_command(program: &str, args: &[&str]) -> String { redact_command_secrets(&shell::shell_join(&words)) } -pub(super) fn remote_ssh_args(vm: &str) -> Vec { +pub(super) fn remote_ssh_args(dest: &str) -> Vec { vec![ "-o".into(), "ControlMaster=no".into(), @@ -305,7 +332,7 @@ pub(super) fn remote_ssh_args(vm: &str) -> Vec { "StrictHostKeyChecking=accept-new".into(), "-o".into(), "ConnectTimeout=15".into(), - format!("{vm}.exe.xyz"), + dest.to_string(), "sh".into(), "-s".into(), ] diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 6297f41..abac55a 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -1,8 +1,8 @@ use super::super::fleet::NodeSpec; use super::kubectl::kubeconfig_args; -use super::parsing::{parse_kubernetes_nodes, parse_vm_names}; +use super::parsing::{parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names}; use super::process::{ - command_output_detail, display_command, parse_remote_stdout, remote_ssh_args, + SshTargets, command_output_detail, display_command, parse_remote_stdout, remote_ssh_args, remote_status_script, }; use super::scripts::{ @@ -139,7 +139,7 @@ fn k3s_agent_install_command_supports_no_supervisor_fallback() { #[test] fn builds_remote_ssh_command_for_stdin_script() { - let args = remote_ssh_args("vm-1"); + let args = remote_ssh_args("vm-1.exe.xyz"); assert_eq!(args.len(), 11); assert_eq!(args[0], "-o"); assert_eq!(args[1], "ControlMaster=no"); @@ -154,6 +154,35 @@ fn builds_remote_ssh_command_for_stdin_script() { assert_eq!(args[10], "-s"); } +#[test] +fn parses_ssh_destinations_from_ls_json() { + let destinations = parse_ssh_destinations( + r#"{"vms":[ + {"vm_name":"routable","ssh_dest":"routable.exe.xyz","ssh_host":"routable.exe.xyz"}, + {"vm_name":"prefixed","ssh_dest":"vm+prefixed@exe.dev","ssh_host":"exe.dev","ssh_user":"vm+prefixed"}, + {"vm_name":"host-only","ssh_host":"shard3.exe.dev","ssh_user":"vm+host-only"}, + {"vm_name":"unknown"} + ]}"#, + ); + assert_eq!(destinations.get("routable").unwrap(), "routable.exe.xyz"); + assert_eq!(destinations.get("prefixed").unwrap(), "vm+prefixed@exe.dev"); + assert_eq!( + destinations.get("host-only").unwrap(), + "vm+host-only@shard3.exe.dev" + ); + assert!(!destinations.contains_key("unknown")); +} + +#[test] +fn ssh_targets_fall_back_to_exe_xyz_hostname() { + let targets = SshTargets::new(parse_ssh_destinations( + r#"[{"vm_name":"vm-1","ssh_dest":"vm+vm-1@exe.dev"}]"#, + )); + assert_eq!(targets.dest("vm-1"), "vm+vm-1@exe.dev"); + assert_eq!(targets.dest("vm-2"), "vm-2.exe.xyz"); + assert_eq!(SshTargets::default().dest("vm-3"), "vm-3.exe.xyz"); +} + #[test] fn display_command_redacts_bootstrap_secrets() { let command = display_command( diff --git a/skills/exedev-ctl/SKILL.md b/skills/exedev-ctl/SKILL.md index a036fb3..db7fe59 100644 --- a/skills/exedev-ctl/SKILL.md +++ b/skills/exedev-ctl/SKILL.md @@ -33,14 +33,15 @@ Check the current environment and scope before proposing changes: - Verify `EXE_DEV_API_KEY` is present for HTTPS `/exec` operations. - Use `exedev-ctl --json ls` to inspect current VMs. - Treat destructive VM actions as high risk. Require explicit confirmation before `rm`, bulk deletion, or operations that could lose disk state unless the user already asked for that exact action. +- Treat access grants as high risk too. `share add --root` and `share access allow ` give SSH, Terminal, and Shelley access, not web-only access; `billing credits buy` spends money. The CLI prompts for these unless `--yes` is passed. - When a token returns `403`, inspect token permissions before assuming a VM or CLI bug. - When `/exec` returns `422`, surface the exe.dev command failure body. ## Command Selection -Use typed wrappers for supported commands: `help`, `doc`, `ls`, `new`, `rm`, `restart`, `rename`, `tag`, `comment`, `stat`, `cp`, `resize`, `share`, `domain`, `team`, `invite`, `whoami`, `ssh-key`, `set-region`, `integrations`, `billing`, `shelley`, `browser`, `ssh`, and `grant-support-root`. +Use typed wrappers for supported commands: `help`, `doc`, `ls`, `new`, `rm`, `restart`, `rename`, `tag`, `comment`, `stat`, `cp`, `resize`, `share`, `domain`, `team`, `pool`, `invite`, `whoami`, `ssh-key`, `set-region`, `integrations`, `billing`, `shelley`, `browser`, `ssh`, and `grant-support-root`. -Use `exec -- ` only for raw exe.dev commands that do not yet have a typed wrapper. +Use `exec -- ` only for raw exe.dev commands that do not yet have a typed wrapper. `billing provider link` and `exe0-to-exe1` have no wrapper on purpose: both take a token as an argument, so run them through `exec --` or `ssh exe.dev` and keep the token out of persisted history. Use `--json` when output must be parsed, compared, or included in automation. diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index 104ff0c..705e7d0 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -199,6 +199,22 @@ exedev-ctl share add-link p1-a-1 exedev-ctl share remove-link p1-a-1 ``` +`share add` grants web-proxy access. Add `--root` to grant SSH, Terminal, and +Shelley access instead, and `share remove --root` to downgrade shell access back +to web-only: + +```sh +exedev-ctl share add p1-a-1 teammate@example.com --root +exedev-ctl share remove p1-a-1 teammate@example.com --root +``` + +Control inbound and outbound VM email: + +```sh +exedev-ctl share receive-email p1-a-1 on +exedev-ctl share receive-email p1-a-1 --reply-policy known +``` + Manage custom domains after DNS points at the VM: ```sh @@ -215,12 +231,49 @@ exedev-ctl ssh p1-a-1 ssh p1-a-1.exe.xyz ``` +`.exe.xyz` is the usual SSH destination, but exe.dev reports the authoritative +one as `ssh_dest` in `ls --json`, and it may carry a username prefix such as +`vm+p1-a-1@exe.dev`. Read `ssh_dest` before hardcoding a hostname in scripts. + +Manage team pools (reserved capacity for team VMs): + +```sh +exedev-ctl pool list +exedev-ctl pool new builders --cpus 16 --region fra --max-vms 20 +exedev-ctl new --name p1-a-1 --pool builders --no-email +exedev-ctl --yes pool delete builders --force +``` + +Manage integrations, including time-boxed and read-only grants: + +```sh +exedev-ctl --json integrations list --usage +exedev-ctl integrations test my-mcp +exedev-ctl integrations catalog stripe +exedev-ctl integrations attach gmail vm:p1-a-1 --for 2h +exedev-ctl integrations add github --name repo --repository owner/repo --readonly +``` + +Report billing and Shelley credit usage: + +```sh +exedev-ctl billing usage --range 7d +exedev-ctl billing credits usage --group box --detail +exedev-ctl billing credits transactions --limit 50 +exedev-ctl billing payment list +``` + Run raw exe.dev command: ```sh exedev-ctl exec -- whoami ``` +`billing provider link` and `exe0-to-exe1` have no typed wrapper on purpose: +each takes a token as an argument. Run them through `exec --` or `ssh exe.dev` +so the token is supplied from an environment variable rather than a stored +wrapper invocation. + ## Token Generation Helper The `exedev-ctl` wrapper supports exe.dev token generation with `--label`, `--vm`, `--cmds`, and `--exp`: From 61304efac25fdd375d2ea429ae2e7b0ac62cf9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:24:27 +0800 Subject: [PATCH 03/18] fix: harden release tag handling and correct token-exposure guidance The three release-workflow steps that resolve the tag interpolated `inputs.tag_name` straight into their bash scripts, so a crafted workflow_dispatch input ran as shell. They now receive it through the step environment and read the variable instead. `set-version.sh` validated versions with a loose "digits, dots and dashes" pattern. It rejected the valid tag 1.2.3-rc.1+build.5, because build metadata can follow a prerelease, and accepted invalid ones like 01.2.3, which cargo refuses later in the release with a much less obvious error. Replaced with semver.org's reference grammar. The docs presented `exec --` and `ssh exe.dev` as a way to keep tokens out of persisted history, which they are not: the token is a command argument either way. They now state the actual reason these two commands have no wrapper (they are one-time onboarding steps a wrapper would not make safer) and the actual exposure (variable expansion keeps the literal out of shell history, but the expanded value is still readable in local process arguments). --- .github/workflows/release.yml | 14 +++++++++++--- cli/README.md | 13 +++++++++---- cli/README.zh-CN.md | 11 ++++++++--- scripts/release/set-version.sh | 10 +++++++++- skills/exedev-ctl/SKILL.md | 2 +- skills/exedev-ctl/references/exedev-ctl.md | 16 +++++++++++++--- 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c9947b..1f1fedf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,11 +77,15 @@ jobs: # rather than the tag it ships under. - name: Sync crate versions with release tag shell: bash + env: + # Passed through the environment rather than interpolated into the script, + # so a crafted dispatch input cannot inject shell commands. + INPUT_TAG_NAME: ${{ inputs.tag_name }} run: | set -euo pipefail tag="${GITHUB_REF_NAME}" if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${{ inputs.tag_name }}" + tag="${INPUT_TAG_NAME}" fi scripts/release/set-version.sh "${tag}" @@ -91,12 +95,14 @@ jobs: - name: Package release archive id: package shell: bash + env: + INPUT_TAG_NAME: ${{ inputs.tag_name }} run: | set -euo pipefail tag="${GITHUB_REF_NAME}" if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${{ inputs.tag_name }}" + tag="${INPUT_TAG_NAME}" fi archive="exedev-clis-${tag}-${{ matrix.platform }}.tar.gz" @@ -139,11 +145,13 @@ jobs: - name: Resolve release tag id: meta shell: bash + env: + INPUT_TAG_NAME: ${{ inputs.tag_name }} run: | set -euo pipefail tag="${GITHUB_REF_NAME}" if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${{ inputs.tag_name }}" + tag="${INPUT_TAG_NAME}" fi echo "tag=${tag}" >> "${GITHUB_OUTPUT}" diff --git a/cli/README.md b/cli/README.md index 9a7ed3d..6b11671 100644 --- a/cli/README.md +++ b/cli/README.md @@ -167,11 +167,16 @@ grant-support-root exit exec `exec` is the fallback command for future exe.dev commands that do not yet have a typed wrapper. -Two documented commands are intentionally left to `exec`, because each takes a -secret as an argument and a typed wrapper would only make it easier to leak it -into shell history and the process list: +Two documented commands are intentionally left to `exec`. Both are one-time +onboarding steps with no automation value, and both take a token as a positional +argument or flag value, which a typed wrapper would not make any safer: ```sh -exedev-ctl exec -- billing provider link aws --token=... +exedev-ctl exec -- billing provider link aws --token="$MARKETPLACE_TOKEN" exedev-ctl exec -- exe0-to-exe1 "$TOKEN" ``` + +Expanding a variable keeps the token literal out of your shell history, but the +expanded value still appears in the process arguments of the local `exedev-ctl` +and `ssh` processes, where any local process running as your user can read it. +exe.dev exposes no argument-free input path for these two commands. diff --git a/cli/README.zh-CN.md b/cli/README.zh-CN.md index 20f10d9..fbbad84 100644 --- a/cli/README.zh-CN.md +++ b/cli/README.zh-CN.md @@ -164,10 +164,15 @@ grant-support-root exit exec `exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。 -有两个已文档化的 command 不提供 typed wrapper:它们都以 argument 传递 secret, -包装后只会更容易把 secret 写进 shell history 和进程表。 +有两个已文档化的 command 不提供 typed wrapper:它们都是一次性接入操作,没有 +automation 价值,并且都以 argument 传递 token —— 包装成 typed wrapper 并不会让它 +更安全。 ```sh -exedev-ctl exec -- billing provider link aws --token=... +exedev-ctl exec -- billing provider link aws --token="$MARKETPLACE_TOKEN" exedev-ctl exec -- exe0-to-exe1 "$TOKEN" ``` + +用变量展开可以避免 token 字面量写进 shell history,但展开后的值仍然出现在本地 +`exedev-ctl` 和 `ssh` 进程的 arguments 中,同一用户下的任何本地进程都能读到。 +exe.dev 对这两个 command 没有提供不经过 argument 的输入方式。 diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 2ca8b99..4d243d0 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -27,7 +27,15 @@ fi VERSION="${VERSION#v}" -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then +# semver.org's reference grammar. The looser "digits, dots and dashes" shape this +# replaces rejected a valid tag like 1.2.3-rc.1+build.5, because build metadata can +# follow a prerelease, and accepted invalid ones like 01.2.3, which cargo refuses +# later in the release with a much less obvious error. +SEMVER_NUM='(0|[1-9][0-9]*)' +SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" + +if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then echo "not a semantic version: $VERSION" >&2 exit 1 fi diff --git a/skills/exedev-ctl/SKILL.md b/skills/exedev-ctl/SKILL.md index db7fe59..94de074 100644 --- a/skills/exedev-ctl/SKILL.md +++ b/skills/exedev-ctl/SKILL.md @@ -41,7 +41,7 @@ Check the current environment and scope before proposing changes: Use typed wrappers for supported commands: `help`, `doc`, `ls`, `new`, `rm`, `restart`, `rename`, `tag`, `comment`, `stat`, `cp`, `resize`, `share`, `domain`, `team`, `pool`, `invite`, `whoami`, `ssh-key`, `set-region`, `integrations`, `billing`, `shelley`, `browser`, `ssh`, and `grant-support-root`. -Use `exec -- ` only for raw exe.dev commands that do not yet have a typed wrapper. `billing provider link` and `exe0-to-exe1` have no wrapper on purpose: both take a token as an argument, so run them through `exec --` or `ssh exe.dev` and keep the token out of persisted history. +Use `exec -- ` only for raw exe.dev commands that do not yet have a typed wrapper. `billing provider link` and `exe0-to-exe1` have no wrapper on purpose: both are one-time onboarding steps, and both take a token as an argument, which a wrapper would not make safer. Expanding a variable such as `"$TOKEN"` keeps the literal out of shell history, but the token still appears in local process arguments either way; say so rather than presenting `exec` or SSH as protection. Use `--json` when output must be parsed, compared, or included in automation. diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index 705e7d0..2d45b0d 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -270,9 +270,19 @@ exedev-ctl exec -- whoami ``` `billing provider link` and `exe0-to-exe1` have no typed wrapper on purpose: -each takes a token as an argument. Run them through `exec --` or `ssh exe.dev` -so the token is supplied from an environment variable rather than a stored -wrapper invocation. +both are one-time onboarding steps, and each takes a token as an argument, which +a wrapper would not make safer. + +```sh +exedev-ctl exec -- exe0-to-exe1 "$TOKEN" +``` + +Expanding a variable avoids writing the token literal into shell history. It +does not hide the token from the local process list: the expanded value is +visible in the arguments of the `exedev-ctl` and `ssh` processes to any process +running as the same user. exe.dev offers no argument-free input path for these +two commands, so treat the token as exposed locally and prefer short `--exp` +values. ## Token Generation Helper From c281a48edbcb0956e1ab560ac19fd8c5936dfd8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:07:08 +0800 Subject: [PATCH 04/18] fix: validate release inputs and widen the dangerous-command guard sync-homebrew-tap.sh took a release tag from an argument, the environment, or `gh` and interpolated it into download URLs, local paths, and double-quoted Ruby strings with no validation, so a tag like `v1.2.3"-bad` or one containing a slash produced a formula that generation reported as a success and Homebrew could not parse. The same held for the configurable formula metadata. Both are now validated up front, against the same semver grammar set-version.sh uses. The dangerous-command guard prompted for `integrations detach` and `edit` but not for `add` (which accepts --attach specs) or `attach`, so handing a credential to a VM was silent while taking it away was not. It also covered `team remove`, `role`, and `transfer` but not `team add`, `team auth set`, or `team settings vm-sharing`, which change who holds authority over the team. The release workflow passed a dispatch input to actions/checkout as a bare ref, so a branch name or SHA would build a ref that is not the tag the publish job creates a release for. It now resolves the input under refs/tags/. `exec` still sends its arguments verbatim, and the reasoning is now recorded next to the code and in both READMEs: injecting `--yes` could carry out a destructive action the user never typed, while the global `--json` selects an output format and does not change what the command does. --- .github/workflows/release.yml | 5 +++- cli/README.md | 7 +++++- cli/README.zh-CN.md | 6 ++++- cli/src/cli_command.rs | 8 +++++- core/src/shell.rs | 16 ++++++++++++ scripts/release/sync-homebrew-tap.sh | 37 ++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f1fedf..47945f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,10 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref }} + # `refs/tags/` rather than the bare input: a dispatch input is a free-form + # string, so a branch name or SHA would otherwise check out and build a ref + # that is not the tag the publish job creates the release for. + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.ref }} - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/cli/README.md b/cli/README.md index 6b11671..0c0ddb9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -165,7 +165,12 @@ grant-support-root exit exec ``` `exec` is the fallback command for future exe.dev commands that do not yet have -a typed wrapper. +a typed wrapper. Its arguments are sent as written: no flag is injected into +them, so a command that prompts server-side (`team disable`, +`billing credits buy`) needs its own `--yes` inside the raw command. The global +`--yes` still skips this CLI's own confirmation prompt, and the global `--json` +still applies, because it selects the output format rather than changing what +the command does. Two documented commands are intentionally left to `exec`. Both are one-time onboarding steps with no automation value, and both take a token as a positional diff --git a/cli/README.zh-CN.md b/cli/README.zh-CN.md index fbbad84..2c8ece2 100644 --- a/cli/README.zh-CN.md +++ b/cli/README.zh-CN.md @@ -162,7 +162,11 @@ pool invite whoami ssh-key set-region integrations billing shelley browser ssh grant-support-root exit exec ``` -`exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。 +`exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。它的 +arguments 原样发送,不会被注入任何 flag,因此会在服务端要求确认的命令 +(`team disable`、`billing credits buy`)需要自己在 raw command 里带 `--yes`。 +全局 `--yes` 仍然会跳过本 CLI 自身的确认提示;全局 `--json` 也仍然生效,因为它 +选择的是输出格式,不改变命令本身的行为。 有两个已文档化的 command 不提供 typed wrapper:它们都是一次性接入操作,没有 automation 价值,并且都以 argument 传递 token —— 包装成 typed wrapper 并不会让它 diff --git a/cli/src/cli_command.rs b/cli/src/cli_command.rs index 981591e..9edf427 100644 --- a/cli/src/cli_command.rs +++ b/cli/src/cli_command.rs @@ -144,10 +144,16 @@ pub(crate) fn build_command(command: &Commands) -> Result { ]); } Commands::Exit => words.push("exit".into()), - // `exec` is a raw passthrough, so it is left exactly as the user typed it. Commands::Exec(cmd) => words.extend(cmd.command.clone()), } + // `exec` never has `--yes` injected: the user spelled the command out, and + // silently adding a flag that suppresses a server-side confirmation could + // carry out a destructive action they did not agree to. The global `--yes` + // still applies to `exec`, because it controls the local prompt in + // `guard_dangerous_command`, which runs on the built command either way. + // (The global `--json` is separate: it is an output-format flag, so the SSH + // path appends it to any command, `exec` included.) if !matches!(command, Commands::Exec(_)) && needs_server_confirmation(&words) { words.push("--yes".into()); } diff --git a/core/src/shell.rs b/core/src/shell.rs index 6d16508..268a2bc 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -48,13 +48,21 @@ fn is_dangerous(command: &str) -> bool { "share access allow ", "grant-support-root ", "ssh-key remove ", + // `add` can carry --attach specs and `attach` mounts the credential into + // VMs, so both hand out access just as `detach` and `edit` take it away. + "integrations add ", + "integrations attach ", "integrations remove ", "integrations setup ", "integrations detach ", "integrations edit ", + // Everything that changes who holds authority over the team or its VMs. + "team add ", "team remove ", "team role ", "team transfer ", + "team auth set ", + "team settings vm-sharing ", "team disable", "team settings auto-join on", "domain rm ", @@ -110,8 +118,16 @@ mod tests { assert!(is_dangerous("share access allow mybox")); assert!(is_dangerous("team settings auto-join on")); assert!(is_dangerous("share add mybox a@b.c --root")); + assert!(is_dangerous("integrations add github --name repo")); + assert!(is_dangerous("integrations attach my-mcp auto:all")); + assert!(is_dangerous("team add a@b.c admin")); + assert!(is_dangerous("team auth set oidc --issuer-url https://x")); + assert!(is_dangerous("team settings vm-sharing all-members")); assert!(!is_dangerous("ls")); assert!(!is_dangerous("team members")); + assert!(!is_dangerous("team settings")); + assert!(!is_dangerous("integrations list --usage")); + assert!(!is_dangerous("integrations catalog stripe")); assert!(!is_dangerous("domain ls -a")); assert!(!is_dangerous("share add mybox a@b.c")); assert!(!is_dangerous("share remove mybox a@b.c --root")); diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 11bb9b1..4d9caa2 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -40,6 +40,43 @@ fi VERSION="${RELEASE_TAG#v}" +# Every value below is interpolated into download URLs, local file paths, and +# double-quoted Ruby strings in the formula. Validate them here rather than +# escaping at each use: a stray quote, newline, or slash otherwise produces a +# formula that generation reports as a success and Homebrew cannot parse. +SEMVER_NUM='(0|[1-9][0-9]*)' +SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" + +if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then + echo "release tag is not a semantic version: $RELEASE_TAG" >&2 + echo "Expected something like v0.1.11 or 1.2.3-rc.1+build.5." >&2 + exit 1 +fi + +if [[ ! "$REPO_SLUG" =~ ^[0-9A-Za-z._-]+/[0-9A-Za-z._-]+$ ]]; then + echo "REPO_SLUG is not an owner/repo slug: $REPO_SLUG" >&2 + exit 1 +fi + +if [[ ! "$FORMULA_NAME" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "FORMULA_NAME is not a formula name: $FORMULA_NAME" >&2 + exit 1 +fi + +if [[ ! "$FORMULA_CLASS" =~ ^[A-Z][0-9A-Za-z_]*$ ]]; then + echo "FORMULA_CLASS is not a Ruby constant: $FORMULA_CLASS" >&2 + exit 1 +fi + +for field in FORMULA_DESC FORMULA_LICENSE; do + value="${!field}" + if [[ -z "$value" || "$value" == *\"* || "$value" == *\\* || "$value" == *"#"* || "$value" == *$'\n'* ]]; then + echo "$field must be non-empty and free of quotes, backslashes, '#', and newlines: $value" >&2 + exit 1 + fi +done + if [[ -z "$TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" ]]; then # homebrew-core files its formulae under the first character of their name — # `Formula/e/exedev-cli.rb` — while a flat personal tap keeps them directly under From 6b6a1b9dc07e045ed78ecba78abcdaff028cb9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:36:44 +0800 Subject: [PATCH 05/18] ci: stop persisting the checkout token in the release build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build job runs no git operations after checkout and compiles third-party crates, whose build scripts execute with the workspace present. Leaving the token in .git/config only widened what a compromised dependency could reach. Nothing depended on it: the workspace has no git dependencies, so cargo never needs the credential, and the publish job does not check out at all — softprops/action-gh-release authenticates with GITHUB_TOKEN directly. --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47945f3..93af387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,10 @@ jobs: # string, so a branch name or SHA would otherwise check out and build a ref # that is not the tag the publish job creates the release for. ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.ref }} + # This job runs no git operations after checkout, and the build compiles + # third-party crates, so leaving the token in .git/config would only widen + # what a compromised dependency can reach. + persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@stable From 7d833067a01993cc2e4af7680122943e82c5ef07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:24:36 +0800 Subject: [PATCH 06/18] fix: bound remote SSH steps and close secret and readiness gaps `ConnectTimeout=15` bounded only the connect, so a remote step that stopped responding (a package install, a curl, a service start) blocked bootstrap with no upper limit and held the ssh process and its pipes open. Each attempt now runs under a 15-minute bound covering both the script write and the wait, with kill_on_drop so the elapsed attempt takes the process with it. A timeout is not retried: unlike the transport failures the 255 retry exists for, a stuck step would only be repeated. write_secret_file created the kubeconfig and cluster token with fs::write and tightened them afterwards, so the contents existed at the umask's permissions in between and stayed there if the process died in that window. It also followed a symlink, letting a --kubeconfig path redirect secret contents into another file. It now unlinks any existing entry and creates with 0600 applied at creation. wait_for_kubernetes_nodes returned as soon as every node name existed, but k3s registers a node before it can run anything, so labels, taints, and manifests could be applied to a cluster with every node NotReady. It now waits for the Ready condition the parser already reads, and names unregistered and unready nodes separately. parse_ssh_destinations now also reads the {"output":""} wrapper that parse_vm_names falls back to, so a wrapped listing keeps its authoritative ssh_dest rather than silently degrading to the hostname. A wrapped table stays empty, as before. ARCHIVE_PREFIX was the one tap-formula input still reaching the Ruby strings unvalidated. --- k8s_cli/src/manager/mod.rs | 21 ++++++++++-- k8s_cli/src/manager/parsing.rs | 16 ++++++++-- k8s_cli/src/manager/process.rs | 48 +++++++++++++++++++++------- k8s_cli/src/manager/state.rs | 29 ++++++++++++++--- k8s_cli/src/manager/tests.rs | 47 +++++++++++++++++++++++++++ scripts/release/sync-homebrew-tap.sh | 5 +++ 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index e129c2b..4a0478f 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -599,10 +599,25 @@ async fn wait_for_kubernetes_nodes( .filter(|name| !nodes.contains_key(*name)) .cloned() .collect::>(); - if missing.is_empty() { + // k3s registers a node before it can run anything, so registration + // alone is not enough: the labels, taints, and manifests applied + // right after this call need nodes that are actually Ready. + let unready = expected + .iter() + .filter(|name| nodes.get(*name).is_some_and(|node| !node.ready)) + .cloned() + .collect::>(); + if missing.is_empty() && unready.is_empty() { return Ok(()); } - last_error = format!("missing nodes: {}", missing.join(", ")); + last_error = [ + (!missing.is_empty()).then(|| format!("missing: {}", missing.join(", "))), + (!unready.is_empty()).then(|| format!("not ready: {}", unready.join(", "))), + ] + .into_iter() + .flatten() + .collect::>() + .join("; "); } Err(err) => last_error = err.to_string(), } @@ -614,7 +629,7 @@ async fn wait_for_kubernetes_nodes( sleep(KUBERNETES_WAIT_DELAY).await; } } - bail!("Kubernetes nodes did not register: {last_error}"); + bail!("Kubernetes nodes did not become ready: {last_error}"); } async fn apply_node_metadata( diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 0014f29..e6d3471 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -66,8 +66,20 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { /// `.exe.xyz` hostname. pub(super) fn parse_ssh_destinations(response: &str) -> BTreeMap { let mut destinations = BTreeMap::new(); - if let Ok(value) = serde_json::from_str::(response.trim()) { - collect_ssh_destinations(&value, &mut destinations); + let Ok(value) = serde_json::from_str::(response.trim()) else { + return destinations; + }; + collect_ssh_destinations(&value, &mut destinations); + if destinations.is_empty() { + // Same wrapper `parse_vm_names` falls back to. When it holds a rendered + // table there is nothing to find and the caller keeps the hostname + // fallback; when it holds the serialized listing, the destinations are + // in there and are the authoritative ones. + if let Some(output) = value.get("output").and_then(Value::as_str) + && let Ok(inner) = serde_json::from_str::(output.trim()) + { + collect_ssh_destinations(&inner, &mut destinations); + } } destinations } diff --git a/k8s_cli/src/manager/process.rs b/k8s_cli/src/manager/process.rs index 0a503ad..63df5dc 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -5,7 +5,7 @@ use exedev_core::shell; use std::{collections::BTreeMap, path::Path, process::Stdio}; use tokio::io::AsyncWriteExt; use tokio::process::Command as TokioCommand; -use tokio::time::{Duration, sleep}; +use tokio::time::{Duration, sleep, timeout}; const REMOTE_EXIT_PREFIX: &str = "__EXEDEV_K8S_EXIT__:"; @@ -13,6 +13,12 @@ const REMOTE_SSH_ATTEMPTS: usize = 5; const REMOTE_SSH_RETRY_DELAY: Duration = Duration::from_secs(3); +/// Upper bound on a single remote step, covering the whole exchange rather than +/// just the connect that `ConnectTimeout` bounds. Generous enough that the slowest +/// real step (a k3s or Tailscale install on a cold VM) never reaches it, so hitting +/// it means the remote side is stuck rather than slow. +const REMOTE_SSH_TIMEOUT: Duration = Duration::from_secs(900); + const TAILNET_LOCK_AUTH_REQUIRED_STATUS: i32 = 126; #[derive(Debug, Eq, PartialEq)] @@ -220,20 +226,40 @@ pub(super) async fn capture_remote_ssh_output( .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) + // The timed future below is dropped when it elapses, which must take the + // ssh process and its pipes with it rather than leaking both. + .kill_on_drop(true) .spawn() .context("failed to run ssh")?; - let write_result = if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(script.as_bytes()) + // Both the script write and the wait are inside the timeout: a remote side + // that stops reading stdin blocks the write just as a hung script blocks + // the wait. + let attempt_result = timeout(REMOTE_SSH_TIMEOUT, async { + let write_result = if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(script.as_bytes()) + .await + .map_err(anyhow::Error::from) + } else { + Ok(()) + }; + child + .wait_with_output() .await - .map_err(anyhow::Error::from) - } else { - Ok(()) + .context("failed to wait for ssh") + .map(|output| (write_result, output)) + }) + .await; + let (write_result, output) = match attempt_result { + Ok(result) => result?, + // Not retried: a step that stops responding is not the transient + // transport failure the 255 retry below exists for, and rerunning it + // would repeat whatever the remote side already did. + Err(_) => bail!( + "remote command on this VM produced no result within {}s and was killed; check the VM directly, then rerun exedev-k8s bootstrap", + REMOTE_SSH_TIMEOUT.as_secs() + ), }; - let output = child - .wait_with_output() - .await - .context("failed to wait for ssh")?; if let Err(err) = write_result && output.status.success() { diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 9d73c4f..5b9b16b 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -3,7 +3,8 @@ use anyhow::{Context, Result}; use rand::{RngExt, distr::Alphanumeric}; use std::{ env, fs, - os::unix::fs::PermissionsExt, + io::{self, Write}, + os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, }; @@ -42,14 +43,34 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { Ok(token) } +/// Writes a kubeconfig or cluster token so it is never readable by anyone else, +/// not even briefly. +/// +/// Creating the file and then tightening it leaves the contents at the umask's +/// permissions in between, and a crash in that window leaves them there. Any +/// existing entry is unlinked first, so the create below applies 0600 from the +/// start and cannot follow a symlink planted at a caller-supplied `--kubeconfig` +/// path into a file that is readable elsewhere. pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .with_context(|| format!("failed to set permissions on {}", path.display()))?; + match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err).with_context(|| format!("failed to replace {}", path.display())); + } + } + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index abac55a..8a7a766 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -173,6 +173,21 @@ fn parses_ssh_destinations_from_ls_json() { assert!(!destinations.contains_key("unknown")); } +#[test] +fn parses_ssh_destinations_from_output_wrapped_json() { + let destinations = parse_ssh_destinations( + r#"{"output":"[{\"vm_name\":\"vm-1\",\"ssh_dest\":\"vm+vm-1@exe.dev\"}]"}"#, + ); + assert_eq!(destinations.get("vm-1").unwrap(), "vm+vm-1@exe.dev"); +} + +#[test] +fn ignores_output_wrapped_table_text() { + let destinations = + parse_ssh_destinations(r#"{"output":"NAME STATUS\nvm1 running\nvm2 stopped\n"}"#); + assert!(destinations.is_empty()); +} + #[test] fn ssh_targets_fall_back_to_exe_xyz_hostname() { let targets = SshTargets::new(parse_ssh_destinations( @@ -297,3 +312,35 @@ fn parses_kubernetes_node_metadata() { .contains("exedev.dev/pool=project1-a:NoSchedule") ); } + +#[test] +fn secret_files_are_never_group_or_world_readable() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("exedev-k8s-secret-{}", std::process::id())); + let path = dir.join("k3s-token"); + let _ = std::fs::remove_dir_all(&dir); + + write_secret_file(&path, "first").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "fresh secret file mode"); + + // Rewriting must not inherit the mode of whatever was there before. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + write_secret_file(&path, "second").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "rewritten secret file mode"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); + + // A symlink at the destination is replaced, not followed. + let elsewhere = dir.join("elsewhere"); + std::fs::write(&elsewhere, "untouched").unwrap(); + let link = dir.join("linked-kubeconfig"); + std::os::unix::fs::symlink(&elsewhere, &link).unwrap(); + write_secret_file(&link, "secret").unwrap(); + assert_eq!(std::fs::read_to_string(&elsewhere).unwrap(), "untouched"); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "secret"); + assert!(!std::fs::symlink_metadata(&link).unwrap().is_symlink()); + + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 4d9caa2..85e6839 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -64,6 +64,11 @@ if [[ ! "$FORMULA_NAME" =~ ^[0-9A-Za-z._-]+$ ]]; then exit 1 fi +if [[ ! "$ARCHIVE_PREFIX" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "ARCHIVE_PREFIX is not an archive name prefix: $ARCHIVE_PREFIX" >&2 + exit 1 +fi + if [[ ! "$FORMULA_CLASS" =~ ^[A-Z][0-9A-Za-z_]*$ ]]; then echo "FORMULA_CLASS is not a Ruby constant: $FORMULA_CLASS" >&2 exit 1 From a28655b76b612c63c94d4723ce19fd5d597e22f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:52:07 +0800 Subject: [PATCH 07/18] fix: make release publishing and version rewriting fail closed The build resolved `github.ref` at checkout time, so a tag moved between the trigger and the checkout would compile a commit the release was never requested for. The push path now checks out `github.sha`. The publish step, the only one holding contents: write, is pinned to a commit rather than the mutable v3 tag. set-version.sh rewrote manifests one at a time, so a missing later member or a failing lockfile refresh left the workspace split across two versions, with the per-file .tmp behind. Rewrites are now staged and moved only once all of them have succeeded, a failed `cargo update` restores the manifests, and a trap clears the staging files on any exit. The tap script inspected only the macOS arm64 archive, so a malformed Linux archive still produced a formula reported as good. It now checks every platform it downloaded. LICENSE is packaged in every archive and named in the install docs but was missing from `doc.install`, so formula users never received it; adding it to DOCS also brings it under that member check. Tap layout was chosen from the presence of the first-letter directory alone, which picks a path Homebrew will not read when a sharded tap has no letter directory yet, or when a flat tap happens to hold a directory with that name. An existing formula file now decides it, and a tap holding both asks for TAP_FORMULA_PATH instead of guessing. require_env accepted a present-but-empty value, so `TS_AUTHKEY=` passed the pre-flight check and reached the VM as `tailscale up --auth-key ''` after the plan was confirmed and VMs were created. parse_vm_names now decodes a serialized listing inside the `output` wrapper instead of reading it as table text, which produced JSON fragments as VM names and would have had bootstrap recreate VMs that already exist. The previous commit taught parse_ssh_destinations that shape; this makes the pair agree. --- .github/workflows/release.yml | 11 +++++- k8s_cli/src/manager/mod.rs | 9 ++++- k8s_cli/src/manager/parsing.rs | 11 ++++++ k8s_cli/src/manager/tests.rs | 11 ++++++ scripts/release/set-version.sh | 58 ++++++++++++++++++++++------ scripts/release/sync-homebrew-tap.sh | 44 +++++++++++++++------ 6 files changed, 117 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93af387..07f9e9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,10 @@ jobs: # `refs/tags/` rather than the bare input: a dispatch input is a free-form # string, so a branch name or SHA would otherwise check out and build a ref # that is not the tag the publish job creates the release for. - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.ref }} + # `github.sha` rather than `github.ref` on the push path: the ref is + # resolved again at checkout time, so a tag moved between the trigger and + # this step would build a commit the release was never requested for. + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.sha }} # This job runs no git operations after checkout, and the build compiles # third-party crates, so leaving the token in .git/config would only widen # what a compromised dependency can reach. @@ -163,7 +166,11 @@ jobs: echo "tag=${tag}" >> "${GITHUB_OUTPUT}" - name: Publish GitHub release - uses: softprops/action-gh-release@v3 + # Pinned to a commit, not the mutable v3 tag: this is the only step that + # runs with contents: write, so retagging upstream would hand a new + # revision the ability to rewrite this repository's releases. + # softprops/action-gh-release v3.0.2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 with: tag_name: ${{ steps.meta.outputs.tag }} files: dist/*.tar.gz diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 4a0478f..063bf55 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -741,7 +741,14 @@ fn confirm(prompt: &str, yes: bool) -> Result<()> { } fn require_env(name: &str) -> Result { - env::var(name).with_context(|| format!("missing {name}")) + let value = env::var(name).with_context(|| format!("missing {name}"))?; + // A present-but-empty variable would otherwise pass this check and reach the + // VM as `tailscale up --auth-key ''` or an empty k3s URL/token, failing only + // after the plan was confirmed and VMs were created. + if value.trim().is_empty() { + bail!("{name} is set but empty"); + } + Ok(value) } fn mode_name(mode: ClusterMode) -> &'static str { diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index e6d3471..bdffe19 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -24,6 +24,17 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { return Ok(names); } if let Some(output) = value.get("output").and_then(Value::as_str) { + // The wrapper carries either the serialized listing or a rendered + // table. Decode it as JSON first, the way `parse_ssh_destinations` + // does: reading a serialized listing as text yields fragments of the + // JSON as VM names, and bootstrap would then recreate VMs it already + // has. + if let Ok(inner) = serde_json::from_str::(output.trim()) { + collect_vm_names_from_json(&inner, &mut names); + if !names.is_empty() { + return Ok(names); + } + } return Ok(parse_vm_names_from_text(output)); } } diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 8a7a766..91cc86e 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -37,6 +37,17 @@ fn parses_vm_names_from_output_text() { assert!(names.contains("vm2")); } +#[test] +fn parses_vm_names_from_output_wrapped_json() { + let names = parse_vm_names( + r#"{"output":"[{\"vm_name\":\"vm-1\",\"ssh_dest\":\"vm+vm-1@exe.dev\"},{\"vm_name\":\"vm-2\"}]"}"#, + ) + .unwrap(); + assert_eq!(names.len(), 2); + assert!(names.contains("vm-1")); + assert!(names.contains("vm-2")); +} + #[test] fn builds_exedev_new_command() { let node = NodeSpec { diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 4d243d0..5a1145c 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -41,7 +41,7 @@ if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then fi set_package_version() { - local file="$1" + local src="$1" dest="$2" awk -v ver="$VERSION" ' /^\[/ { section = $0 } section == "[package]" && !replaced && /^version[[:space:]]*=/ { @@ -51,47 +51,81 @@ set_package_version() { } { print } END { exit replaced ? 0 : 1 } - ' "$file" > "$file.tmp" + ' "$src" > "$dest" } set_path_dep_version() { - local file="$1" key="$2" + local src="$1" dest="$2" key="$3" awk -v key="$key" -v ver="$VERSION" ' index($0, key "=") == 1 || index($0, key " =") == 1 { if (sub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" ver "\"")) replaced = 1 } { print } END { exit replaced ? 0 : 1 } - ' "$file" > "$file.tmp" + ' "$src" > "$dest" } +# Every rewrite is staged next to its target and only moved into place once all of +# them have succeeded. Rewriting in a single pass left the workspace split across +# two versions whenever a later member or the lockfile refresh failed, which is +# worse than not running at all: the build then reports a version mismatch rather +# than the actual failure. +TARGETS=() +cleanup_staged() { + local target + for target in "${TARGETS[@]}"; do + rm -f "$target.tmp" "$target.bak" + done +} +trap cleanup_staged EXIT + for member in "${MEMBERS[@]}"; do manifest="$REPO_ROOT/$member/Cargo.toml" if [[ ! -f "$manifest" ]]; then echo "workspace member has no manifest: $manifest" >&2 exit 1 fi - if ! set_package_version "$manifest"; then - rm -f "$manifest.tmp" + TARGETS+=("$manifest") + if ! set_package_version "$manifest" "$manifest.tmp"; then echo "no [package] version to replace in $manifest" >&2 exit 1 fi - mv "$manifest.tmp" "$manifest" done +ROOT_MANIFEST="$REPO_ROOT/Cargo.toml" +if [[ ! -f "$ROOT_MANIFEST" ]]; then + echo "workspace has no root manifest: $ROOT_MANIFEST" >&2 + exit 1 +fi +TARGETS+=("$ROOT_MANIFEST") +cp "$ROOT_MANIFEST" "$ROOT_MANIFEST.tmp" for key in "${PATH_DEP_KEYS[@]}"; do - if ! set_path_dep_version "$REPO_ROOT/Cargo.toml" "$key"; then - rm -f "$REPO_ROOT/Cargo.toml.tmp" - echo "no versioned '$key' entry to replace in $REPO_ROOT/Cargo.toml" >&2 + # Each key edits the staged copy, so several of them accumulate in one file. + if ! set_path_dep_version "$ROOT_MANIFEST.tmp" "$ROOT_MANIFEST.next" "$key"; then + rm -f "$ROOT_MANIFEST.next" + echo "no versioned '$key' entry to replace in $ROOT_MANIFEST" >&2 exit 1 fi - mv "$REPO_ROOT/Cargo.toml.tmp" "$REPO_ROOT/Cargo.toml" + mv "$ROOT_MANIFEST.next" "$ROOT_MANIFEST.tmp" +done + +for target in "${TARGETS[@]}"; do + cp "$target" "$target.bak" +done +for target in "${TARGETS[@]}"; do + mv "$target.tmp" "$target" done # The release build runs with --locked, which fails outright when Cargo.lock still # carries the old member versions. Refresh it here rather than leaving the build to # discover the mismatch. -(cd "$REPO_ROOT" && cargo update --workspace --quiet) +if ! (cd "$REPO_ROOT" && cargo update --workspace --quiet); then + for target in "${TARGETS[@]}"; do + mv "$target.bak" "$target" + done + echo "cargo update failed; manifests were restored to their previous versions" >&2 + exit 1 +fi echo "Set workspace version: $VERSION" for member in "${MEMBERS[@]}"; do diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 85e6839..db39388 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -19,7 +19,7 @@ RELEASE_TAG="${1:-${RELEASE_TAG:-}}" # guesses either one installs nothing. ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-exedev-clis}" BINARIES=(exedev-ctl exedev-k8s) -DOCS=(README.md README.zh-CN.md fleet.example.yaml .env.example) +DOCS=(README.md README.zh-CN.md LICENSE fleet.example.yaml .env.example) PLATFORMS=(macos-arm64 macos-amd64 linux-arm64 linux-amd64) sha256_of() { @@ -87,11 +87,26 @@ if [[ -z "$TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" ]]; then # `Formula/e/exedev-cli.rb` — while a flat personal tap keeps them directly under # `Formula`. Writing to the layout the repo does not use produces a file nothing # installs from, and the release then reports a tap update that never reached anyone. - FORMULA_SHARD_DIR="$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}" - if [[ -d "$FORMULA_SHARD_DIR" ]]; then - TAP_FORMULA_PATH="$FORMULA_SHARD_DIR/${FORMULA_NAME}.rb" + # + # An existing formula decides it, because that is the file the tap already + # installs from. The directory is only a hint: a sharded tap has no letter + # directory until its first formula lands there, and a flat tap can hold an + # unrelated directory whose name is that letter. + FORMULA_SHARD_PATH="$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}/${FORMULA_NAME}.rb" + FORMULA_FLAT_PATH="$TAP_REPO_PATH/Formula/${FORMULA_NAME}.rb" + if [[ -f "$FORMULA_SHARD_PATH" && -f "$FORMULA_FLAT_PATH" ]]; then + echo "tap has $FORMULA_NAME in both layouts; set TAP_FORMULA_PATH to pick one:" >&2 + echo " $FORMULA_SHARD_PATH" >&2 + echo " $FORMULA_FLAT_PATH" >&2 + exit 1 + elif [[ -f "$FORMULA_SHARD_PATH" ]]; then + TAP_FORMULA_PATH="$FORMULA_SHARD_PATH" + elif [[ -f "$FORMULA_FLAT_PATH" ]]; then + TAP_FORMULA_PATH="$FORMULA_FLAT_PATH" + elif [[ -d "$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}" ]]; then + TAP_FORMULA_PATH="$FORMULA_SHARD_PATH" else - TAP_FORMULA_PATH="$TAP_REPO_PATH/Formula/${FORMULA_NAME}.rb" + TAP_FORMULA_PATH="$FORMULA_FLAT_PATH" fi fi @@ -136,13 +151,18 @@ sha_for() { # The formula's `install` block names each file directly, so a renamed or dropped # archive member fails at install time on the user's machine rather than here. -# Check the payload against the release we just downloaded instead. -tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-macos-arm64.tar.gz" > "$WORK_DIR/members.txt" -for member in "${BINARIES[@]}" "${DOCS[@]}"; do - if ! grep -qx "\./$member" "$WORK_DIR/members.txt"; then - echo "release archive does not contain expected member: $member" >&2 - exit 1 - fi +# Check the payload against the release we just downloaded instead. Every platform +# is checked: one formula serves all of them, and each `url` is only ever unpacked +# on the platform it belongs to, so a malformed Linux archive is invisible in the +# macOS one. +for platform in "${PLATFORMS[@]}"; do + tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" > "$WORK_DIR/members.txt" + for member in "${BINARIES[@]}" "${DOCS[@]}"; do + if ! grep -qx "\./$member" "$WORK_DIR/members.txt"; then + echo "$platform release archive does not contain expected member: $member" >&2 + exit 1 + fi + done done url_for() { From 1eddfcd68f9eec6268d3761a8702e4c16657cf3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:16:55 +0800 Subject: [PATCH 08/18] fix: make bootstrap state and release publishing failure-safe write_secret_file wrote into the destination after unlinking it, so a write that failed partway left a truncated token or kubeconfig that the next run read back as authoritative. Contents now go to a 0600 staging file in the same directory and are renamed over the target only once the write and flush succeed, so a failure leaves the previous secret untouched. The token read path rejected nothing, so an entry swapped for a symlink had another file's contents adopted as the cluster token; it now requires a regular file. ssh parses options up to the first non-option word, so a reported ssh_dest beginning with `-` would have been taken as a local ssh option rather than a host. The destination is now passed after `--`. The 255 retry resent the script even when stdout already carried the wrapper's exit marker, which only appears once the remote script has finished; that turned a connection lost while returning output into a second install. It now retries only when the remote side did not report completion. Remote status 126 was likewise read as Tailnet Lock regardless of origin, so an unrelated 126 prompted for a signature and then reran a state-changing step; it now also requires the message the lock check emits. kubectl's --request-timeout bounds its API call, not the process, so a wedged credential or exec plugin could consume the whole readiness window in one attempt. Captured commands now run under a wall-clock bound. A probe returning unparseable JSON also aborted bootstrap outright instead of spending a retry. parse_vm_names handed a JSON response it had already searched to the text parser, turning `{"vms":[]}` into a VM named after the JSON, and dropped any real VM whose name begins with "name" along with the header row. The guard now also covers the credential and access operations it had skipped: ssh-key add and generate-api-key mint credentials that reach VMs, share remove/remove-link/set-private revoke access, and domain add changes domain and certificate state. The release workflow resolved the tag independently in every job. One resolve job now pins a single commit that all four matrix builds check out, and publish refuses to attach archives to a tag that has moved since. set-version.sh restores its backups when it exits between applying manifests and refreshing the lockfile, including on a signal. --- .github/workflows/release.yml | 89 ++++++++++++++++++++++----------- core/src/shell.rs | 21 +++++++- k8s_cli/src/manager/mod.rs | 19 ++++++- k8s_cli/src/manager/parsing.rs | 12 ++++- k8s_cli/src/manager/process.rs | 48 +++++++++++++++--- k8s_cli/src/manager/state.rs | 91 ++++++++++++++++++++++++---------- k8s_cli/src/manager/tests.rs | 84 +++++++++++++++++++++++++++++-- scripts/release/set-version.sh | 23 +++++++-- 8 files changed, 311 insertions(+), 76 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07f9e9f..4166ece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,43 @@ env: CARGO_TERM_COLOR: always jobs: + resolve: + name: resolve release commit + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.meta.outputs.tag }} + sha: ${{ steps.meta.outputs.sha }} + + steps: + # Resolved once, here, so every matrix build and the publish step agree on a + # single immutable commit. Resolving the tag independently per job would let a + # tag moved mid-run produce archives from more than one commit. + - name: Resolve the tag to a commit + id: meta + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG_NAME: ${{ inputs.tag_name }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + tag="${INPUT_TAG_NAME}" + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" + type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.type')" + # An annotated tag points at a tag object, not the commit it names. + if [[ "${type}" == "tag" ]]; then + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" + fi + else + tag="${GITHUB_REF_NAME}" + sha="${GITHUB_SHA}" + fi + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + build: name: build ${{ matrix.platform }} + needs: resolve runs-on: ${{ matrix.os }} strategy: @@ -43,13 +78,10 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - # `refs/tags/` rather than the bare input: a dispatch input is a free-form - # string, so a branch name or SHA would otherwise check out and build a ref - # that is not the tag the publish job creates the release for. - # `github.sha` rather than `github.ref` on the push path: the ref is - # resolved again at checkout time, so a tag moved between the trigger and - # this step would build a commit the release was never requested for. - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag_name) || github.sha }} + # The commit the resolve job pinned, never a ref name: a ref is resolved + # again at checkout time, so a tag moved between the trigger and this step + # would build a commit the release was never requested for. + ref: ${{ needs.resolve.outputs.sha }} # This job runs no git operations after checkout, and the build compiles # third-party crates, so leaving the token in .git/config would only widen # what a compromised dependency can reach. @@ -90,14 +122,10 @@ jobs: env: # Passed through the environment rather than interpolated into the script, # so a crafted dispatch input cannot inject shell commands. - INPUT_TAG_NAME: ${{ inputs.tag_name }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${INPUT_TAG_NAME}" - fi - scripts/release/set-version.sh "${tag}" + scripts/release/set-version.sh "${RELEASE_TAG}" - name: Build optimized release binaries run: cargo build --profile dist --locked --target ${{ matrix.target }} -p exedev-ctl -p exedev-k8s @@ -106,16 +134,11 @@ jobs: id: package shell: bash env: - INPUT_TAG_NAME: ${{ inputs.tag_name }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${INPUT_TAG_NAME}" - fi - - archive="exedev-clis-${tag}-${{ matrix.platform }}.tar.gz" + archive="exedev-clis-${RELEASE_TAG}-${{ matrix.platform }}.tar.gz" mkdir -p package dist cp "target/${{ matrix.target }}/dist/exedev-ctl" "package/exedev-ctl" @@ -139,7 +162,7 @@ jobs: publish: name: publish release - needs: build + needs: [resolve, build] runs-on: ubuntu-24.04 permissions: contents: write @@ -152,18 +175,26 @@ jobs: path: dist merge-multiple: true - - name: Resolve release tag - id: meta + # The archives were built from one commit; the release is about to be + # attached to a tag name. If the tag moved in between, publishing would ship + # binaries that do not match the source the tag now points at. + - name: Verify the tag still points at the built commit shell: bash env: - INPUT_TAG_NAME: ${{ inputs.tag_name }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + BUILT_SHA: ${{ needs.resolve.outputs.sha }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${INPUT_TAG_NAME}" + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.sha')" + type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.type')" + if [[ "${type}" == "tag" ]]; then + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" + fi + if [[ "${sha}" != "${BUILT_SHA}" ]]; then + echo "tag ${RELEASE_TAG} now points at ${sha}, but these archives were built from ${BUILT_SHA}" >&2 + exit 1 fi - echo "tag=${tag}" >> "${GITHUB_OUTPUT}" - name: Publish GitHub release # Pinned to a commit, not the mutable v3 tag: this is the only step that @@ -172,6 +203,6 @@ jobs: # softprops/action-gh-release v3.0.2 uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 with: - tag_name: ${{ steps.meta.outputs.tag }} + tag_name: ${{ needs.resolve.outputs.tag }} files: dist/*.tar.gz generate_release_notes: true diff --git a/core/src/shell.rs b/core/src/shell.rs index 268a2bc..601ebae 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -43,11 +43,20 @@ fn is_dangerous(command: &str) -> bool { let prefixes = [ "rm ", "share set-public ", + "share set-private ", "share add-link ", "share add-share-link ", + "share remove-link ", + "share remove-share-link ", + "share remove ", "share access allow ", "grant-support-root ", + // Both mint a credential that reaches VMs, so they belong with the + // revocation the list already covers. + "ssh-key add ", + "ssh-key generate-api-key", "ssh-key remove ", + "domain add ", // `add` can carry --attach specs and `attach` mounts the credential into // VMs, so both hand out access just as `detach` and `edit` take it away. "integrations add ", @@ -123,14 +132,24 @@ mod tests { assert!(is_dangerous("team add a@b.c admin")); assert!(is_dangerous("team auth set oidc --issuer-url https://x")); assert!(is_dangerous("team settings vm-sharing all-members")); + assert!(is_dangerous("ssh-key add --tag prod 'ssh-ed25519 AAAA k'")); + assert!(is_dangerous("ssh-key generate-api-key --exp 30d")); + assert!(is_dangerous("share remove mybox a@b.c")); + assert!(is_dangerous("share remove-link mybox tok")); + assert!(is_dangerous("share set-private mybox")); + assert!(is_dangerous("domain add mybox app.example.com")); assert!(!is_dangerous("ls")); + assert!(!is_dangerous("ssh-key list")); + assert!(!is_dangerous("share show mybox")); + assert!(!is_dangerous("domain ls mybox")); assert!(!is_dangerous("team members")); assert!(!is_dangerous("team settings")); assert!(!is_dangerous("integrations list --usage")); assert!(!is_dangerous("integrations catalog stripe")); assert!(!is_dangerous("domain ls -a")); assert!(!is_dangerous("share add mybox a@b.c")); - assert!(!is_dangerous("share remove mybox a@b.c --root")); + // Revocation is covered as a deletion, so the --root downgrade is too. + assert!(is_dangerous("share remove mybox a@b.c --root")); assert!(!is_dangerous("team settings auto-join off")); assert!(!is_dangerous("billing credits usage --group=day")); assert!(!is_dangerous("pool list")); diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 063bf55..6d03409 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -592,8 +592,25 @@ async fn wait_for_kubernetes_nodes( ) .await { + // A probe that returns unparseable JSON is treated like any other + // failed probe: kubectl can answer mid-rollout with something this + // cannot read, and giving up on the first one would spend none of the + // retry window and report a parse error instead of the cluster state. Ok(output) => { - let nodes = parse_kubernetes_nodes(&output)?; + let nodes = match parse_kubernetes_nodes(&output) { + Ok(nodes) => nodes, + Err(err) => { + last_error = err.to_string(); + if attempt < KUBERNETES_NODE_WAIT_ATTEMPTS { + println!( + "{} Kubernetes nodes are not ready yet ({last_error}); retrying ({attempt}/{KUBERNETES_NODE_WAIT_ATTEMPTS})", + output::warn("waiting:") + ); + sleep(KUBERNETES_WAIT_DELAY).await; + } + continue; + } + }; let missing = expected .iter() .filter(|name| !nodes.contains_key(*name)) diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index bdffe19..e30e9f3 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -37,6 +37,10 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { } return Ok(parse_vm_names_from_text(output)); } + // A response that parsed as JSON has already been searched. Handing its + // serialized form to the text parser would take `{"vms":[]}` apart into + // a VM named after the JSON itself; an empty list is simply empty. + return Ok(names); } Ok(parse_vm_names_from_text(trimmed)) } @@ -144,9 +148,13 @@ pub(super) fn parse_vm_names_from_text(text: &str) -> BTreeSet { text.lines() .map(str::trim) .filter(|line| !line.is_empty()) - .filter(|line| !line.to_ascii_lowercase().starts_with("name")) .filter_map(|line| line.split_whitespace().next()) - .map(str::to_string) + // Only the first column of the first row is a header. Dropping every row + // whose name starts with "name" would lose a VM actually called + // `nameserver`, and bootstrap would then try to create it again. + .enumerate() + .filter(|(index, name)| *index > 0 || !name.eq_ignore_ascii_case("name")) + .map(|(_, name)| name.to_string()) .collect() } diff --git a/k8s_cli/src/manager/process.rs b/k8s_cli/src/manager/process.rs index 63df5dc..a508a34 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -19,8 +19,16 @@ const REMOTE_SSH_RETRY_DELAY: Duration = Duration::from_secs(3); /// it means the remote side is stuck rather than slow. const REMOTE_SSH_TIMEOUT: Duration = Duration::from_secs(900); +/// Upper bound on a captured local command. Every caller is a kubectl read whose +/// own `--request-timeout` is at most 30s, so this only fires when kubectl itself +/// is stuck rather than waiting on the API. +const CAPTURE_COMMAND_TIMEOUT: Duration = Duration::from_secs(120); + const TAILNET_LOCK_AUTH_REQUIRED_STATUS: i32 = 126; +/// Emitted by `CHECK_TAILNET_LOCK_SCRIPT` alongside its 126 exit. +const TAILNET_LOCK_MARKER: &str = "Tailnet Lock is enabled and this VM is locked out"; + #[derive(Debug, Eq, PartialEq)] pub(super) struct CommandOutput { stdout: String, @@ -71,7 +79,14 @@ pub(super) async fn remote_run(targets: &SshTargets, vm: &str, script: &str) -> if output.status == 0 { return Ok(()); } - if output.status != TAILNET_LOCK_AUTH_REQUIRED_STATUS { + // 126 is also the conventional shell status for "found but not executable", + // so the status alone does not identify the Tailnet Lock case. Pairing it + // with the message the check emits keeps an unrelated 126 reported as the + // failure it is, rather than prompting for a signature and then rerunning + // a step that already changed state. + if output.status != TAILNET_LOCK_AUTH_REQUIRED_STATUS + || !output.stderr.contains(TAILNET_LOCK_MARKER) + { bail!( "remote command on {vm} exited with status {}", output.status @@ -188,12 +203,23 @@ pub(super) async fn capture_command_output(program: &str, args: &[&str]) -> Resu "{}", output::command(format!("$ {}", display_command(program, args))) ); - let output = TokioCommand::new(program) + let child = TokioCommand::new(program) .args(args) .stdin(Stdio::null()) - .output() - .await - .with_context(|| format!("failed to run {program}"))?; + // Dropped by the timeout below, which must take the process with it. + .kill_on_drop(true) + .output(); + // `--request-timeout` bounds kubectl's API call, not kubectl itself: a + // kubeconfig exec plugin, a credential helper, or a wedged resolver can hang + // before any request is made, which would otherwise consume the whole polling + // window in one attempt and never reach the diagnostics. + let output = match timeout(CAPTURE_COMMAND_TIMEOUT, child).await { + Ok(result) => result.with_context(|| format!("failed to run {program}"))?, + Err(_) => bail!( + "{program} produced no result within {}s and was killed", + CAPTURE_COMMAND_TIMEOUT.as_secs() + ), + }; if !output.status.success() { bail!( "{program} exited with status {}: {}", @@ -274,7 +300,12 @@ pub(super) async fn capture_remote_ssh_output( last_status = Some(output.status); last_detail = command_output_detail(&output.stdout, &output.stderr); - if output.status.code() == Some(255) && attempt < REMOTE_SSH_ATTEMPTS { + // The wrapper prints the exit marker once the remote script has finished. + // Seeing it means ssh failed while returning output, not before running + // anything, so resending the script would repeat an install or a service + // change that already happened. + let remote_ran = String::from_utf8_lossy(&output.stdout).contains(REMOTE_EXIT_PREFIX); + if output.status.code() == Some(255) && !remote_ran && attempt < REMOTE_SSH_ATTEMPTS { eprintln!( "{}", output::stderr_block(format!( @@ -358,6 +389,11 @@ pub(super) fn remote_ssh_args(dest: &str) -> Vec { "StrictHostKeyChecking=accept-new".into(), "-o".into(), "ConnectTimeout=15".into(), + // The destination comes from the exe.dev API, and ssh parses options up to + // the first non-option word, so without this a reported destination + // starting with `-` would be read as a local ssh option such as + // `-oProxyCommand=...` instead of a host. + "--".into(), dest.to_string(), "sh".into(), "-s".into(), diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 5b9b16b..33c6f8c 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -1,9 +1,9 @@ use super::K3S_TOKEN_ENV; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use rand::{RngExt, distr::Alphanumeric}; use std::{ env, fs, - io::{self, Write}, + io::Write, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, }; @@ -22,8 +22,7 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { let path = generated_token_path(cluster_name); if let Ok(token) = env::var(K3S_TOKEN_ENV) { if path.exists() { - let file_token = fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; + let file_token = read_regular_file(&path)?; if file_token.trim() != token { write_secret_file(&path, &token) .with_context(|| format!("failed to update {}", path.display()))?; @@ -34,9 +33,7 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { return Ok(token); } if path.exists() { - return fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display())) - .map(|text| text.trim().to_string()); + return read_regular_file(&path).map(|text| text.trim().to_string()); } let token = random_token(); write_secret_file(&path, &token)?; @@ -44,36 +41,76 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { } /// Writes a kubeconfig or cluster token so it is never readable by anyone else, -/// not even briefly. +/// not even briefly, and never observable half-written. /// -/// Creating the file and then tightening it leaves the contents at the umask's -/// permissions in between, and a crash in that window leaves them there. Any -/// existing entry is unlinked first, so the create below applies 0600 from the -/// start and cannot follow a symlink planted at a caller-supplied `--kubeconfig` -/// path into a file that is readable elsewhere. +/// The contents go to a staging file in the same directory, created 0600 so they +/// are never present at the umask's permissions, and only a completed write is +/// renamed over `path`. Writing into the destination directly would leave a +/// truncated token or kubeconfig behind if the write failed partway, and the next +/// run reads whatever is at the path without being able to tell it is a fragment. +/// The rename also replaces a symlink rather than following one planted at a +/// caller-supplied `--kubeconfig` path. pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - match fs::remove_file(path) { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err).with_context(|| format!("failed to replace {}", path.display())); - } + let staged = staging_path(path); + // A staged file from a crashed run with this pid would fail the create below. + let _ = fs::remove_file(&staged); + let write = || -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staged) + .with_context(|| format!("failed to create {}", staged.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("failed to write {}", staged.display()))?; + file.sync_all() + .with_context(|| format!("failed to flush {}", staged.display()))?; + Ok(()) + }; + if let Err(err) = write() { + let _ = fs::remove_file(&staged); + return Err(err); + } + if let Err(err) = fs::rename(&staged, path) { + let _ = fs::remove_file(&staged); + return Err(err).with_context(|| format!("failed to replace {}", path.display())); } - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(path) - .with_context(|| format!("failed to create {}", path.display()))?; - file.write_all(contents.as_bytes()) - .with_context(|| format!("failed to write {}", path.display()))?; Ok(()) } +fn staging_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "secret".to_string()); + let staged = format!(".{name}.{}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(staged), + None => PathBuf::from(staged), + } +} + +/// Reads a file that must be a real file this tool wrote. +/// +/// `fs::read_to_string` follows symlinks, so an entry swapped for a link to +/// another readable file would have that file's contents adopted as the cluster +/// token. +pub(super) fn read_regular_file(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect {}", path.display()))?; + if !metadata.is_file() { + bail!( + "{} is not a regular file; remove it and rerun", + path.display() + ); + } + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display())) +} + pub(super) fn random_token() -> String { rand::rng() .sample_iter(&Alphanumeric) diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 91cc86e..f97c0e0 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -1,6 +1,8 @@ use super::super::fleet::NodeSpec; use super::kubectl::kubeconfig_args; -use super::parsing::{parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names}; +use super::parsing::{ + parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names, parse_vm_names_from_text, +}; use super::process::{ SshTargets, command_output_detail, display_command, parse_remote_stdout, remote_ssh_args, remote_status_script, @@ -8,6 +10,7 @@ use super::process::{ use super::scripts::{ k3s_agent_install_command, k3s_server_install_command, tailscale_install_command, }; +use super::state::{read_regular_file, write_secret_file}; use super::*; use std::{collections::BTreeMap, path::Path}; @@ -151,7 +154,7 @@ fn k3s_agent_install_command_supports_no_supervisor_fallback() { #[test] fn builds_remote_ssh_command_for_stdin_script() { let args = remote_ssh_args("vm-1.exe.xyz"); - assert_eq!(args.len(), 11); + assert_eq!(args.len(), 12); assert_eq!(args[0], "-o"); assert_eq!(args[1], "ControlMaster=no"); assert_eq!(args[2], "-o"); @@ -160,9 +163,17 @@ fn builds_remote_ssh_command_for_stdin_script() { assert_eq!(args[5], "StrictHostKeyChecking=accept-new"); assert_eq!(args[6], "-o"); assert_eq!(args[7], "ConnectTimeout=15"); - assert_eq!(args[8], "vm-1.exe.xyz"); - assert_eq!(args[9], "sh"); - assert_eq!(args[10], "-s"); + assert_eq!(args[8], "--"); + assert_eq!(args[9], "vm-1.exe.xyz"); + assert_eq!(args[10], "sh"); + assert_eq!(args[11], "-s"); +} + +#[test] +fn option_shaped_destination_stays_a_destination() { + let args = remote_ssh_args("-oProxyCommand=touch /tmp/pwned"); + let separator = args.iter().position(|arg| arg == "--").unwrap(); + assert_eq!(args[separator + 1], "-oProxyCommand=touch /tmp/pwned"); } #[test] @@ -355,3 +366,66 @@ fn secret_files_are_never_group_or_world_readable() { std::fs::remove_dir_all(&dir).unwrap(); } + +#[test] +fn empty_json_listing_yields_no_vm_names() { + assert!(parse_vm_names(r#"{"vms":[]}"#).unwrap().is_empty()); + assert!(parse_vm_names("[]").unwrap().is_empty()); + assert!( + parse_vm_names(r#"{"error":"quota exceeded"}"#) + .unwrap() + .is_empty() + ); +} + +#[test] +fn text_fallback_keeps_vm_names_starting_with_name() { + let names = parse_vm_names_from_text("NAME STATUS\nnameserver running\nvm2 stopped\n"); + assert!(names.contains("nameserver")); + assert!(names.contains("vm2")); + assert!(!names.contains("NAME")); + assert_eq!(names.len(), 2); +} + +#[test] +fn secret_write_failure_leaves_the_previous_secret_intact() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("exedev-k8s-failpath-{}", std::process::id())); + let path = dir.join("k3s-token"); + let _ = std::fs::remove_dir_all(&dir); + write_secret_file(&path, "good-token").unwrap(); + + // A read-only directory fails the staged create, standing in for any I/O + // error partway through replacing the file. + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = write_secret_file(&path, "replacement").unwrap_err(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + assert!(err.to_string().contains("failed to create")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "good-token"); + let staged = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")) + .count(); + assert_eq!(staged, 0, "staged file left behind"); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn symlinked_token_is_rejected_rather_than_followed() { + let dir = std::env::temp_dir().join(format!("exedev-k8s-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let secret_elsewhere = dir.join("other-secret"); + std::fs::write(&secret_elsewhere, "someone-elses-secret").unwrap(); + let token_path = dir.join("k3s-token"); + std::os::unix::fs::symlink(&secret_elsewhere, &token_path).unwrap(); + + let err = read_regular_file(&token_path).unwrap_err(); + assert!(err.to_string().contains("not a regular file")); + + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 5a1145c..0648062 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -71,13 +71,25 @@ set_path_dep_version() { # worse than not running at all: the build then reports a version mismatch rather # than the actual failure. TARGETS=() +APPLIED=0 +REFRESHED=0 cleanup_staged() { local target + # An exit between applying the manifests and refreshing the lockfile — an error, + # a Ctrl-C, or a terminated CI step — would otherwise leave the workspace on the + # new version with a lockfile still on the old one, and drop the backups that + # are the only way back. + if [[ "$APPLIED" -eq 1 && "$REFRESHED" -eq 0 ]]; then + for target in "${TARGETS[@]}"; do + [[ -f "$target.bak" ]] && mv "$target.bak" "$target" + done + fi for target in "${TARGETS[@]}"; do rm -f "$target.tmp" "$target.bak" done } trap cleanup_staged EXIT +trap 'exit 1' INT TERM for member in "${MEMBERS[@]}"; do manifest="$REPO_ROOT/$member/Cargo.toml" @@ -115,17 +127,18 @@ done for target in "${TARGETS[@]}"; do mv "$target.tmp" "$target" done +APPLIED=1 # The release build runs with --locked, which fails outright when Cargo.lock still # carries the old member versions. Refresh it here rather than leaving the build to -# discover the mismatch. +# discover the mismatch. `--workspace` re-resolves only the workspace members, so +# no third-party dependency selection changes and the tag still builds from its +# reviewed lockfile. if ! (cd "$REPO_ROOT" && cargo update --workspace --quiet); then - for target in "${TARGETS[@]}"; do - mv "$target.bak" "$target" - done - echo "cargo update failed; manifests were restored to their previous versions" >&2 + echo "cargo update failed; manifests are being restored to their previous versions" >&2 exit 1 fi +REFRESHED=1 echo "Set workspace version: $VERSION" for member in "${MEMBERS[@]}"; do From a034aa5b8a38279129d55848e7ee4859616bb2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:37:05 +0800 Subject: [PATCH 09/18] fix: confine state paths and reject empty or unpinned release inputs State paths took the fleet's cluster name unchecked, so a name like `../../outside` wrote the token and kubeconfig outside `.exedev-k8s`; the name is now reduced to a single path component. An exported but empty K3S_TOKEN was accepted through `env::var` and became the cluster credential for the server and every agent. A reused token file left readable by others stayed that way on every later run, and is now tightened when it is read back. Secret staging used a name derived from the pid and unlinked it first, which is both guessable and a window for substitution. The name is now random and the `create_new` open, which refuses any existing entry including a symlink, is what guarantees the file is this process's own. The read path checked the path and then reopened it, so the entry could be swapped in between; it now reads through one handle and confirms that handle is the object the no-follow check accepted. k3s wrote its server kubeconfig 0644 on the VM, exposing client credentials to every local user there. fetch_kubeconfig reads it through sudo, so nothing needed that; it is now 0600. A JSON wrapper holding an empty or error listing was still handed to the text parser, which returned a VM named after the JSON. A parsed listing is now the answer whether or not it is empty. The guard now also covers `pool new`, which reserves billable capacity like the `billing capacity` change already listed, and `billing payment default`, which changes the card that gets charged. Release inputs are checked where they enter: the semver grammar moved to check-version.sh so the resolve step rejects a bad dispatch tag before four matrix builds start, the peeled tag object must be a commit, and every action is pinned to a commit rather than a mutable tag. set-version.sh now backs up Cargo.lock alongside the manifests, so an interrupted refresh no longer restores manifests and leaves a rewritten lockfile. The tap script validates an explicit TAP_FORMULA_PATH, which it creates and truncates, the same way it validates a discovered one. --- .github/workflows/release.yml | 23 +++++-- core/src/shell.rs | 8 +++ k8s_cli/src/manager/parsing.rs | 7 +- k8s_cli/src/manager/scripts.rs | 4 +- k8s_cli/src/manager/state.rs | 97 ++++++++++++++++++++++++---- k8s_cli/src/manager/tests.rs | 48 +++++++++++++- scripts/release/check-version.sh | 31 +++++++++ scripts/release/set-version.sh | 27 ++++---- scripts/release/sync-homebrew-tap.sh | 14 ++++ 9 files changed, 220 insertions(+), 39 deletions(-) create mode 100755 scripts/release/check-version.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4166ece..631982e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,11 @@ jobs: sha: ${{ steps.meta.outputs.sha }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + # Resolved once, here, so every matrix build and the publish step agree on a # single immutable commit. Resolving the tag independently per job would let a # tag moved mid-run produce archives from more than one commit. @@ -39,12 +44,20 @@ jobs: set -euo pipefail if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then tag="${INPUT_TAG_NAME}" + # Rejected here rather than in each matrix build, where set-version + # would fail four times over after the checkouts and toolchain setup. + scripts/release/check-version.sh "${tag}" sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.type')" # An annotated tag points at a tag object, not the commit it names. if [[ "${type}" == "tag" ]]; then + type="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.type')" sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" fi + if [[ "${type}" != "commit" ]]; then + echo "tag ${tag} does not resolve to a commit (got ${type})" >&2 + exit 1 + fi else tag="${GITHUB_REF_NAME}" sha="${GITHUB_SHA}" @@ -76,7 +89,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # The commit the resolve job pinned, never a ref name: a ref is resolved # again at checkout time, so a tag moved between the trigger and this step @@ -88,12 +101,12 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: ${{ matrix.target }} - name: Cache Cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ matrix.target }} @@ -154,7 +167,7 @@ jobs: echo "archive=${archive}" >> "${GITHUB_OUTPUT}" - name: Upload archive artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-${{ matrix.platform }} path: dist/${{ steps.package.outputs.archive }} @@ -169,7 +182,7 @@ jobs: steps: - name: Download release archives - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: release-* path: dist diff --git a/core/src/shell.rs b/core/src/shell.rs index 601ebae..3733245 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -75,10 +75,14 @@ fn is_dangerous(command: &str) -> bool { "team disable", "team settings auto-join on", "domain rm ", + // Both sides of reserved capacity: creating a pool reserves it, and the + // list already prompts before `billing capacity` changes the subscription. + "pool new ", "pool delete ", "billing capacity", "billing credits buy ", "billing payment remove ", + "billing payment default ", ]; prefixes .iter() @@ -138,7 +142,11 @@ mod tests { assert!(is_dangerous("share remove-link mybox tok")); assert!(is_dangerous("share set-private mybox")); assert!(is_dangerous("domain add mybox app.example.com")); + assert!(is_dangerous("pool new builders --cpus 16 --region fra")); + assert!(is_dangerous("billing payment default 4f1c2a9b")); assert!(!is_dangerous("ls")); + assert!(!is_dangerous("pool list")); + assert!(!is_dangerous("billing payment list")); assert!(!is_dangerous("ssh-key list")); assert!(!is_dangerous("share show mybox")); assert!(!is_dangerous("domain ls mybox")); diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index e30e9f3..2a845e5 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -31,9 +31,10 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { // has. if let Ok(inner) = serde_json::from_str::(output.trim()) { collect_vm_names_from_json(&inner, &mut names); - if !names.is_empty() { - return Ok(names); - } + // Whatever the listing held is the answer, including nothing. A + // wrapped `[]` or an error object handed to the text parser would + // come back as a VM named after the JSON itself. + return Ok(names); } return Ok(parse_vm_names_from_text(output)); } diff --git a/k8s_cli/src/manager/scripts.rs b/k8s_cli/src/manager/scripts.rs index 4823816..37a54ed 100644 --- a/k8s_cli/src/manager/scripts.rs +++ b/k8s_cli/src/manager/scripts.rs @@ -215,13 +215,13 @@ K3S_SERVICE_CIDR={} require_no_k3s_agent_state_for_server if has_k3s_supervisor; then if ! command -v k3s >/dev/null 2>&1; then - curl -sfL https://get.k3s.io | ${{SUDO}} env INSTALL_K3S_SKIP_START=true K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" sh -s - server --write-kubeconfig-mode 644 --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" --advertise-address "$K3S_NODE_IP" --tls-san "$K3S_TLS_SAN" --cluster-cidr "$K3S_CLUSTER_CIDR" --service-cidr "$K3S_SERVICE_CIDR" + curl -sfL https://get.k3s.io | ${{SUDO}} env INSTALL_K3S_SKIP_START=true K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" sh -s - server --write-kubeconfig-mode 600 --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" --advertise-address "$K3S_NODE_IP" --tls-san "$K3S_TLS_SAN" --cluster-cidr "$K3S_CLUSTER_CIDR" --service-cidr "$K3S_SERVICE_CIDR" fi start_k3s_service_no_block k3s else install_k3s_binary if ! [ -f /var/run/exedev-k8s-k3s-server.pid ] || ! ${{SUDO}} kill -0 "$(cat /var/run/exedev-k8s-k3s-server.pid)" 2>/dev/null; then - ${{SUDO}} env K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" nohup k3s server --write-kubeconfig-mode 644 --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" --advertise-address "$K3S_NODE_IP" --tls-san "$K3S_TLS_SAN" --cluster-cidr "$K3S_CLUSTER_CIDR" --service-cidr "$K3S_SERVICE_CIDR" >/tmp/exedev-k8s-k3s-server.log 2>&1 & + ${{SUDO}} env K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" nohup k3s server --write-kubeconfig-mode 600 --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" --advertise-address "$K3S_NODE_IP" --tls-san "$K3S_TLS_SAN" --cluster-cidr "$K3S_CLUSTER_CIDR" --service-cidr "$K3S_SERVICE_CIDR" >/tmp/exedev-k8s-k3s-server.log 2>&1 & echo $! | ${{SUDO}} tee /var/run/exedev-k8s-k3s-server.pid >/dev/null fi fi diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 33c6f8c..fdc3d2b 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -3,24 +3,56 @@ use anyhow::{Context, Result, bail}; use rand::{RngExt, distr::Alphanumeric}; use std::{ env, fs, - io::Write, - os::unix::fs::OpenOptionsExt, + io::{Read, Write}, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, }; const STATE_DIR: &str = ".exedev-k8s"; pub(super) fn generated_kubeconfig_path(cluster_name: &str) -> PathBuf { - Path::new(STATE_DIR).join(cluster_name).join("kubeconfig") + Path::new(STATE_DIR) + .join(state_dir_name(cluster_name)) + .join("kubeconfig") } pub(super) fn generated_token_path(cluster_name: &str) -> PathBuf { - Path::new(STATE_DIR).join(cluster_name).join("k3s-token") + Path::new(STATE_DIR) + .join(state_dir_name(cluster_name)) + .join("k3s-token") +} + +/// Keeps a cluster name from reaching outside the state directory. +/// +/// The name comes from fleet.yaml, which only requires it to be non-empty, so +/// `../../elsewhere` would otherwise place the token and kubeconfig outside +/// `.exedev-k8s`. Anything that is not a plain name component is replaced. +fn state_dir_name(cluster_name: &str) -> String { + let sanitized = cluster_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + if sanitized.trim_matches('_').is_empty() { + "cluster".to_string() + } else { + sanitized + } } pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { let path = generated_token_path(cluster_name); if let Ok(token) = env::var(K3S_TOKEN_ENV) { + // An exported but empty value would otherwise become the cluster + // credential for the server and every agent. + if token.trim().is_empty() { + bail!("{K3S_TOKEN_ENV} is set but empty"); + } if path.exists() { let file_token = read_regular_file(&path)?; if file_token.trim() != token { @@ -33,7 +65,11 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { return Ok(token); } if path.exists() { - return read_regular_file(&path).map(|text| text.trim().to_string()); + let token = read_regular_file(&path).map(|text| text.trim().to_string())?; + // A token left readable by others (an older run, a restored backup) stays + // that way for every future run unless it is tightened when reused. + restrict_secret_permissions(&path)?; + return Ok(token); } let token = random_token(); write_secret_file(&path, &token)?; @@ -56,8 +92,6 @@ pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { .with_context(|| format!("failed to create {}", parent.display()))?; } let staged = staging_path(path); - // A staged file from a crashed run with this pid would fail the create below. - let _ = fs::remove_file(&staged); let write = || -> Result<()> { let mut file = fs::OpenOptions::new() .write(true) @@ -82,33 +116,72 @@ pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { Ok(()) } +/// A staging name that cannot be guessed ahead of the write. +/// +/// The name is random rather than derived from the pid so nothing can be waiting +/// at it. Combined with `create_new`, which refuses an existing entry of any kind +/// including a symlink, the staged file is always one this process just made. fn staging_path(path: &Path) -> PathBuf { let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "secret".to_string()); - let staged = format!(".{name}.{}.tmp", std::process::id()); + let staged = format!(".{name}.{}.tmp", random_suffix()); match path.parent() { Some(parent) => parent.join(staged), None => PathBuf::from(staged), } } +fn restrict_secret_permissions(path: &Path) -> Result<()> { + let mode = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect {}", path.display()))? + .permissions() + .mode(); + if mode & 0o077 == 0 { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to restrict permissions on {}", path.display())) +} + +fn random_suffix() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect() +} + /// Reads a file that must be a real file this tool wrote. /// /// `fs::read_to_string` follows symlinks, so an entry swapped for a link to /// another readable file would have that file's contents adopted as the cluster -/// token. +/// token. Inspecting the path and then reading it would still leave a gap for the +/// entry to be swapped in between, so the contents are read through one handle +/// and that handle is confirmed to be the same object the no-follow inspection +/// accepted. pub(super) fn read_regular_file(path: &Path) -> Result { - let metadata = fs::symlink_metadata(path) + let before = fs::symlink_metadata(path) .with_context(|| format!("failed to inspect {}", path.display()))?; - if !metadata.is_file() { + if !before.is_file() { bail!( "{} is not a regular file; remove it and rerun", path.display() ); } - fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display())) + let mut file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let opened = file + .metadata() + .with_context(|| format!("failed to inspect {}", path.display()))?; + if opened.dev() != before.dev() || opened.ino() != before.ino() { + bail!("{} changed while it was being read", path.display()); + } + let mut contents = String::new(); + file.read_to_string(&mut contents) + .with_context(|| format!("failed to read {}", path.display()))?; + Ok(contents) } pub(super) fn random_token() -> String { diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index f97c0e0..16c9d5e 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -10,7 +10,9 @@ use super::process::{ use super::scripts::{ k3s_agent_install_command, k3s_server_install_command, tailscale_install_command, }; -use super::state::{read_regular_file, write_secret_file}; +use super::state::{ + generated_kubeconfig_path, generated_token_path, read_regular_file, write_secret_file, +}; use super::*; use std::{collections::BTreeMap, path::Path}; @@ -118,7 +120,10 @@ fn k3s_server_install_command_supports_no_supervisor_fallback() { assert!(command.contains("INSTALL_K3S_SKIP_START=true")); assert!(command.contains("start_k3s_service_no_block k3s")); assert!(command.contains("systemctl start --no-block \"$k3s_service\"")); - assert!(command.contains("--write-kubeconfig-mode 644 --node-name \"$K3S_NODE_NAME\"")); + // 600, not 644: k3s.yaml holds client credentials and fetch_kubeconfig reads + // it through sudo, so nothing needs it world-readable on the VM. + assert!(command.contains("--write-kubeconfig-mode 600 --node-name \"$K3S_NODE_NAME\"")); + assert!(!command.contains("--write-kubeconfig-mode 644")); assert!(command.contains("require_no_k3s_agent_state_for_server")); assert!(command.contains("--cluster-cidr \"$K3S_CLUSTER_CIDR\"")); assert!(command.contains("--service-cidr \"$K3S_SERVICE_CIDR\"")); @@ -429,3 +434,42 @@ fn symlinked_token_is_rejected_rather_than_followed() { std::fs::remove_dir_all(&dir).unwrap(); } + +#[test] +fn wrapped_empty_listing_invents_no_vm_name() { + assert!(parse_vm_names(r#"{"output":"[]"}"#).unwrap().is_empty()); + assert!( + parse_vm_names(r#"{"output":"{\"error\":\"quota exceeded\"}"}"#) + .unwrap() + .is_empty() + ); +} + +#[test] +fn cluster_name_cannot_escape_the_state_directory() { + let path = generated_token_path("../../outside"); + assert!(path.starts_with(".exedev-k8s")); + assert!(!path.to_string_lossy().contains("..")); + assert!(generated_kubeconfig_path("../../outside").starts_with(".exedev-k8s")); + assert_eq!( + generated_token_path("prod-1"), + Path::new(".exedev-k8s/prod-1/k3s-token") + ); +} + +#[test] +fn staging_names_do_not_repeat() { + let dir = std::env::temp_dir().join(format!("exedev-k8s-staging-{}", std::process::id())); + let path = dir.join("k3s-token"); + let _ = std::fs::remove_dir_all(&dir); + write_secret_file(&path, "one").unwrap(); + write_secret_file(&path, "two").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "two"); + let leftovers = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")) + .count(); + assert_eq!(leftovers, 0); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/scripts/release/check-version.sh b/scripts/release/check-version.sh new file mode 100755 index 0000000..d29ebc1 --- /dev/null +++ b/scripts/release/check-version.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates a release version or tag against semver.org's reference grammar and +# prints the version with any leading `v` removed. +# +# Kept apart from set-version.sh so the release workflow can reject a bad tag at +# the point it is resolved, before four matrix builds check out and install a +# toolchain only to fail on the same string. + +VERSION="${1:-${RELEASE_TAG:-}}" + +if [[ -z "$VERSION" ]]; then + echo "usage: $(basename "$0") " >&2 + echo "Accepts either 0.1.22 or v0.1.22; RELEASE_TAG is used when no argument is given." >&2 + exit 1 +fi + +VERSION="${VERSION#v}" + +SEMVER_NUM='(0|[1-9][0-9]*)' +SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" + +if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then + echo "not a semantic version: $VERSION" >&2 + echo "Expected something like v0.1.11 or 1.2.3-rc.1+build.5." >&2 + exit 1 +fi + +printf '%s\n' "$VERSION" diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 0648062..b85e16d 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -25,20 +25,9 @@ if [[ -z "$VERSION" ]]; then exit 1 fi -VERSION="${VERSION#v}" - -# semver.org's reference grammar. The looser "digits, dots and dashes" shape this -# replaces rejected a valid tag like 1.2.3-rc.1+build.5, because build metadata can -# follow a prerelease, and accepted invalid ones like 01.2.3, which cargo refuses -# later in the release with a much less obvious error. -SEMVER_NUM='(0|[1-9][0-9]*)' -SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" -SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" - -if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then - echo "not a semantic version: $VERSION" >&2 - exit 1 -fi +# One grammar, shared with the release workflow's resolve step, which rejects a +# bad tag before any build starts. +VERSION="$("$SCRIPT_DIR/check-version.sh" "$VERSION")" set_package_version() { local src="$1" dest="$2" @@ -121,11 +110,19 @@ for key in "${PATH_DEP_KEYS[@]}"; do mv "$ROOT_MANIFEST.next" "$ROOT_MANIFEST.tmp" done +# Cargo.lock is backed up but never staged: `cargo update` writes it below, and a +# failure or interrupt there would otherwise leave a refreshed lockfile beside +# restored manifests. +LOCKFILE="$REPO_ROOT/Cargo.lock" +if [[ -f "$LOCKFILE" ]]; then + TARGETS+=("$LOCKFILE") +fi + for target in "${TARGETS[@]}"; do cp "$target" "$target.bak" done for target in "${TARGETS[@]}"; do - mv "$target.tmp" "$target" + [[ -f "$target.tmp" ]] && mv "$target.tmp" "$target" done APPLIED=1 diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index db39388..0f909a6 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -115,6 +115,20 @@ if [[ -z "$TAP_FORMULA_PATH" ]]; then exit 1 fi +# The path is created and truncated below, so an explicit value gets the same +# scrutiny as a discovered one: a `../` path or a non-formula target would +# overwrite a file that is not a formula. +if [[ "$TAP_FORMULA_PATH" != *.rb ]]; then + echo "TAP_FORMULA_PATH must name a .rb formula file: $TAP_FORMULA_PATH" >&2 + exit 1 +fi +case "$TAP_FORMULA_PATH" in + *..*) + echo "TAP_FORMULA_PATH must not traverse with '..': $TAP_FORMULA_PATH" >&2 + exit 1 + ;; +esac + if [[ -z "$EXPLICIT_TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" && ! -d "$TAP_REPO_PATH" ]]; then echo "TAP_REPO_PATH does not exist: $TAP_REPO_PATH" >&2 exit 1 From 86a371f9486c947643c226071a5b795619cd51d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:19:25 +0800 Subject: [PATCH 10/18] fix: bind existing-mode kubectl to K3S_URL and tighten command classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing mode joined workers to K3S_URL and then labelled, tainted, and deployed wherever kubectl happened to point, warning about it rather than checking. The kubectl context is now compared with K3S_URL by host and port before anything is changed, so a default context for another cluster fails instead of receiving the fleet's metadata and manifests. The dangerous-command classifier matched prefixes with `starts_with`, so raw commands that merely spell one as a prefix — `team disablex`, `billing capacityfoo`, `ssh-key generate-api-keyx` — prompted as if they were the dangerous form. Matching is now anchored at a word boundary, which also lets the list drop its trailing-space convention. `share receive-email` joins the list, since it turns a VM's mailbox on and sets who it may write to, while the read-only `integrations setup --list` and `--verify` forms no longer prompt. `tailscale up` exits non-zero when the node is locked out, and the script returned that status before reaching the Tailnet Lock check, so the documented sign-and-retry flow could never trigger for the case it exists for. The check now runs before the status is acted on. The text fallback took any first word as a VM name, so `Error: quota exceeded` became a VM called `Error:` and `VM vm-1 is unavailable` became `VM`; a planned VM reported that way would look like it already existed. Words that are not DNS labels are no longer inventory. State directory names sanitized `a/b` and `a_b` to the same directory, where two clusters would overwrite each other's token; unsafe names now carry a digest of the original. An existing but empty token file was returned as the cluster credential. Release: the tag is validated on the push path too, since the trigger only filters `v*`; verification moved to its own job so the write-capable token is scoped to publishing alone; and a post-publish check turns a tag moved during the release action from a silent mismatch into a failed run. The tap script requires archive members to be regular files rather than merely present, keeps FORMULA_CLASS consistent with FORMULA_NAME, confines an explicit TAP_FORMULA_PATH to the tap and refuses a symlink, and treats a directory as sharded only when it actually holds formulae. --- .github/workflows/release.yml | 59 ++++++++++--- core/src/shell.rs | 126 +++++++++++++++++++-------- k8s_cli/src/manager/mod.rs | 59 +++++++++++-- k8s_cli/src/manager/parsing.rs | 19 +++- k8s_cli/src/manager/scripts.rs | 2 +- k8s_cli/src/manager/state.rs | 33 ++++++- k8s_cli/src/manager/tests.rs | 84 ++++++++++++++++++ scripts/release/check-version.sh | 4 + scripts/release/sync-homebrew-tap.sh | 49 ++++++++++- 9 files changed, 370 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 631982e..e27e94f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,9 +44,6 @@ jobs: set -euo pipefail if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then tag="${INPUT_TAG_NAME}" - # Rejected here rather than in each matrix build, where set-version - # would fail four times over after the checkouts and toolchain setup. - scripts/release/check-version.sh "${tag}" sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.type')" # An annotated tag points at a tag object, not the commit it names. @@ -62,6 +59,11 @@ jobs: tag="${GITHUB_REF_NAME}" sha="${GITHUB_SHA}" fi + # Rejected here on either path rather than in each matrix build, where + # set-version would fail four times over after the checkouts and + # toolchain setup. The push trigger only filters `v*`, so a pushed + # `v1.2` reaches this point too. + scripts/release/check-version.sh "${tag}" echo "tag=${tag}" >> "${GITHUB_OUTPUT}" echo "sha=${sha}" >> "${GITHUB_OUTPUT}" @@ -173,21 +175,14 @@ jobs: path: dist/${{ steps.package.outputs.archive }} if-no-files-found: error - publish: - name: publish release + # Separate from publish so the write-capable token is not in scope while this + # runs: the job that holds contents: write grants it to every one of its steps. + verify: + name: verify release commit needs: [resolve, build] runs-on: ubuntu-24.04 - permissions: - contents: write steps: - - name: Download release archives - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: release-* - path: dist - merge-multiple: true - # The archives were built from one commit; the release is about to be # attached to a tag name. If the tag moved in between, publishing would ship # binaries that do not match the source the tag now points at. @@ -209,6 +204,21 @@ jobs: exit 1 fi + publish: + name: publish release + needs: [resolve, verify] + runs-on: ubuntu-24.04 + permissions: + contents: write + + steps: + - name: Download release archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: release-* + path: dist + merge-multiple: true + - name: Publish GitHub release # Pinned to a commit, not the mutable v3 tag: this is the only step that # runs with contents: write, so retagging upstream would hand a new @@ -219,3 +229,24 @@ jobs: tag_name: ${{ needs.resolve.outputs.tag }} files: dist/*.tar.gz generate_release_notes: true + + # The release API attaches to a tag name, so a tag moved during the step + # above cannot be refused at that instant. Failing here turns what would be + # a silent mismatch between the release and its source into a red run. + - name: Confirm the published tag is still the built commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + BUILT_SHA: ${{ needs.resolve.outputs.sha }} + run: | + set -euo pipefail + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.sha')" + type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.type')" + if [[ "${type}" == "tag" ]]; then + sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" + fi + if [[ "${sha}" != "${BUILT_SHA}" ]]; then + echo "tag ${RELEASE_TAG} moved to ${sha} while publishing; the release does not match ${BUILT_SHA}" >&2 + exit 1 + fi diff --git a/core/src/shell.rs b/core/src/shell.rs index 3733245..303f945 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -40,57 +40,92 @@ pub fn guard_dangerous_command(command: &str, yes: bool) -> Result<()> { fn is_dangerous(command: &str) -> bool { let normalized = command.trim(); - let prefixes = [ - "rm ", - "share set-public ", - "share set-private ", - "share add-link ", - "share add-share-link ", - "share remove-link ", - "share remove-share-link ", - "share remove ", - "share access allow ", - "grant-support-root ", + let commands = [ + "rm", + "share set-public", + "share set-private", + "share add-link", + "share add-share-link", + "share remove-link", + "share remove-share-link", + "share remove", + "share access allow", + // Turns a VM's mailbox on and sets who it may write to. + "share receive-email", + "grant-support-root", // Both mint a credential that reaches VMs, so they belong with the // revocation the list already covers. - "ssh-key add ", + "ssh-key add", "ssh-key generate-api-key", - "ssh-key remove ", - "domain add ", + "ssh-key remove", + "domain add", + "domain rm", // `add` can carry --attach specs and `attach` mounts the credential into // VMs, so both hand out access just as `detach` and `edit` take it away. - "integrations add ", - "integrations attach ", - "integrations remove ", - "integrations setup ", - "integrations detach ", - "integrations edit ", + "integrations add", + "integrations attach", + "integrations remove", + "integrations setup", + "integrations detach", + "integrations edit", // Everything that changes who holds authority over the team or its VMs. - "team add ", - "team remove ", - "team role ", - "team transfer ", - "team auth set ", - "team settings vm-sharing ", + "team add", + "team remove", + "team role", + "team transfer", + "team auth set", + "team settings vm-sharing", "team disable", "team settings auto-join on", - "domain rm ", // Both sides of reserved capacity: creating a pool reserves it, and the // list already prompts before `billing capacity` changes the subscription. - "pool new ", - "pool delete ", + "pool new", + "pool delete", "billing capacity", - "billing credits buy ", - "billing payment remove ", - "billing payment default ", + "billing credits buy", + "billing payment remove", + "billing payment default", + "tag -d", ]; - prefixes + if is_read_only_integrations_setup(normalized) { + return false; + } + commands .iter() - .any(|prefix| normalized == prefix.trim_end() || normalized.starts_with(prefix)) - || normalized.starts_with("tag -d ") + .any(|name| matches_command(normalized, name)) || grants_shell_access(normalized) } +/// Matches a command name at a word boundary. +/// +/// A plain `starts_with` would also match anything that merely spells one of +/// these names as a prefix, so a raw `exec -- team disablex` or +/// `billing capacityfoo` would prompt for a command that is not the dangerous one. +fn matches_command(command: &str, name: &str) -> bool { + command == name + || command + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with(' ')) +} + +/// `integrations setup --list` and `--verify` only report what is already +/// connected, so they are exempt unless a disconnect flag is present too. +fn is_read_only_integrations_setup(command: &str) -> bool { + if !matches_command(command, "integrations setup") { + return false; + } + let mut reads = false; + let mut mutates = false; + for word in command.split_whitespace() { + match word { + "--list" | "--verify" => reads = true, + "-d" | "--delete" => mutates = true, + _ => {} + } + } + reads && !mutates +} + /// `share add --root` grants SSH, Terminal, and Shelley access, /// which is strictly more powerful than the web-only share it looks like. fn grants_shell_access(command: &str) -> bool { @@ -144,9 +179,30 @@ mod tests { assert!(is_dangerous("domain add mybox app.example.com")); assert!(is_dangerous("pool new builders --cpus 16 --region fra")); assert!(is_dangerous("billing payment default 4f1c2a9b")); + assert!(is_dangerous("share receive-email mybox on")); assert!(!is_dangerous("ls")); assert!(!is_dangerous("pool list")); assert!(!is_dangerous("billing payment list")); + } + + #[test] + fn danger_matching_stops_at_word_boundaries() { + assert!(!is_dangerous("team disablex")); + assert!(!is_dangerous("billing capacityfoo")); + assert!(!is_dangerous("ssh-key generate-api-keyx")); + assert!(!is_dangerous("team settings auto-join oncall")); + assert!(!is_dangerous("rmx vm1")); + assert!(is_dangerous("team disable --yes")); + assert!(is_dangerous("billing capacity")); + } + + #[test] + fn read_only_integrations_setup_is_exempt() { + assert!(!is_dangerous("integrations setup github --list")); + assert!(!is_dangerous("integrations setup chatgpt --verify")); + assert!(is_dangerous("integrations setup github")); + assert!(is_dangerous("integrations setup github --list -d")); + assert!(is_dangerous("integrations setup github --delete --list")); assert!(!is_dangerous("ssh-key list")); assert!(!is_dangerous("share show mybox")); assert!(!is_dangerous("domain ls mybox")); diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 6d03409..f121254 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -399,12 +399,11 @@ async fn bootstrap_k3s( ClusterMode::Existing => { let k3s_url = require_env(K3S_URL_ENV)?; let token = require_env(K3S_TOKEN_ENV)?; - if kubeconfig_arg.is_none() && env::var_os("KUBECONFIG").is_none() { - println!( - "{} no --kubeconfig or KUBECONFIG set; kubectl will use its default config", - output::warn("warning:") - ); - } + // Workers are joined to K3S_URL, while the labels, taints, and + // manifests that follow go wherever kubectl points. Without this they + // could be applied to an unrelated cluster, so the two are required to + // be the same cluster before anything is changed. + ensure_kubectl_targets_cluster(kubeconfig_arg, &k3s_url).await?; for node in plan .nodes .iter() @@ -419,6 +418,54 @@ async fn bootstrap_k3s( } } +/// Confirms the kubectl context serves the cluster the workers are joining. +async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str) -> Result<()> { + let server = kubectl_capture( + kubeconfig, + &[ + "config", + "view", + "--minify", + "-o", + "jsonpath={.clusters[0].cluster.server}", + ], + ) + .await + .context("failed to read the kubectl context; pass --kubeconfig or set KUBECONFIG")?; + let server = server.trim(); + if server.is_empty() { + bail!( + "kubectl has no cluster server configured; pass --kubeconfig or set KUBECONFIG so {K3S_URL_ENV} and kubectl agree" + ); + } + if !same_cluster_endpoint(server, k3s_url) { + bail!( + "kubectl points at {server} but {K3S_URL_ENV} is {k3s_url}; pass --kubeconfig for that cluster rather than labelling and deploying to another one" + ); + } + Ok(()) +} + +/// Compares two endpoints by host and port, so an explicit `:6443` and the same +/// URL without it are still the same cluster. +fn same_cluster_endpoint(left: &str, right: &str) -> bool { + fn parts(url: &str) -> (String, String) { + let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); + let authority = without_scheme + .split('/') + .next() + .unwrap_or(without_scheme) + .trim_end_matches('.'); + match authority.rsplit_once(':') { + Some((host, port)) if port.chars().all(|ch| ch.is_ascii_digit()) => { + (host.to_ascii_lowercase(), port.to_string()) + } + _ => (authority.to_ascii_lowercase(), "6443".to_string()), + } + } + parts(left) == parts(right) +} + async fn install_tailscale(targets: &SshTargets, vm: &str, authkey: &str) -> Result<()> { let command = tailscale_install_command(authkey); let script = remote_bootstrap_script(&command); diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 2a845e5..bf61456 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -155,10 +155,27 @@ pub(super) fn parse_vm_names_from_text(text: &str) -> BTreeSet { // `nameserver`, and bootstrap would then try to create it again. .enumerate() .filter(|(index, name)| *index > 0 || !name.eq_ignore_ascii_case("name")) - .map(|(_, name)| name.to_string()) + .map(|(_, name)| name) + .filter(|name| is_vm_name(name)) + .map(str::to_string) .collect() } +/// Whether a word from rendered output can be a VM name at all. +/// +/// Not every non-JSON body is a table: `Error: quota exceeded` and +/// `VM vm-1 is unavailable` would otherwise contribute `Error:` and `VM` as VMs, +/// and a planned VM reported that way would look like it already exists. exe.dev +/// names are DNS labels, so anything else is prose rather than a row. +fn is_vm_name(word: &str) -> bool { + !word.is_empty() + && word.len() <= 63 + && word.starts_with(|ch: char| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + && word + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + pub(super) fn parse_kubernetes_nodes(response: &str) -> Result> { let value = serde_json::from_str::(response).context("kubectl returned invalid JSON")?; let mut nodes = BTreeMap::new(); diff --git a/k8s_cli/src/manager/scripts.rs b/k8s_cli/src/manager/scripts.rs index 37a54ed..1d03473 100644 --- a/k8s_cli/src/manager/scripts.rs +++ b/k8s_cli/src/manager/scripts.rs @@ -190,7 +190,7 @@ install_k3s_binary() { pub(super) fn tailscale_install_command(authkey: &str) -> String { format!( - "if ! command -v tailscale >/dev/null 2>&1; then curl -fsSL https://tailscale.com/install.sh | ${{SUDO}} sh; fi;\n{START_TAILSCALED_SCRIPT}\ntailscale_up_output=\"$(${{SUDO}} tailscale up --auth-key {} --ssh --accept-routes 2>&1)\"\ntailscale_up_status=$?\nif [ -n \"$tailscale_up_output\" ]; then\n printf '%s\\n' \"$tailscale_up_output\" >&2\nfi\nif [ \"$tailscale_up_status\" -ne 0 ]; then\n exit \"$tailscale_up_status\"\nfi\n{CHECK_TAILNET_LOCK_SCRIPT}", + "if ! command -v tailscale >/dev/null 2>&1; then curl -fsSL https://tailscale.com/install.sh | ${{SUDO}} sh; fi;\n{START_TAILSCALED_SCRIPT}\ntailscale_up_output=\"$(${{SUDO}} tailscale up --auth-key {} --ssh --accept-routes 2>&1)\"\ntailscale_up_status=$?\nif [ -n \"$tailscale_up_output\" ]; then\n printf '%s\\n' \"$tailscale_up_output\" >&2\nfi\n{CHECK_TAILNET_LOCK_SCRIPT}\nif [ \"$tailscale_up_status\" -ne 0 ]; then\n exit \"$tailscale_up_status\"\nfi", shell_single_quote(authkey) ) } diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index fdc3d2b..87ac173 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -28,6 +28,16 @@ pub(super) fn generated_token_path(cluster_name: &str) -> PathBuf { /// `../../elsewhere` would otherwise place the token and kubeconfig outside /// `.exedev-k8s`. Anything that is not a plain name component is replaced. fn state_dir_name(cluster_name: &str) -> String { + let safe = !cluster_name.is_empty() + && cluster_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'); + if safe { + return cluster_name.to_string(); + } + // Two names that sanitize alike — `a/b` and `a_b` — would otherwise share one + // directory and overwrite each other's token. The digest of the original name + // keeps them apart while the sanitized part keeps the directory recognizable. let sanitized = cluster_name .chars() .map(|ch| { @@ -38,11 +48,18 @@ fn state_dir_name(cluster_name: &str) -> String { } }) .collect::(); - if sanitized.trim_matches('_').is_empty() { - "cluster".to_string() - } else { - sanitized + format!("{sanitized}-{:016x}", fnv1a(cluster_name.as_bytes())) +} + +/// FNV-1a, spelled out so the directory a cluster uses never changes with the +/// toolchain the way `DefaultHasher` would. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x1000_0000_01b3); } + hash } pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { @@ -66,6 +83,14 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { } if path.exists() { let token = read_regular_file(&path).map(|text| text.trim().to_string())?; + // An empty file is not a token. Returning it would hand the server and + // every agent a blank credential, the same way an empty K3S_TOKEN would. + if token.is_empty() { + bail!( + "{} is empty; delete it to generate a new cluster token", + path.display() + ); + } // A token left readable by others (an older run, a restored backup) stays // that way for every future run unless it is tightened when reused. restrict_secret_permissions(&path)?; diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 16c9d5e..9909b56 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -188,9 +188,16 @@ fn parses_ssh_destinations_from_ls_json() { {"vm_name":"routable","ssh_dest":"routable.exe.xyz","ssh_host":"routable.exe.xyz"}, {"vm_name":"prefixed","ssh_dest":"vm+prefixed@exe.dev","ssh_host":"exe.dev","ssh_user":"vm+prefixed"}, {"vm_name":"host-only","ssh_host":"shard3.exe.dev","ssh_user":"vm+host-only"}, + {"vm_name":"conflicting","ssh_dest":"vm+conflicting@exe.dev","ssh_host":"wrong.exe.xyz","ssh_user":"wrong"}, {"vm_name":"unknown"} ]}"#, ); + // ssh_dest is authoritative: preferring the host/user pair here would dial a + // different route than exe.dev reported. + assert_eq!( + destinations.get("conflicting").unwrap(), + "vm+conflicting@exe.dev" + ); assert_eq!(destinations.get("routable").unwrap(), "routable.exe.xyz"); assert_eq!(destinations.get("prefixed").unwrap(), "vm+prefixed@exe.dev"); assert_eq!( @@ -473,3 +480,80 @@ fn staging_names_do_not_repeat() { assert_eq!(leftovers, 0); std::fs::remove_dir_all(&dir).unwrap(); } + +#[test] +fn error_and_status_text_is_not_taken_for_inventory() { + assert!( + parse_vm_names(r#"{"output":"Error: quota exceeded\n"}"#) + .unwrap() + .is_empty() + ); + assert!(parse_vm_names("VM vm-1 is unavailable").unwrap().is_empty()); + let names = parse_vm_names_from_text("NAME STATUS\nvm-1 running\nnameserver stopped\n"); + assert_eq!(names.len(), 2); + assert!(names.contains("vm-1")); + assert!(names.contains("nameserver")); +} + +#[test] +fn distinct_cluster_names_get_distinct_state_directories() { + let slash = generated_token_path("a/b"); + let underscore = generated_token_path("a_b"); + assert_ne!(slash, underscore); + assert!(slash.starts_with(".exedev-k8s")); + // A name that needs no sanitizing keeps its own readable directory. + assert_eq!( + generated_token_path("a_b"), + Path::new(".exedev-k8s/a_b/k3s-token") + ); + // Same input, same directory, run after run. + assert_eq!(generated_token_path("a/b"), generated_token_path("a/b")); +} + +#[test] +fn cluster_endpoints_compare_by_host_and_port() { + assert!(same_cluster_endpoint( + "https://100.64.0.1:6443", + "https://100.64.0.1:6443" + )); + assert!(same_cluster_endpoint( + "https://k3s.example", + "k3s.example:6443" + )); + assert!(!same_cluster_endpoint( + "https://100.64.0.1:6443", + "https://100.64.0.2:6443" + )); + assert!(!same_cluster_endpoint( + "https://100.64.0.1:6443", + "https://100.64.0.1:7443" + )); +} + +/// `read_or_create_k3s_token` resolves its path relative to the working +/// directory and consults the environment, both of which are process-wide. +static STATE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn an_empty_token_file_is_refused() { + let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + let previous_dir = std::env::current_dir().unwrap(); + let dir = std::env::temp_dir().join(format!("exedev-k8s-emptytok-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_current_dir(&dir).unwrap(); + unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + + write_secret_file(&generated_token_path("c1"), " \n").unwrap(); + let result = read_or_create_k3s_token("c1"); + + // A fresh cluster still generates one; only an empty file is refused. + let generated = read_or_create_k3s_token("c2"); + + std::env::set_current_dir(&previous_dir).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + + let err = result.unwrap_err().to_string(); + assert!(err.contains("is empty"), "unexpected error: {err}"); + assert!(!generated.unwrap().is_empty()); +} diff --git a/scripts/release/check-version.sh b/scripts/release/check-version.sh index d29ebc1..1e24cf1 100755 --- a/scripts/release/check-version.sh +++ b/scripts/release/check-version.sh @@ -18,6 +18,10 @@ fi VERSION="${VERSION#v}" +# Character ranges in the pattern below are ASCII; a locale with different +# collation would otherwise decide what [0-9A-Za-z] covers. +LC_ALL=C + SEMVER_NUM='(0|[1-9][0-9]*)' SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 0f909a6..72fcf27 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -74,6 +74,18 @@ if [[ ! "$FORMULA_CLASS" =~ ^[A-Z][0-9A-Za-z_]*$ ]]; then exit 1 fi +# Homebrew derives the class from the file name, so a class naming a different +# formula produces a file it will not load under the name it was written as. +EXPECTED_CLASS="$(printf '%s' "$FORMULA_NAME" | awk -F'[-_]' '{ + out = "" + for (i = 1; i <= NF; i++) out = out toupper(substr($i, 1, 1)) substr($i, 2) + print out +}')" +if [[ "$FORMULA_CLASS" != "$EXPECTED_CLASS" ]]; then + echo "FORMULA_CLASS $FORMULA_CLASS does not match FORMULA_NAME $FORMULA_NAME (expected $EXPECTED_CLASS)" >&2 + exit 1 +fi + for field in FORMULA_DESC FORMULA_LICENSE; do value="${!field}" if [[ -z "$value" || "$value" == *\"* || "$value" == *\\* || "$value" == *"#"* || "$value" == *$'\n'* ]]; then @@ -103,7 +115,9 @@ if [[ -z "$TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" ]]; then TAP_FORMULA_PATH="$FORMULA_SHARD_PATH" elif [[ -f "$FORMULA_FLAT_PATH" ]]; then TAP_FORMULA_PATH="$FORMULA_FLAT_PATH" - elif [[ -d "$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}" ]]; then + elif compgen -G "$TAP_REPO_PATH/Formula/${FORMULA_NAME:0:1}/*.rb" > /dev/null; then + # The directory alone proves nothing; a flat tap can hold an unrelated one. + # Formulae inside it are what makes the tap sharded. TAP_FORMULA_PATH="$FORMULA_SHARD_PATH" else TAP_FORMULA_PATH="$FORMULA_FLAT_PATH" @@ -129,6 +143,29 @@ case "$TAP_FORMULA_PATH" in ;; esac +if [[ -L "$TAP_FORMULA_PATH" ]]; then + echo "TAP_FORMULA_PATH is a symlink; refusing to write through it: $TAP_FORMULA_PATH" >&2 + exit 1 +fi + +# An explicit path gets the same confinement as a discovered one when there is a +# tap to confine it to; the file is created and truncated below either way. +if [[ -n "$TAP_REPO_PATH" && -d "$TAP_REPO_PATH" ]]; then + TAP_REPO_REAL="$(cd "$TAP_REPO_PATH" && pwd -P)" + FORMULA_PARENT="$(dirname "$TAP_FORMULA_PATH")" + mkdir -p "$FORMULA_PARENT" + FORMULA_PARENT_REAL="$(cd "$FORMULA_PARENT" && pwd -P)" + case "$FORMULA_PARENT_REAL/" in + "$TAP_REPO_REAL"/*) ;; + *) + echo "TAP_FORMULA_PATH resolves outside TAP_REPO_PATH:" >&2 + echo " formula: $FORMULA_PARENT_REAL" >&2 + echo " tap: $TAP_REPO_REAL" >&2 + exit 1 + ;; + esac +fi + if [[ -z "$EXPLICIT_TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" && ! -d "$TAP_REPO_PATH" ]]; then echo "TAP_REPO_PATH does not exist: $TAP_REPO_PATH" >&2 exit 1 @@ -170,10 +207,14 @@ sha_for() { # on the platform it belongs to, so a malformed Linux archive is invisible in the # macOS one. for platform in "${PLATFORMS[@]}"; do - tar -tzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" > "$WORK_DIR/members.txt" + # Verbose listing: the formula installs these paths as files, so a directory or + # a symlink carrying the expected name would satisfy a name-only check and then + # install the wrong thing. + tar -tvzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" > "$WORK_DIR/members.txt" for member in "${BINARIES[@]}" "${DOCS[@]}"; do - if ! grep -qx "\./$member" "$WORK_DIR/members.txt"; then - echo "$platform release archive does not contain expected member: $member" >&2 + if ! awk -v want="./$member" '$1 ~ /^-/ && $NF == want { found = 1 } END { exit found ? 0 : 1 }' \ + "$WORK_DIR/members.txt"; then + echo "$platform release archive has no regular file member: $member" >&2 exit 1 fi done From 97b0ed263df5a7c3e7d1c81d5f9c624bb3bd8d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:38:54 +0800 Subject: [PATCH 11/18] fix: reconcile node taints and close remaining state and release gaps apply_node_metadata only ever added the desired taint, so a pool changed to unisolated kept its old NoSchedule and stayed unschedulable while the plan said otherwise, and status printed taint=ok because it treated a desired `None` as nothing to check. Taints under the exedev.dev prefix that the plan no longer wants are now removed, taints set by anything else are left alone, and status compares what is on the node with what the plan asks for. same_cluster_endpoint trimmed the root label from the whole authority, which cannot reach a dot before an explicit port, so `k3s.example.:6443` and `k3s.example:6443` compared as different clusters and blocked existing-mode bootstrap. The trim now happens after the port is split off. Tightening a reused token's permissions went through the path, and fs::set_permissions follows symlinks, so an entry swapped after the check could have had its target chmodded instead. It now happens through the handle the contents were read from. State directories are also confirmed to be real directories rather than symlinks before a secret is written into them, and a bare relative path no longer takes the create_dir_all branch at all. A recorded k3s pid can outlive the process and be reused by an unrelated one, which read as "already running" and skipped the start; the pid is now confirmed to still belong to k3s. A failed k3s download left its partial file in /tmp rather than cleaning up as the checksum-mismatch path does. Release: the post-publish check moved into its own job, so the job holding contents: write is down to downloading the artifacts and running the release action. Archive validation now requires every entry with a required name to be a regular file, since extraction applies entries in order and a later symlink would be what gets installed. set-version.sh marks the workspace as applied before the first move rather than after the last, so an interrupt partway through the moves still restores. --- .github/workflows/release.yml | 14 ++++- k8s_cli/src/manager/mod.rs | 88 +++++++++++++++++++++++----- k8s_cli/src/manager/scripts.rs | 33 +++++++++-- k8s_cli/src/manager/state.rs | 59 +++++++++++++++---- k8s_cli/src/manager/tests.rs | 64 ++++++++++++++++++++ scripts/release/set-version.sh | 5 +- scripts/release/sync-homebrew-tap.sh | 11 +++- 7 files changed, 235 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e27e94f..746e241 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -230,9 +230,17 @@ jobs: files: dist/*.tar.gz generate_release_notes: true - # The release API attaches to a tag name, so a tag moved during the step - # above cannot be refused at that instant. Failing here turns what would be - # a silent mismatch between the release and its source into a red run. + + # Its own job, and therefore its own read-only token: the release API attaches + # to a tag name, so a tag moved during publication cannot be refused at that + # instant. Failing afterwards turns what would be a silent mismatch between the + # release and its source into a red run. + confirm: + name: confirm published commit + needs: [resolve, publish] + runs-on: ubuntu-24.04 + + steps: - name: Confirm the published tag is still the built commit shell: bash env: diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index f121254..4245f78 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -13,7 +13,7 @@ use exedev_core::{ shell, }; use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, env, net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpStream}, path::{Path, PathBuf}, @@ -33,7 +33,7 @@ use kubectl::{ KUBECTL_PROBE_REQUEST_TIMEOUT, kubectl_apply, kubectl_capture, kubectl_capture_with_timeout, kubectl_run_owned, }; -use parsing::{parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names}; +use parsing::{KubernetesNode, parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names}; use process::{SshTargets, ensure_tool, remote_capture, remote_run, verify_vm_access}; use scripts::{ k3s_agent_install_command, k3s_server_install_command, remote_bootstrap_script, @@ -50,6 +50,8 @@ const KUBERNETES_API_WAIT_ATTEMPTS: usize = 24; const KUBERNETES_NODE_WAIT_ATTEMPTS: usize = 30; const KUBERNETES_WAIT_DELAY: Duration = Duration::from_secs(5); const LOCAL_K8S_API_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(3); +/// Labels and taints under this prefix are this tool's to reconcile. +const NODE_LABEL_PREFIX: &str = "exedev.dev/"; pub(crate) async fn run(cli: K8sCli) -> Result<()> { match cli.command { K8sCommands::Plan(cmd) => run_plan(&cli.endpoint, cmd).await, @@ -288,9 +290,18 @@ async fn print_kubernetes_status(plan: &FleetPlan, kubeconfig: Option<&Path>) -> .labels .iter() .all(|(key, value)| actual.labels.get(key) == Some(value)); + // A node the plan no longer isolates still counts as drift while it + // carries one of this tool's taints, so `None` cannot simply be `ok`. + let owned_taints = actual + .taints + .iter() + .filter(|taint| { + taint_key(taint).is_some_and(|key| key.starts_with(NODE_LABEL_PREFIX)) + }) + .collect::>(); let taint_ok = match &expected.taint { - Some(taint) => actual.taints.contains(taint), - None => true, + Some(taint) => owned_taints == [taint], + None => owned_taints.is_empty(), }; println!( " - {} ready={} labels={} taint={}", @@ -451,17 +462,21 @@ async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str fn same_cluster_endpoint(left: &str, right: &str) -> bool { fn parts(url: &str) -> (String, String) { let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); - let authority = without_scheme - .split('/') - .next() - .unwrap_or(without_scheme) - .trim_end_matches('.'); - match authority.rsplit_once(':') { - Some((host, port)) if port.chars().all(|ch| ch.is_ascii_digit()) => { - (host.to_ascii_lowercase(), port.to_string()) + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) + if !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) => + { + (host, port) } - _ => (authority.to_ascii_lowercase(), "6443".to_string()), - } + _ => (authority, "6443"), + }; + // The root label is trimmed after the port is split off, so `host.:6443` + // and `host:6443` are the same authority. + ( + host.trim_end_matches('.').to_ascii_lowercase(), + port.to_string(), + ) } parts(left) == parts(right) } @@ -701,6 +716,10 @@ async fn apply_node_metadata( include_control_plane: bool, kubeconfig: Option<&Path>, ) -> Result<()> { + let actual = kubectl_capture(kubeconfig, &["get", "nodes", "-o", "json"]) + .await + .and_then(|output| parse_kubernetes_nodes(&output)) + .unwrap_or_default(); for node in plan.bootstrap_nodes(include_control_plane) { let mut label_args = vec!["label".into(), "node".into(), node.name.clone()]; label_args.extend( @@ -724,10 +743,51 @@ async fn apply_node_metadata( ) .await?; } + + // Applying the desired taint says nothing about the one before it. A pool + // changed to unisolated would keep its old NoSchedule and stay unschedulable + // while the plan says otherwise, so taints this tool owns and no longer + // wants are removed. + for stale in stale_owned_taints(&actual, &node.name, node.taint.as_deref()) { + kubectl_run_owned( + kubeconfig, + vec!["taint".into(), "node".into(), node.name.clone(), stale], + ) + .await?; + } } Ok(()) } +/// Taint removal arguments (`key-`) for this tool's taints that the plan dropped. +/// +/// Ownership is the `exedev.dev/` prefix, so taints set by anything else are left +/// alone. +fn stale_owned_taints( + nodes: &BTreeMap, + name: &str, + desired: Option<&str>, +) -> Vec { + let desired_key = desired.and_then(taint_key); + nodes + .get(name) + .map(|node| { + node.taints + .iter() + .filter_map(|taint| taint_key(taint)) + .filter(|key| key.starts_with(NODE_LABEL_PREFIX)) + .filter(|key| Some(*key) != desired_key) + .map(|key| format!("{key}-")) + .collect() + }) + .unwrap_or_default() +} + +fn taint_key(taint: &str) -> Option<&str> { + let key = taint.split(['=', ':']).next()?; + (!key.is_empty()).then_some(key) +} + fn kubeconfig_for_bootstrap( plan: &FleetPlan, mode: ClusterMode, diff --git a/k8s_cli/src/manager/scripts.rs b/k8s_cli/src/manager/scripts.rs index 1d03473..ff05563 100644 --- a/k8s_cli/src/manager/scripts.rs +++ b/k8s_cli/src/manager/scripts.rs @@ -50,6 +50,22 @@ if printf '%s\n' "$tailscale_lock_output" | grep -qi 'Tailnet Lock is ENABLED'; fi "#; pub(super) const K3S_INSTALL_HELPERS: &str = r#" +k3s_pidfile_alive() { + # A recorded pid can outlive k3s and be reused by an unrelated process, which + # would otherwise read as "already running" and skip the start entirely. + [ -f "$1" ] || return 1 + k3s_recorded_pid="$(cat "$1" 2>/dev/null)" + case "$k3s_recorded_pid" in + ''|*[!0-9]*) return 1 ;; + esac + ${SUDO} kill -0 "$k3s_recorded_pid" 2>/dev/null || return 1 + k3s_recorded_comm="$(${SUDO} ps -p "$k3s_recorded_pid" -o comm= 2>/dev/null || true)" + case "$k3s_recorded_comm" in + *k3s*) return 0 ;; + *) return 1 ;; + esac +} + has_k3s_supervisor() { { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; } || [ -x /sbin/openrc-run ] } @@ -163,8 +179,13 @@ install_k3s_binary() { k3s_tmp_bin="/tmp/exedev-k8s-k3s.$$" k3s_tmp_hash="/tmp/exedev-k8s-k3s.sha256.$$" k3s_base_url="https://github.com/k3s-io/k3s/releases/download/${k3s_version}" - curl -sfL -o "$k3s_tmp_bin" "${k3s_base_url}/k3s${k3s_suffix}" - curl -sfL -o "$k3s_tmp_hash" "${k3s_base_url}/sha256sum-${k3s_arch}.txt" + if ! curl -sfL -o "$k3s_tmp_bin" "${k3s_base_url}/k3s${k3s_suffix}" \ + || ! curl -sfL -o "$k3s_tmp_hash" "${k3s_base_url}/sha256sum-${k3s_arch}.txt"; then + # Without this a failed download leaves its partial file in /tmp for good. + rm -f "$k3s_tmp_bin" "$k3s_tmp_hash" + echo "failed to download k3s ${k3s_version}" >&2 + exit 1 + fi k3s_expected="$(grep " k3s${k3s_suffix}$" "$k3s_tmp_hash" | awk '{print $1}')" k3s_actual="$(sha256sum "$k3s_tmp_bin" | awk '{print $1}')" @@ -220,7 +241,7 @@ if has_k3s_supervisor; then start_k3s_service_no_block k3s else install_k3s_binary - if ! [ -f /var/run/exedev-k8s-k3s-server.pid ] || ! ${{SUDO}} kill -0 "$(cat /var/run/exedev-k8s-k3s-server.pid)" 2>/dev/null; then + if ! k3s_pidfile_alive /var/run/exedev-k8s-k3s-server.pid; then ${{SUDO}} env K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" nohup k3s server --write-kubeconfig-mode 600 --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" --advertise-address "$K3S_NODE_IP" --tls-san "$K3S_TLS_SAN" --cluster-cidr "$K3S_CLUSTER_CIDR" --service-cidr "$K3S_SERVICE_CIDR" >/tmp/exedev-k8s-k3s-server.log 2>&1 & echo $! | ${{SUDO}} tee /var/run/exedev-k8s-k3s-server.pid >/dev/null fi @@ -271,7 +292,7 @@ if has_k3s_supervisor; then restart_k3s_service_no_block k3s-agent else install_k3s_binary - if ! [ -f /var/run/exedev-k8s-k3s-agent.pid ] || ! ${{SUDO}} kill -0 "$(cat /var/run/exedev-k8s-k3s-agent.pid)" 2>/dev/null; then + if ! k3s_pidfile_alive /var/run/exedev-k8s-k3s-agent.pid; then ${{SUDO}} env K3S_URL="$K3S_SERVER_URL" K3S_TOKEN="$K3S_BOOTSTRAP_TOKEN" nohup k3s agent --node-name "$K3S_NODE_NAME" --node-ip "$K3S_NODE_IP" >/tmp/exedev-k8s-k3s-agent.log 2>&1 & echo $! | ${{SUDO}} tee /var/run/exedev-k8s-k3s-agent.pid >/dev/null fi @@ -279,7 +300,7 @@ fi k3s_wait=0 while [ "$k3s_wait" -lt 30 ]; do - if [ -f /var/run/exedev-k8s-k3s-agent.pid ] && ${{SUDO}} kill -0 "$(cat /var/run/exedev-k8s-k3s-agent.pid)" 2>/dev/null; then + if k3s_pidfile_alive /var/run/exedev-k8s-k3s-agent.pid; then break fi if has_k3s_supervisor && k3s_service_started k3s-agent; then @@ -289,7 +310,7 @@ while [ "$k3s_wait" -lt 30 ]; do sleep 2 done if ! has_k3s_supervisor; then - if ! [ -f /var/run/exedev-k8s-k3s-agent.pid ] || ! ${{SUDO}} kill -0 "$(cat /var/run/exedev-k8s-k3s-agent.pid)" 2>/dev/null; then + if ! k3s_pidfile_alive /var/run/exedev-k8s-k3s-agent.pid; then echo "k3s agent did not stay running" >&2 if [ -f /tmp/exedev-k8s-k3s-agent.log ]; then ${{SUDO}} tail -n 80 /tmp/exedev-k8s-k3s-agent.log >&2 || true diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 87ac173..c4d58b8 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -82,7 +82,7 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { return Ok(token); } if path.exists() { - let token = read_regular_file(&path).map(|text| text.trim().to_string())?; + let token = read_secret_file(&path).map(|text| text.trim().to_string())?; // An empty file is not a token. Returning it would hand the server and // every agent a blank credential, the same way an empty K3S_TOKEN would. if token.is_empty() { @@ -91,9 +91,6 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { path.display() ); } - // A token left readable by others (an older run, a restored backup) stays - // that way for every future run unless it is tightened when reused. - restrict_secret_permissions(&path)?; return Ok(token); } let token = random_token(); @@ -112,9 +109,19 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { /// The rename also replaces a symlink rather than following one planted at a /// caller-supplied `--kubeconfig` path. pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { - if let Some(parent) = path.parent() { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; + // Everything below writes by name inside this directory, so a symlinked + // component would place the secret wherever it points. Only the directories + // this tool creates are checked; a caller-supplied --kubeconfig path is the + // caller's own choice of destination. + if parent.starts_with(STATE_DIR) { + ensure_real_directories(parent)?; + } } let staged = staging_path(path); let write = || -> Result<()> { @@ -158,16 +165,40 @@ fn staging_path(path: &Path) -> PathBuf { } } -fn restrict_secret_permissions(path: &Path) -> Result<()> { - let mode = fs::symlink_metadata(path) +/// Reads a secret this tool wrote, tightening it if a previous run or a restored +/// backup left it readable by others. +/// +/// The permissions are changed through the handle the contents are read from. +/// `fs::set_permissions` takes a path and follows symlinks, so doing it by name +/// could chmod whatever an entry swapped in the meantime points at. +pub(super) fn read_secret_file(path: &Path) -> Result { + let (contents, file) = open_regular_file(path)?; + let mode = file + .metadata() .with_context(|| format!("failed to inspect {}", path.display()))? .permissions() .mode(); - if mode & 0o077 == 0 { - return Ok(()); + if mode & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to restrict permissions on {}", path.display()))?; + } + Ok(contents) +} + +fn ensure_real_directories(dir: &Path) -> Result<()> { + let mut walked = PathBuf::new(); + for component in dir.components() { + walked.push(component); + let metadata = fs::symlink_metadata(&walked) + .with_context(|| format!("failed to inspect {}", walked.display()))?; + if !metadata.is_dir() { + bail!( + "{} is not a real directory; remove it and rerun", + walked.display() + ); + } } - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .with_context(|| format!("failed to restrict permissions on {}", path.display())) + Ok(()) } fn random_suffix() -> String { @@ -187,6 +218,10 @@ fn random_suffix() -> String { /// and that handle is confirmed to be the same object the no-follow inspection /// accepted. pub(super) fn read_regular_file(path: &Path) -> Result { + Ok(open_regular_file(path)?.0) +} + +fn open_regular_file(path: &Path) -> Result<(String, fs::File)> { let before = fs::symlink_metadata(path) .with_context(|| format!("failed to inspect {}", path.display()))?; if !before.is_file() { @@ -206,7 +241,7 @@ pub(super) fn read_regular_file(path: &Path) -> Result { let mut contents = String::new(); file.read_to_string(&mut contents) .with_context(|| format!("failed to read {}", path.display()))?; - Ok(contents) + Ok((contents, file)) } pub(super) fn random_token() -> String { diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 9909b56..920ba22 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -557,3 +557,67 @@ fn an_empty_token_file_is_refused() { assert!(err.contains("is empty"), "unexpected error: {err}"); assert!(!generated.unwrap().is_empty()); } + +#[test] +fn trailing_dot_hosts_compare_equal_with_an_explicit_port() { + assert!(same_cluster_endpoint( + "https://k3s.example.:6443", + "https://k3s.example:6443" + )); + assert!(same_cluster_endpoint("https://k3s.example.", "k3s.example")); + assert!(!same_cluster_endpoint( + "https://k3s.example.:6443", + "https://other.example:6443" + )); +} + +#[test] +fn stale_owned_taints_are_scheduled_for_removal() { + let mut nodes = BTreeMap::new(); + nodes.insert( + "vm-1".to_string(), + parse_kubernetes_nodes( + r#"{"items":[{"metadata":{"name":"vm-1"},"spec":{"taints":[ + {"key":"exedev.dev/pool","value":"blue","effect":"NoSchedule"}, + {"key":"node.kubernetes.io/unreachable","value":"","effect":"NoExecute"} + ]}}]}"#, + ) + .unwrap() + .remove("vm-1") + .unwrap(), + ); + + // Dropping the isolation removes our taint and leaves Kubernetes' own alone. + assert_eq!( + stale_owned_taints(&nodes, "vm-1", None), + vec!["exedev.dev/pool-".to_string()] + ); + // Keeping the same key is not stale. + assert!(stale_owned_taints(&nodes, "vm-1", Some("exedev.dev/pool=blue:NoSchedule")).is_empty()); + // Switching keys retires the previous one. + assert_eq!( + stale_owned_taints(&nodes, "vm-1", Some("exedev.dev/role=x:NoSchedule")), + vec!["exedev.dev/pool-".to_string()] + ); +} + +#[test] +fn secret_writes_reject_a_symlinked_state_directory() { + // The directory is read under the lock: another test holding it has the + // process chdir'd into a directory it is about to delete. + let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + let previous_dir = std::env::current_dir().unwrap(); + let root = std::env::temp_dir().join(format!("exedev-k8s-statelink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("elsewhere")).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::env::set_current_dir(&root).unwrap(); + std::os::unix::fs::symlink("elsewhere", ".exedev-k8s").unwrap(); + + let result = write_secret_file(&generated_token_path("c1"), "secret"); + + std::env::set_current_dir(&previous_dir).unwrap(); + let err = result.unwrap_err().to_string(); + std::fs::remove_dir_all(&root).unwrap(); + assert!(err.contains("not a real directory"), "unexpected: {err}"); +} diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index b85e16d..4d95113 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -121,10 +121,13 @@ fi for target in "${TARGETS[@]}"; do cp "$target" "$target.bak" done +# Set before the first move, not after the last: an interrupt or a failing mv +# partway through leaves some manifests new and some old, which is exactly the +# state the restore exists for. +APPLIED=1 for target in "${TARGETS[@]}"; do [[ -f "$target.tmp" ]] && mv "$target.tmp" "$target" done -APPLIED=1 # The release build runs with --locked, which fails outright when Cargo.lock still # carries the old member versions. Refresh it here rather than leaving the build to diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 72fcf27..8d00f23 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -212,9 +212,14 @@ for platform in "${PLATFORMS[@]}"; do # install the wrong thing. tar -tvzf "$WORK_DIR/${ARCHIVE_PREFIX}-${RELEASE_TAG}-${platform}.tar.gz" > "$WORK_DIR/members.txt" for member in "${BINARIES[@]}" "${DOCS[@]}"; do - if ! awk -v want="./$member" '$1 ~ /^-/ && $NF == want { found = 1 } END { exit found ? 0 : 1 }' \ - "$WORK_DIR/members.txt"; then - echo "$platform release archive has no regular file member: $member" >&2 + # Every entry with the name must be a regular file, not just one of them: + # extraction applies entries in order, so a later symlink or directory with + # the same name is what ends up installed. + if ! awk -v want="./$member" ' + $NF == want { seen++; if ($1 !~ /^-/) bad++ } + END { exit (seen > 0 && bad == 0) ? 0 : 1 } + ' "$WORK_DIR/members.txt"; then + echo "$platform release archive member is missing or not a regular file: $member" >&2 exit 1 fi done From 19e9d713f9d8d97406dd94426f3441453c0b967e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:37:40 +0800 Subject: [PATCH 12/18] fix: close release-script write paths and merge wrapped listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tap script truncated the formula in place with `cat >`, so a symlink put there after the checks was written through. It now writes beside the target and renames over it, which replaces the entry rather than following it. An explicit TAP_FORMULA_PATH was only confined when TAP_REPO_PATH happened to be an existing directory, leaving `TAP_FORMULA_PATH=/tmp/x.rb TAP_REPO_PATH=/does-not-exist` free to write anywhere; a tap root is now required and the path must resolve inside it. Tag validation goes through the shared grammar, which pins LC_ALL, and the formula pins the version instead of letting Homebrew infer it from a platform-suffixed URL. Archive validation read the last field of the verbose listing, which for a symlink is its target rather than its name, so an archive carrying the real file followed by a symlink of the same name passed while extraction ended on the symlink. The name is now taken from before the ` -> `. set-version.sh derives staging and backup names from its targets, so anything already at one of them was written through — a symlink redirecting the rewrite out of the workspace — and then deleted by cleanup; those siblings must now be free before it runs. A workspace that had no Cargo.lock kept the one cargo writes when a run was interrupted, since there was no backup to restore over it. The cleanup also tripped `set -u` on an early failure, before any target was registered. The workflow read `.object.sha` and `.object.type` in two separate API calls, so a tag moved in between could pair a SHA from one side with a type from the other and pin a torn snapshot. Each ref is now read once. Both listing parsers now read the `output` wrapper on the same terms, so a response carrying outer records and a serialized listing contributes both rather than whichever one the parser happened to check first. Bare strings in a JSON array are filtered by name shape, which keeps prose out of inventory. --- .github/workflows/release.yml | 26 ++++++---- k8s_cli/src/manager/parsing.rs | 42 ++++++++-------- k8s_cli/src/manager/tests.rs | 30 +++++++++++- scripts/release/set-version.sh | 31 ++++++++++++ scripts/release/sync-homebrew-tap.sh | 72 ++++++++++++++++------------ 5 files changed, 138 insertions(+), 63 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 746e241..54e5e45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,12 +44,16 @@ jobs: set -euo pipefail if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then tag="${INPUT_TAG_NAME}" - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.sha')" - type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" --jq '.object.type')" + # One read of the ref, then one of the tag object: asking twice can + # pair a SHA from before a tag move with a type from after it. + ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" + sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" # An annotated tag points at a tag object, not the commit it names. if [[ "${type}" == "tag" ]]; then - type="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.type')" - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" + tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")" + sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${tag_json}" | jq -r '.object.type')" fi if [[ "${type}" != "commit" ]]; then echo "tag ${tag} does not resolve to a commit (got ${type})" >&2 @@ -194,8 +198,11 @@ jobs: BUILT_SHA: ${{ needs.resolve.outputs.sha }} run: | set -euo pipefail - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.sha')" - type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.type')" + # Single read of the ref, as in resolve: two requests can pair a SHA + # from one side of a tag move with a type from the other. + ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" + sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" if [[ "${type}" == "tag" ]]; then sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" fi @@ -249,8 +256,11 @@ jobs: BUILT_SHA: ${{ needs.resolve.outputs.sha }} run: | set -euo pipefail - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.sha')" - type="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq '.object.type')" + # Single read of the ref, as in resolve: two requests can pair a SHA + # from one side of a tag move with a type from the other. + ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" + sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" if [[ "${type}" == "tag" ]]; then sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" fi diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index bf61456..9944bad 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -20,23 +20,16 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { if let Ok(value) = serde_json::from_str::(trimmed) { let mut names = BTreeSet::new(); collect_vm_names_from_json(&value, &mut names); - if !names.is_empty() { - return Ok(names); - } if let Some(output) = value.get("output").and_then(Value::as_str) { // The wrapper carries either the serialized listing or a rendered - // table. Decode it as JSON first, the way `parse_ssh_destinations` - // does: reading a serialized listing as text yields fragments of the - // JSON as VM names, and bootstrap would then recreate VMs it already - // has. + // table, and is read whether or not the outer object also listed + // something: skipping it when the outer had any entry would drop the + // rest, and bootstrap would recreate VMs that already exist. if let Ok(inner) = serde_json::from_str::(output.trim()) { collect_vm_names_from_json(&inner, &mut names); - // Whatever the listing held is the answer, including nothing. A - // wrapped `[]` or an error object handed to the text parser would - // come back as a VM named after the JSON itself. - return Ok(names); + } else if names.is_empty() { + return Ok(parse_vm_names_from_text(output)); } - return Ok(parse_vm_names_from_text(output)); } // A response that parsed as JSON has already been searched. Handing its // serialized form to the text parser would take `{"vms":[]}` apart into @@ -51,7 +44,12 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { Value::Array(items) => { for item in items { if let Some(name) = item.as_str() { - names.insert(name.to_string()); + // A bare string carries no field saying it is a VM, so an + // array like ["error", "quota exceeded"] would otherwise + // become inventory and suppress creating the real VM. + if is_vm_name(name) { + names.insert(name.to_string()); + } } else { collect_vm_names_from_json(item, names); } @@ -86,16 +84,14 @@ pub(super) fn parse_ssh_destinations(response: &str) -> BTreeMap return destinations; }; collect_ssh_destinations(&value, &mut destinations); - if destinations.is_empty() { - // Same wrapper `parse_vm_names` falls back to. When it holds a rendered - // table there is nothing to find and the caller keeps the hostname - // fallback; when it holds the serialized listing, the destinations are - // in there and are the authoritative ones. - if let Some(output) = value.get("output").and_then(Value::as_str) - && let Ok(inner) = serde_json::from_str::(output.trim()) - { - collect_ssh_destinations(&inner, &mut destinations); - } + // Same wrapper `parse_vm_names` reads, and read on the same terms: when it + // holds a rendered table there is nothing to find and the caller keeps the + // hostname fallback; when it holds the serialized listing, its destinations + // are authoritative, including for VMs the outer object did not mention. + if let Some(output) = value.get("output").and_then(Value::as_str) + && let Ok(inner) = serde_json::from_str::(output.trim()) + { + collect_ssh_destinations(&inner, &mut destinations); } destinations } diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 920ba22..6451c9c 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -14,7 +14,10 @@ use super::state::{ generated_kubeconfig_path, generated_token_path, read_regular_file, write_secret_file, }; use super::*; -use std::{collections::BTreeMap, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; #[test] fn parses_vm_names_from_json_array() { @@ -621,3 +624,28 @@ fn secret_writes_reject_a_symlinked_state_directory() { std::fs::remove_dir_all(&root).unwrap(); assert!(err.contains("not a real directory"), "unexpected: {err}"); } + +#[test] +fn mixed_outer_and_wrapped_listings_are_merged() { + let response = r#"{"vms":[{"vm_name":"outer","ssh_dest":"vm+outer@exe.dev"}],"output":"[{\"vm_name\":\"inner\",\"ssh_dest\":\"vm+inner@exe.dev\"}]"}"#; + let names = parse_vm_names(response).unwrap(); + assert!(names.contains("outer"), "outer missing: {names:?}"); + assert!(names.contains("inner"), "inner missing: {names:?}"); + + let destinations = parse_ssh_destinations(response); + assert_eq!(destinations.get("outer").unwrap(), "vm+outer@exe.dev"); + assert_eq!(destinations.get("inner").unwrap(), "vm+inner@exe.dev"); +} + +#[test] +fn bare_json_strings_must_look_like_vm_names() { + // Prose in a string array is rejected on shape. A single lowercase word is + // not: `error` is a valid VM name, and no shape test can tell it apart from + // one. The wrapper handling above is what keeps error payloads out of here. + let names = parse_vm_names(r#"["Error:", "quota exceeded", "VM", "vm-1"]"#).unwrap(); + assert_eq!(names, BTreeSet::from(["vm-1".to_string()])); + + let names = parse_vm_names(r#"["vm-1","vm-2"]"#).unwrap(); + assert_eq!(names.len(), 2); + assert!(names.contains("vm-1")); +} diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 4d95113..b585532 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -62,8 +62,15 @@ set_path_dep_version() { TARGETS=() APPLIED=0 REFRESHED=0 +LOCKFILE="" +LOCKFILE_CREATED=0 cleanup_staged() { local target + # Nothing registered yet: `${TARGETS[@]}` on an empty array is an unbound + # variable under `set -u`, and an early failure would exit through this. + if [[ "${#TARGETS[@]}" -eq 0 ]]; then + return + fi # An exit between applying the manifests and refreshing the lockfile — an error, # a Ctrl-C, or a terminated CI step — would otherwise leave the workspace on the # new version with a lockfile still on the old one, and drop the backups that @@ -72,6 +79,9 @@ cleanup_staged() { for target in "${TARGETS[@]}"; do [[ -f "$target.bak" ]] && mv "$target.bak" "$target" done + if [[ "$LOCKFILE_CREATED" -eq 1 ]]; then + rm -f "$LOCKFILE" + fi fi for target in "${TARGETS[@]}"; do rm -f "$target.tmp" "$target.bak" @@ -80,12 +90,26 @@ cleanup_staged() { trap cleanup_staged EXIT trap 'exit 1' INT TERM +# The staging and backup names are derived from the target, so anything already +# sitting at one of them would be written through (a symlink there redirects the +# rewrite outside the workspace) and then deleted by the cleanup below. +require_free_sibling() { + local sibling + for sibling in "$1.tmp" "$1.next" "$1.bak"; do + if [[ -e "$sibling" || -L "$sibling" ]]; then + echo "refusing to run: $sibling already exists; move it aside first" >&2 + exit 1 + fi + done +} + for member in "${MEMBERS[@]}"; do manifest="$REPO_ROOT/$member/Cargo.toml" if [[ ! -f "$manifest" ]]; then echo "workspace member has no manifest: $manifest" >&2 exit 1 fi + require_free_sibling "$manifest" TARGETS+=("$manifest") if ! set_package_version "$manifest" "$manifest.tmp"; then echo "no [package] version to replace in $manifest" >&2 @@ -98,6 +122,7 @@ if [[ ! -f "$ROOT_MANIFEST" ]]; then echo "workspace has no root manifest: $ROOT_MANIFEST" >&2 exit 1 fi +require_free_sibling "$ROOT_MANIFEST" TARGETS+=("$ROOT_MANIFEST") cp "$ROOT_MANIFEST" "$ROOT_MANIFEST.tmp" for key in "${PATH_DEP_KEYS[@]}"; do @@ -114,8 +139,14 @@ done # failure or interrupt there would otherwise leave a refreshed lockfile beside # restored manifests. LOCKFILE="$REPO_ROOT/Cargo.lock" +LOCKFILE_CREATED=0 if [[ -f "$LOCKFILE" ]]; then + require_free_sibling "$LOCKFILE" TARGETS+=("$LOCKFILE") +else + # Nothing to restore it to: a workspace that had no lockfile must not keep the + # one `cargo update` writes if the run does not finish. + LOCKFILE_CREATED=1 fi for target in "${TARGETS[@]}"; do diff --git a/scripts/release/sync-homebrew-tap.sh b/scripts/release/sync-homebrew-tap.sh index 8d00f23..e9b3e33 100755 --- a/scripts/release/sync-homebrew-tap.sh +++ b/scripts/release/sync-homebrew-tap.sh @@ -38,22 +38,19 @@ if [[ -z "$RELEASE_TAG" ]]; then RELEASE_TAG="$(gh release view --repo "$REPO_SLUG" --json tagName -q .tagName)" fi -VERSION="${RELEASE_TAG#v}" - # Every value below is interpolated into download URLs, local file paths, and # double-quoted Ruby strings in the formula. Validate them here rather than # escaping at each use: a stray quote, newline, or slash otherwise produces a # formula that generation reports as a success and Homebrew cannot parse. -SEMVER_NUM='(0|[1-9][0-9]*)' -SEMVER_PRE_ID="(${SEMVER_NUM}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" -SEMVER_RE="^${SEMVER_NUM}\.${SEMVER_NUM}\.${SEMVER_NUM}(-${SEMVER_PRE_ID}(\.${SEMVER_PRE_ID})*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$" - -if [[ ! "$VERSION" =~ $SEMVER_RE ]]; then - echo "release tag is not a semantic version: $RELEASE_TAG" >&2 - echo "Expected something like v0.1.11 or 1.2.3-rc.1+build.5." >&2 +# +# The tag goes through the same grammar the release workflow uses, which pins +# LC_ALL so its ASCII ranges do not depend on the caller's locale. +if ! VERSION="$("$SCRIPT_DIR/check-version.sh" "$RELEASE_TAG")"; then exit 1 fi +LC_ALL=C + if [[ ! "$REPO_SLUG" =~ ^[0-9A-Za-z._-]+/[0-9A-Za-z._-]+$ ]]; then echo "REPO_SLUG is not an owner/repo slug: $REPO_SLUG" >&2 exit 1 @@ -148,28 +145,25 @@ if [[ -L "$TAP_FORMULA_PATH" ]]; then exit 1 fi -# An explicit path gets the same confinement as a discovered one when there is a -# tap to confine it to; the file is created and truncated below either way. -if [[ -n "$TAP_REPO_PATH" && -d "$TAP_REPO_PATH" ]]; then - TAP_REPO_REAL="$(cd "$TAP_REPO_PATH" && pwd -P)" - FORMULA_PARENT="$(dirname "$TAP_FORMULA_PATH")" - mkdir -p "$FORMULA_PARENT" - FORMULA_PARENT_REAL="$(cd "$FORMULA_PARENT" && pwd -P)" - case "$FORMULA_PARENT_REAL/" in - "$TAP_REPO_REAL"/*) ;; - *) - echo "TAP_FORMULA_PATH resolves outside TAP_REPO_PATH:" >&2 - echo " formula: $FORMULA_PARENT_REAL" >&2 - echo " tap: $TAP_REPO_REAL" >&2 - exit 1 - ;; - esac -fi - -if [[ -z "$EXPLICIT_TAP_FORMULA_PATH" && -n "$TAP_REPO_PATH" && ! -d "$TAP_REPO_PATH" ]]; then - echo "TAP_REPO_PATH does not exist: $TAP_REPO_PATH" >&2 +# Confinement is required, not conditional: without a tap root to resolve against +# there is nothing bounding where the write below lands. +if [[ ! -d "$TAP_REPO_PATH" ]]; then + echo "TAP_REPO_PATH must be an existing tap checkout to write a formula into: $TAP_REPO_PATH" >&2 exit 1 fi +TAP_REPO_REAL="$(cd "$TAP_REPO_PATH" && pwd -P)" +FORMULA_PARENT="$(dirname "$TAP_FORMULA_PATH")" +mkdir -p "$FORMULA_PARENT" +FORMULA_PARENT_REAL="$(cd "$FORMULA_PARENT" && pwd -P)" +case "$FORMULA_PARENT_REAL/" in + "$TAP_REPO_REAL"/*) ;; + *) + echo "TAP_FORMULA_PATH resolves outside TAP_REPO_PATH:" >&2 + echo " formula: $FORMULA_PARENT_REAL" >&2 + echo " tap: $TAP_REPO_REAL" >&2 + exit 1 + ;; +esac WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT @@ -216,7 +210,15 @@ for platform in "${PLATFORMS[@]}"; do # extraction applies entries in order, so a later symlink or directory with # the same name is what ends up installed. if ! awk -v want="./$member" ' - $NF == want { seen++; if ($1 !~ /^-/) bad++ } + { + # For a symlink, tar prints "name -> target", so the last field is the + # target and a duplicate symlink shadowing a real file would go unseen. + entry = $0 + arrow = index(entry, " -> ") + if (arrow > 0) entry = substr(entry, 1, arrow - 1) + fields = split(entry, parts, /[ \t]+/) + if (parts[fields] == want) { seen++; if ($1 !~ /^-/) bad++ } + } END { exit (seen > 0 && bad == 0) ? 0 : 1 } ' "$WORK_DIR/members.txt"; then echo "$platform release archive member is missing or not a regular file: $member" >&2 @@ -239,11 +241,17 @@ quoted_list() { } mkdir -p "$(dirname "$TAP_FORMULA_PATH")" -cat > "$TAP_FORMULA_PATH" <` follows a symlink put +# there after the checks above, while rename replaces the entry itself. +FORMULA_STAGED="$(mktemp "$(dirname "$TAP_FORMULA_PATH")/.${FORMULA_NAME}.XXXXXX")" +cat > "$FORMULA_STAGED" < Date: Wed, 12 Aug 2026 23:24:42 +0800 Subject: [PATCH 13/18] docs: tell the skill which commands ask for confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard grew from a handful of commands to 37 over this branch, but the skill still named three of them, and its own examples ran commands that now prompt — including the token-generation example it tells an agent to use. An agent has no terminal, so those fail with `failed to read confirmation: IO error: not a terminal` before anything reaches exe.dev, with nothing in the skill explaining why or what to do. The reference now lists every guarded command by category, notes the two alias spellings and the read-only `integrations setup` forms that are exempt, and points at `is_dangerous` in core/src/shell.rs as the list to re-derive from. Triage covers the non-terminal failure, and the token examples pass `--yes` on that one command rather than leaving an agent to discover the prompt. SKILL.md states the rule instead of enumerating: confirm the action with the user, then rerun that single command with `--yes`, rather than adding it pre-emptively. Also corrects the archive contents, which omitted README.zh-CN.md; the doc, the release workflow, and the tap formula's install list now agree. --- skills/exedev-ctl/SKILL.md | 3 +- skills/exedev-ctl/references/exedev-ctl.md | 51 +++++++++++++++++----- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/skills/exedev-ctl/SKILL.md b/skills/exedev-ctl/SKILL.md index 94de074..0d5ae25 100644 --- a/skills/exedev-ctl/SKILL.md +++ b/skills/exedev-ctl/SKILL.md @@ -33,7 +33,8 @@ Check the current environment and scope before proposing changes: - Verify `EXE_DEV_API_KEY` is present for HTTPS `/exec` operations. - Use `exedev-ctl --json ls` to inspect current VMs. - Treat destructive VM actions as high risk. Require explicit confirmation before `rm`, bulk deletion, or operations that could lose disk state unless the user already asked for that exact action. -- Treat access grants as high risk too. `share add --root` and `share access allow ` give SSH, Terminal, and Shelley access, not web-only access; `billing credits buy` spends money. The CLI prompts for these unless `--yes` is passed. +- Know which commands prompt. The CLI asks for confirmation before deletions, access grants, credential creation, and spending; `references/exedev-ctl.md` lists them. `share add --root` and `share access allow ` give SSH, Terminal, and Shelley access rather than web-only, and `billing credits buy` spends money. +- The prompt needs a terminal. In a non-interactive session a guarded command fails with `failed to read confirmation: IO error: not a terminal` before anything is sent to exe.dev. That is the guard, not a bug: confirm the exact action with the user, then rerun that one command with `--yes`. Do not add `--yes` pre-emptively to commands that do not need it. - When a token returns `403`, inspect token permissions before assuming a VM or CLI bug. - When `/exec` returns `422`, surface the exe.dev command failure body. diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index 2d45b0d..f76f88c 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -18,9 +18,9 @@ exedev-clis--macos-arm64.tar.gz ``` Each archive contains both the `exedev-ctl` and `exedev-k8s` binaries, plus -`README.md`, `LICENSE`, `.env.example`, and `fleet.example.yaml`. Archive -member names carry a `./` prefix, so extract with `./exedev-ctl`, not -`exedev-ctl`. +`README.md`, `README.zh-CN.md`, `LICENSE`, `.env.example`, and +`fleet.example.yaml`. Archive member names carry a `./` prefix, so extract with +`./exedev-ctl`, not `exedev-ctl`. Manual install pattern: @@ -284,18 +284,46 @@ running as the same user. exe.dev offers no argument-free input path for these two commands, so treat the token as exposed locally and prefer short `--exp` values. +## Commands That Ask For Confirmation + +These prompt before running, on either transport, and the prompt needs a +terminal. In a non-interactive session they fail with +`failed to read confirmation: IO error: not a terminal` before anything reaches +exe.dev. Confirm the exact action with the user, then rerun that one command +with `--yes`. + +| Category | Commands | +|---|---| +| Deletion | `rm`, `tag -d`, `domain rm`, `pool delete`, `ssh-key remove`, `integrations remove`, `share remove`, `share remove-link`, `team remove` | +| Widening access | `share set-public`, `share add-link`, `share add --root`, `share access allow`, `share receive-email`, `grant-support-root`, `team add`, `team settings auto-join on`, `team settings vm-sharing` | +| Narrowing access | `share set-private`, `integrations detach` | +| Credentials | `ssh-key add`, `ssh-key generate-api-key`, `integrations add`, `integrations attach`, `integrations setup`, `integrations edit`, `team auth set` | +| Domains | `domain add` | +| Spending | `billing capacity`, `billing credits buy`, `billing payment remove`, `billing payment default`, `pool new` | +| Ownership | `team role`, `team transfer`, `team disable` | + +`integrations setup --list` and `--verify` are exempt, since they only +report what is connected. The `share add-share-link` and `share remove-share-link` +aliases are treated the same as the names above. Everything not listed runs +without a prompt. + +The list this table mirrors is `is_dangerous` in `core/src/shell.rs`; re-derive +it from there if a release adds commands. + ## Token Generation Helper -The `exedev-ctl` wrapper supports exe.dev token generation with `--label`, `--vm`, `--cmds`, and `--exp`: +The `exedev-ctl` wrapper supports exe.dev token generation with `--label`, +`--vm`, `--cmds`, and `--exp`. Creating a credential prompts, so a +non-interactive run needs `--yes` on that command: ```sh -exedev-ctl ssh-key generate-api-key --label automation --cmds "ls,new,whoami,share show,share port,domain add,domain ls,domain rm" --exp 30d +exedev-ctl --yes ssh-key generate-api-key --label automation --cmds "ls,new,whoami,share show,share port,domain add,domain ls,domain rm" --exp 30d ``` For a VM-scoped token accepted by the VM HTTPS proxy (not `/exec`): ```sh -exedev-ctl ssh-key generate-api-key --vm p1-a-1 --label deploy +exedev-ctl --yes ssh-key generate-api-key --vm p1-a-1 --label deploy ``` When `--cmds` is omitted, exe.dev grants the defaults: `help`, `ls`, `new`, `whoami`, `ssh-key list`, `share show`, `exe0-to-exe1`, `team`, and `team members`. Only command names are checked; flags like `--json` are always allowed. For destructive operations, include commands intentionally and narrowly, for example `rm`, `restart`, or `rename` only when needed. @@ -306,7 +334,10 @@ When a VM task fails: 1. Run `exedev-ctl whoami` or `exedev-ctl --json ls` to verify default SSH auth. 2. For HTTPS-specific failures, retry with `exedev-ctl --transport http whoami` or `exedev-ctl --transport http --json ls`. -3. If HTTP status is `403`, check token `cmds` permissions. -4. If HTTP status is `422`, read the exe.dev command failure body. -5. If interactive SSH or stdin is involved, use the SSH path. -6. If SSH or script transport fails, prefer direct `ssh .exe.xyz ...` checks to separate VM reachability from exe.dev API permissions. +3. If the error is `failed to read confirmation`, the command is one of the + guarded ones above and there is no terminal to answer on. Nothing was sent to + exe.dev. Confirm the action, then rerun it with `--yes`. +4. If HTTP status is `403`, check token `cmds` permissions. +5. If HTTP status is `422`, read the exe.dev command failure body. +6. If interactive SSH or stdin is involved, use the SSH path. +7. If SSH or script transport fails, prefer direct `ssh .exe.xyz ...` checks to separate VM reachability from exe.dev API permissions. From caff018215797b507fb86686ce9c3fc3d465e67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:32:46 +0800 Subject: [PATCH 14/18] docs: point SSH triage at the reported destination Triage step 7 told the reader to check a VM at `.exe.xyz`, which the same document already warns against three sections earlier: exe.dev reports the destination as `ssh_dest`, and it carries a `vm+@exe.dev` username on VMs whose hostname cannot route SSH. Following the step on such a VM fails, and the step exists precisely to tell reachability apart from API permissions, so the failure reads as the VM being down. SKILL.md gave the same advice for streamed and interactive work. Both now take the destination from `ssh_dest`, username included, and say what assembling the hostname costs. The purpose of the step is unchanged. --- skills/exedev-ctl/SKILL.md | 2 +- skills/exedev-ctl/references/exedev-ctl.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/skills/exedev-ctl/SKILL.md b/skills/exedev-ctl/SKILL.md index 0d5ae25..d2d7e86 100644 --- a/skills/exedev-ctl/SKILL.md +++ b/skills/exedev-ctl/SKILL.md @@ -56,7 +56,7 @@ The HTTPS endpoint has no stdin or pty, so these commands always use local SSH e - `exedev-ctl new --prompt /dev/stdin` - `exedev-ctl new --setup-script /dev/stdin` -For streamed scripts or interactive VM work, prefer direct VM SSH such as `ssh .exe.xyz ...` when the repo evidence shows it is the reliable path. +For streamed scripts or interactive VM work, prefer direct VM SSH when the repo evidence shows it is the reliable path. Take the destination from `ssh_dest` in `exedev-ctl --json ls`, with any username it carries; `.exe.xyz` is only the usual form and fails on VMs routed as `vm+@exe.dev`. ## More Detail diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index f76f88c..b658576 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -340,4 +340,14 @@ When a VM task fails: 4. If HTTP status is `403`, check token `cmds` permissions. 5. If HTTP status is `422`, read the exe.dev command failure body. 6. If interactive SSH or stdin is involved, use the SSH path. -7. If SSH or script transport fails, prefer direct `ssh .exe.xyz ...` checks to separate VM reachability from exe.dev API permissions. +7. If SSH or script transport fails, check the VM directly to separate VM + reachability from exe.dev API permissions. Use the destination exe.dev + reports, username included, rather than assembling a hostname: + + ```sh + dest="$(exedev-ctl --json ls | jq -r '.vms[] | select(.vm_name=="p1-a-1") | .ssh_dest')" + ssh "$dest" uptime + ``` + + Building `.exe.xyz` instead fails on VMs whose route needs the + `vm+@exe.dev` form, which looks like the VM being unreachable. From f1bdea737c13a2801733e6e57e9bc4b55b005ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:52:10 +0800 Subject: [PATCH 15/18] fix: validate cluster targets earlier and stop clobbering concurrent state Two bootstraps of the same new cluster each generated a token and overwrote the other, so the server and the agents could end up with different credentials. The token is now linked into place, which fails rather than replaces when the name is taken, and the loser adopts the winner's. Existing-mode target validation ran inside bootstrap_k3s, after VMs had been created, so a mismatched K3S_URL left the fleet's VMs behind when it aborted. It now runs before anything is created. That check also discarded the URL scheme, accepting `http://host:6443` as the HTTPS endpoint a kubeconfig names, and accepted any number of trailing dots on the host. apply_node_metadata treated an unreadable node list as an empty one and carried on, reporting a reconciliation it had not done. It now fails. Stale labels get the same treatment taints already had: `exedev.dev/*` labels the plan dropped are removed rather than left beside the desired ones, and status stops reporting `labels=ok` while they are still there. An object that named a VM stopped both parsers from descending into listings nested under it, dropping those entries and their SSH destinations. The SSH retry keyed on the wrapper's exit marker, but its absence does not mean the script never ran: output arriving without the marker means it did. Retries are now limited to exchanges that produced no output at all. `--endpoint` was passed to reqwest with the API key attached regardless of scheme, so `--endpoint http://elsewhere/collect` sent the key and the command in the clear; it must now be https. `share receive-email` forwarded any state string rather than the documented on/off. set-version.sh validated only `-f` on the assembled manifest path, so a symlinked member directory redirected the rewrite, its staging file and its backup outside the workspace. Member directories and manifests must now be real. `.next` is also cleaned up, having been staged but not removed. The workflow peels a tag object naming another tag object rather than giving up after one level. --- .github/workflows/release.yml | 29 +++++++---- cli/src/cli.rs | 1 + core/src/client.rs | 11 +++- k8s_cli/src/manager/mod.rs | 84 +++++++++++++++++++++++-------- k8s_cli/src/manager/parsing.rs | 13 ++--- k8s_cli/src/manager/process.rs | 5 +- k8s_cli/src/manager/state.rs | 74 ++++++++++++++++++++------- k8s_cli/src/manager/tests.rs | 92 ++++++++++++++++++++++++++++++---- scripts/release/set-version.sh | 20 +++++--- 9 files changed, 256 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54e5e45..fc13ed1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,12 +49,15 @@ jobs: ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" - # An annotated tag points at a tag object, not the commit it names. - if [[ "${type}" == "tag" ]]; then + # An annotated tag points at a tag object, not the commit it names, + # and a tag object can name another one. Bounded so a cycle cannot + # spin here. + for _ in 1 2 3 4 5; do + [[ "${type}" == "tag" ]] || break tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")" sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')" type="$(printf '%s' "${tag_json}" | jq -r '.object.type')" - fi + done if [[ "${type}" != "commit" ]]; then echo "tag ${tag} does not resolve to a commit (got ${type})" >&2 exit 1 @@ -203,9 +206,13 @@ jobs: ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" - if [[ "${type}" == "tag" ]]; then - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" - fi + # Peeled the same way as resolve, including a tag naming another tag. + for _ in 1 2 3 4 5; do + [[ "${type}" == "tag" ]] || break + tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")" + sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${tag_json}" | jq -r '.object.type')" + done if [[ "${sha}" != "${BUILT_SHA}" ]]; then echo "tag ${RELEASE_TAG} now points at ${sha}, but these archives were built from ${BUILT_SHA}" >&2 exit 1 @@ -261,9 +268,13 @@ jobs: ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')" type="$(printf '%s' "${ref_json}" | jq -r '.object.type')" - if [[ "${type}" == "tag" ]]; then - sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}" --jq '.object.sha')" - fi + # Peeled the same way as resolve, including a tag naming another tag. + for _ in 1 2 3 4 5; do + [[ "${type}" == "tag" ]] || break + tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")" + sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')" + type="$(printf '%s' "${tag_json}" | jq -r '.object.type')" + done if [[ "${sha}" != "${BUILT_SHA}" ]]; then echo "tag ${RELEASE_TAG} moved to ${sha} while publishing; the release does not match ${BUILT_SHA}" >&2 exit 1 diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 3e1f3e2..9bd24ca 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -290,6 +290,7 @@ pub(crate) struct ShareRemoveLinkCmd { pub(crate) struct ShareReceiveEmailCmd { pub(crate) vm: String, /// One of on, off. + #[arg(value_parser = ["on", "off"])] pub(crate) state: Option, /// Restrict who the VM may email: all, known, owner, none. #[arg(long)] diff --git a/core/src/client.rs b/core/src/client.rs index d51d1fe..7f5c08c 100644 --- a/core/src/client.rs +++ b/core/src/client.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use reqwest::StatusCode; use thiserror::Error; @@ -38,6 +38,15 @@ impl ExeDevClient { } pub async fn exec(&self, command: &str) -> Result { + // Every request carries the API key as a bearer token, so the endpoint has + // to be HTTPS: `--endpoint http://elsewhere/collect` would otherwise send + // the key and the command in the clear to whatever the caller named. + if !self.endpoint.to_ascii_lowercase().starts_with("https://") { + bail!( + "endpoint must be an https:// URL to carry the API key, got {}", + self.endpoint + ); + } let response = self .http .post(&self.endpoint) diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 4245f78..e96e9be 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -75,8 +75,11 @@ async fn run_bootstrap(endpoint: &str, yes: bool, cmd: BootstrapCmd) -> Result<( ensure_tool("kubectl").await?; let ts_authkey = require_env(TS_AUTHKEY_ENV)?; if cmd.mode == ClusterMode::Existing { - require_env(K3S_URL_ENV)?; + let k3s_url = require_env(K3S_URL_ENV)?; require_env(K3S_TOKEN_ENV)?; + // Before anything is created: a target that does not match leaves the + // fleet's VMs behind when the bootstrap aborts further down. + ensure_kubectl_targets_cluster(cmd.kubeconfig.as_deref(), &k3s_url).await?; } let include_control_plane = cmd.mode == ClusterMode::New; let inventory = fetch_inventory(endpoint).await?; @@ -289,7 +292,14 @@ async fn print_kubernetes_status(plan: &FleetPlan, kubeconfig: Option<&Path>) -> let labels_ok = expected .labels .iter() - .all(|(key, value)| actual.labels.get(key) == Some(value)); + .all(|(key, value)| actual.labels.get(key) == Some(value)) + // An owned label the plan dropped is drift too, so matching the + // desired ones is not on its own enough to report ok. + && actual + .labels + .keys() + .filter(|key| key.starts_with(NODE_LABEL_PREFIX)) + .all(|key| expected.labels.contains_key(key)); // A node the plan no longer isolates still counts as drift while it // carries one of this tool's taints, so `None` cannot simply be `ok`. let owned_taints = actual @@ -410,11 +420,7 @@ async fn bootstrap_k3s( ClusterMode::Existing => { let k3s_url = require_env(K3S_URL_ENV)?; let token = require_env(K3S_TOKEN_ENV)?; - // Workers are joined to K3S_URL, while the labels, taints, and - // manifests that follow go wherever kubectl points. Without this they - // could be applied to an unrelated cluster, so the two are required to - // be the same cluster before anything is changed. - ensure_kubectl_targets_cluster(kubeconfig_arg, &k3s_url).await?; + // Already checked in run_bootstrap, before any VM was created. for node in plan .nodes .iter() @@ -449,7 +455,7 @@ async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str "kubectl has no cluster server configured; pass --kubeconfig or set KUBECONFIG so {K3S_URL_ENV} and kubectl agree" ); } - if !same_cluster_endpoint(server, k3s_url) { + if same_cluster_endpoint(server, k3s_url).is_none() { bail!( "kubectl points at {server} but {K3S_URL_ENV} is {k3s_url}; pass --kubeconfig for that cluster rather than labelling and deploying to another one" ); @@ -457,12 +463,20 @@ async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str Ok(()) } -/// Compares two endpoints by host and port, so an explicit `:6443` and the same +/// Compares two Kubernetes API endpoints, so an explicit `:6443` and the same /// URL without it are still the same cluster. -fn same_cluster_endpoint(left: &str, right: &str) -> bool { - fn parts(url: &str) -> (String, String) { - let without_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); +/// +/// The scheme is part of the identity: `http://host:6443` is not the HTTPS API +/// endpoint that `https://host:6443` names, and treating them as equal would let +/// a mistyped K3S_URL through the only check made before workers are joined. +fn same_cluster_endpoint(left: &str, right: &str) -> Option<(String, String, String)> { + fn parts(url: &str) -> Option<(String, String, String)> { + let (scheme, rest) = url.split_once("://").unwrap_or(("https", url)); + let scheme = scheme.to_ascii_lowercase(); + if scheme != "https" { + return None; + } + let authority = rest.split('/').next().unwrap_or(rest); let (host, port) = match authority.rsplit_once(':') { Some((host, port)) if !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) => @@ -471,14 +485,16 @@ fn same_cluster_endpoint(left: &str, right: &str) -> bool { } _ => (authority, "6443"), }; - // The root label is trimmed after the port is split off, so `host.:6443` - // and `host:6443` are the same authority. - ( - host.trim_end_matches('.').to_ascii_lowercase(), - port.to_string(), - ) + // One trailing dot is the DNS root label and is dropped after the port is + // split off; more than one is not a hostname at all. + let host = host.strip_suffix('.').unwrap_or(host); + if host.is_empty() || host.ends_with('.') { + return None; + } + Some((scheme, host.to_ascii_lowercase(), port.to_string())) } - parts(left) == parts(right) + let left = parts(left)?; + parts(right).filter(|right| *right == left) } async fn install_tailscale(targets: &SshTargets, vm: &str, authkey: &str) -> Result<()> { @@ -716,10 +732,13 @@ async fn apply_node_metadata( include_control_plane: bool, kubeconfig: Option<&Path>, ) -> Result<()> { + // Not defaulted to empty: without the current nodes the stale labels and + // taints below cannot be found, and reporting success would claim a + // reconciliation that did not happen. let actual = kubectl_capture(kubeconfig, &["get", "nodes", "-o", "json"]) .await .and_then(|output| parse_kubernetes_nodes(&output)) - .unwrap_or_default(); + .context("failed to read current node labels and taints")?; for node in plan.bootstrap_nodes(include_control_plane) { let mut label_args = vec!["label".into(), "node".into(), node.name.clone()]; label_args.extend( @@ -727,6 +746,10 @@ async fn apply_node_metadata( .iter() .map(|(key, value)| format!("{key}={value}")), ); + // Removing a label the plan dropped uses the same `key-` form as taints; + // sending only the current key=value pairs would leave the old ones on + // the node while status reported the desired ones as present. + label_args.extend(stale_owned_labels(&actual, &node.name, &node.labels)); label_args.push("--overwrite".into()); kubectl_run_owned(kubeconfig, label_args).await?; @@ -759,6 +782,25 @@ async fn apply_node_metadata( Ok(()) } +/// Label removal arguments (`key-`) for this tool's labels that the plan dropped. +fn stale_owned_labels( + nodes: &BTreeMap, + name: &str, + desired: &BTreeMap, +) -> Vec { + nodes + .get(name) + .map(|node| { + node.labels + .keys() + .filter(|key| key.starts_with(NODE_LABEL_PREFIX)) + .filter(|key| !desired.contains_key(*key)) + .map(|key| format!("{key}-")) + .collect() + }) + .unwrap_or_default() +} + /// Taint removal arguments (`key-`) for this tool's taints that the plan dropped. /// /// Ownership is the `exedev.dev/` prefix, so taints set by anything else are left diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 9944bad..68ad846 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -59,9 +59,11 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { for key in VM_NAME_KEYS { if let Some(name) = object.get(key).and_then(Value::as_str) { names.insert(name.to_string()); - return; + break; } } + // Naming a VM does not rule out carrying more of them: returning here + // dropped every entry nested under an object that had both. for key in ["vms", "items", "data"] { if let Some(child) = object.get(key) { collect_vm_names_from_json(child, names); @@ -107,11 +109,10 @@ fn collect_ssh_destinations(value: &Value, destinations: &mut BTreeMap Result { } return Ok(token); } + create_k3s_token(&path) +} + +/// Generates the cluster token, or adopts the one another run created first. +/// +/// Writing it outright would let two bootstraps of the same cluster each +/// generate a token, clobber the other, and hand the server and the agents +/// different credentials. The staged file is linked into place instead, which +/// fails rather than replaces when the name is already taken, so whoever loses +/// the race reads the winner's token. +fn create_k3s_token(path: &Path) -> Result { + prepare_secret_parent(path)?; let token = random_token(); - write_secret_file(&path, &token)?; - Ok(token) + let staged = stage_secret(path, &token)?; + match fs::hard_link(&staged, path) { + Ok(()) => { + let _ = fs::remove_file(&staged); + Ok(token) + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(&staged); + read_secret_file(path).map(|text| text.trim().to_string()) + } + Err(err) => { + let _ = fs::remove_file(&staged); + Err(err).with_context(|| format!("failed to create {}", path.display())) + } + } } /// Writes a kubeconfig or cluster token so it is never readable by anyone else, @@ -109,20 +134,35 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { /// The rename also replaces a symlink rather than following one planted at a /// caller-supplied `--kubeconfig` path. pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { - if let Some(parent) = path + prepare_secret_parent(path)?; + let staged = stage_secret(path, contents)?; + if let Err(err) = fs::rename(&staged, path) { + let _ = fs::remove_file(&staged); + return Err(err).with_context(|| format!("failed to replace {}", path.display())); + } + Ok(()) +} + +fn prepare_secret_parent(path: &Path) -> Result<()> { + let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - // Everything below writes by name inside this directory, so a symlinked - // component would place the secret wherever it points. Only the directories - // this tool creates are checked; a caller-supplied --kubeconfig path is the - // caller's own choice of destination. - if parent.starts_with(STATE_DIR) { - ensure_real_directories(parent)?; - } + else { + return Ok(()); + }; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + // Secrets are written by name inside this directory, so a symlinked component + // would place them wherever it points. Only the directories this tool creates + // are checked; a caller-supplied --kubeconfig path is the caller's own choice + // of destination. + if parent.starts_with(STATE_DIR) { + ensure_real_directories(parent)?; } + Ok(()) +} + +/// Writes `contents` to a fresh 0600 file beside `path` and returns its path. +fn stage_secret(path: &Path, contents: &str) -> Result { let staged = staging_path(path); let write = || -> Result<()> { let mut file = fs::OpenOptions::new() @@ -141,11 +181,7 @@ pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { let _ = fs::remove_file(&staged); return Err(err); } - if let Err(err) = fs::rename(&staged, path) { - let _ = fs::remove_file(&staged); - return Err(err).with_context(|| format!("failed to replace {}", path.display())); - } - Ok(()) + Ok(staged) } /// A staging name that cannot be guessed ahead of the write. diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 6451c9c..33151d2 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -513,21 +513,22 @@ fn distinct_cluster_names_get_distinct_state_directories() { assert_eq!(generated_token_path("a/b"), generated_token_path("a/b")); } +fn is_same_cluster(left: &str, right: &str) -> bool { + same_cluster_endpoint(left, right).is_some() +} + #[test] fn cluster_endpoints_compare_by_host_and_port() { - assert!(same_cluster_endpoint( + assert!(is_same_cluster( "https://100.64.0.1:6443", "https://100.64.0.1:6443" )); - assert!(same_cluster_endpoint( - "https://k3s.example", - "k3s.example:6443" - )); - assert!(!same_cluster_endpoint( + assert!(is_same_cluster("https://k3s.example", "k3s.example:6443")); + assert!(!is_same_cluster( "https://100.64.0.1:6443", "https://100.64.0.2:6443" )); - assert!(!same_cluster_endpoint( + assert!(!is_same_cluster( "https://100.64.0.1:6443", "https://100.64.0.1:7443" )); @@ -563,12 +564,12 @@ fn an_empty_token_file_is_refused() { #[test] fn trailing_dot_hosts_compare_equal_with_an_explicit_port() { - assert!(same_cluster_endpoint( + assert!(is_same_cluster( "https://k3s.example.:6443", "https://k3s.example:6443" )); - assert!(same_cluster_endpoint("https://k3s.example.", "k3s.example")); - assert!(!same_cluster_endpoint( + assert!(is_same_cluster("https://k3s.example.", "k3s.example")); + assert!(!is_same_cluster( "https://k3s.example.:6443", "https://other.example:6443" )); @@ -649,3 +650,74 @@ fn bare_json_strings_must_look_like_vm_names() { assert_eq!(names.len(), 2); assert!(names.contains("vm-1")); } + +#[test] +fn cluster_endpoints_require_a_matching_scheme() { + // http:// is not the HTTPS API endpoint the kubeconfig names. + assert!(!is_same_cluster( + "https://cluster.example:6443", + "http://cluster.example:6443" + )); + assert!(!is_same_cluster( + "ssh://cluster.example:6443", + "https://cluster.example:6443" + )); + // More than one trailing dot is not a hostname. + assert!(!is_same_cluster( + "https://k3s.example...:6443", + "https://k3s.example:6443" + )); +} + +#[test] +fn nested_listings_survive_an_object_that_also_names_a_vm() { + let response = r#"{"vm_name":"outer","ssh_dest":"vm+outer@exe.dev", + "vms":[{"vm_name":"inner","ssh_dest":"vm+inner@exe.dev"}]}"#; + let names = parse_vm_names(response).unwrap(); + assert!( + names.contains("outer") && names.contains("inner"), + "{names:?}" + ); + let destinations = parse_ssh_destinations(response); + assert_eq!(destinations.get("outer").unwrap(), "vm+outer@exe.dev"); + assert_eq!(destinations.get("inner").unwrap(), "vm+inner@exe.dev"); +} + +#[test] +fn stale_owned_labels_are_scheduled_for_removal() { + let nodes = parse_kubernetes_nodes( + r#"{"items":[{"metadata":{"name":"vm-1","labels":{ + "exedev.dev/pool":"blue","exedev.dev/task":"old", + "kubernetes.io/hostname":"vm-1"}},"spec":{}}]}"#, + ) + .unwrap(); + let mut desired = BTreeMap::new(); + desired.insert("exedev.dev/pool".to_string(), "blue".to_string()); + + // Only our own dropped label is retired; the node's own label is untouched. + assert_eq!( + stale_owned_labels(&nodes, "vm-1", &desired), + vec!["exedev.dev/task-".to_string()] + ); +} + +#[test] +fn a_losing_concurrent_token_creation_adopts_the_winner() { + let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + let previous_dir = std::env::current_dir().unwrap(); + let dir = std::env::temp_dir().join(format!("exedev-k8s-tokrace-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_current_dir(&dir).unwrap(); + unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + + let first = read_or_create_k3s_token("c1"); + // A second run of the same cluster must not mint a competing credential. + let second = read_or_create_k3s_token("c1"); + + std::env::set_current_dir(&previous_dir).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + let (first, second) = (first.unwrap(), second.unwrap()); + assert!(!first.is_empty()); + assert_eq!(first, second); +} diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index b585532..080086d 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -84,7 +84,7 @@ cleanup_staged() { fi fi for target in "${TARGETS[@]}"; do - rm -f "$target.tmp" "$target.bak" + rm -f "$target.tmp" "$target.next" "$target.bak" done } trap cleanup_staged EXIT @@ -104,9 +104,17 @@ require_free_sibling() { } for member in "${MEMBERS[@]}"; do - manifest="$REPO_ROOT/$member/Cargo.toml" - if [[ ! -f "$manifest" ]]; then - echo "workspace member has no manifest: $manifest" >&2 + member_dir="$REPO_ROOT/$member" + # A symlinked member directory puts the rewrite, its staging file and its + # backup wherever the link points, which the sibling checks below cannot see + # because every one of those paths is inside it. + if [[ -L "$member_dir" || ! -d "$member_dir" ]]; then + echo "workspace member is not a real directory: $member_dir" >&2 + exit 1 + fi + manifest="$member_dir/Cargo.toml" + if [[ -L "$manifest" || ! -f "$manifest" ]]; then + echo "workspace member has no regular manifest: $manifest" >&2 exit 1 fi require_free_sibling "$manifest" @@ -118,8 +126,8 @@ for member in "${MEMBERS[@]}"; do done ROOT_MANIFEST="$REPO_ROOT/Cargo.toml" -if [[ ! -f "$ROOT_MANIFEST" ]]; then - echo "workspace has no root manifest: $ROOT_MANIFEST" >&2 +if [[ -L "$ROOT_MANIFEST" || ! -f "$ROOT_MANIFEST" ]]; then + echo "workspace has no regular root manifest: $ROOT_MANIFEST" >&2 exit 1 fi require_free_sibling "$ROOT_MANIFEST" From 49b09590315913e27aae4662e0edec2cd72afe9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:16:01 +0800 Subject: [PATCH 16/18] fix: validate reported destinations and make state handling durable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exe.dev names its field `vm_name`, but the key list checked the generic `name` first, so a record carrying both was indexed under a display name and its destination attached to a node that does not exist. A nonempty but malformed `ssh_dest` — one carrying whitespace or control characters — was also passed to ssh verbatim, failing in a way that reads as the VM being unreachable; such a value is now left out so the `.exe.xyz` fallback still applies. Secret handling: the K3S_TOKEN branch read through a path that never tightened permissions, so a token file left at 0644 stayed that way; reads did not check that the state directory was real, so a symlinked `.exedev-k8s` had an outside file adopted as the cluster credential; and only the staged file was flushed, not the directory entry the rename or link created, so a crash could lose the name a returned-successful write had published. `--endpoint` was validated once for https but reqwest followed redirects, so the endpoint could bounce the command, and the token on a same-host hop, somewhere never checked. Redirects are no longer followed. Fleet validation accepted two pools expanding to the same VM name, which collapses two planned nodes into one with whichever role the plan visits last. `run_command` now dies with a cancelled future, ssh failures carry the VM they belong to, and the wrapper strips exactly its own trailing newline rather than every one, which was rewriting the stdout of commands ending in a blank line. check-version.sh treated an explicitly empty argument as no argument and validated the ambient RELEASE_TAG instead. set-version.sh takes a lock, since two runs could otherwise interleave their moves and overwrite each other's backups. Tests: the process-global ones restore the working directory and environment through a guard rather than after their assertions, the permission-based failure test confirms the denial it depends on instead of assuming it, and the losing-writer branch of token creation is now exercised deliberately rather than by two sequential calls that never reach it. --- core/src/client.rs | 8 +- k8s_cli/src/fleet.rs | 27 +++- k8s_cli/src/manager/parsing.rs | 35 ++++- k8s_cli/src/manager/process.rs | 14 +- k8s_cli/src/manager/state.rs | 46 ++++-- k8s_cli/src/manager/tests.rs | 231 +++++++++++++++++++------------ scripts/release/check-version.sh | 9 +- scripts/release/set-version.sh | 15 ++ 8 files changed, 264 insertions(+), 121 deletions(-) diff --git a/core/src/client.rs b/core/src/client.rs index 7f5c08c..5aafa59 100644 --- a/core/src/client.rs +++ b/core/src/client.rs @@ -33,7 +33,13 @@ impl ExeDevClient { Self { endpoint, token, - http: reqwest::Client::new(), + // Redirects are not followed: the endpoint is checked for https once, + // and a 307 from there would otherwise resend the command, and the + // bearer token on a same-host hop, to somewhere never validated. + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), } } diff --git a/k8s_cli/src/fleet.rs b/k8s_cli/src/fleet.rs index 234e626..d716bdb 100644 --- a/k8s_cli/src/fleet.rs +++ b/k8s_cli/src/fleet.rs @@ -1,6 +1,10 @@ use anyhow::{Context, Result, bail}; use serde::Deserialize; -use std::{collections::BTreeMap, fs, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; const DEFAULT_IMAGE: &str = "exeuntu"; @@ -122,8 +126,12 @@ impl FleetFile { pub(crate) fn load(path: &Path) -> Result { let text = fs::read_to_string(path) .with_context(|| format!("failed to read fleet file {}", path.display()))?; - let fleet = serde_yaml::from_str::(&text) - .with_context(|| format!("failed to parse fleet file {}", path.display()))?; + Self::from_yaml_str(&text) + .with_context(|| format!("failed to load fleet file {}", path.display())) + } + + pub(crate) fn from_yaml_str(text: &str) -> Result { + let fleet = serde_yaml::from_str::(text).context("failed to parse fleet file")?; fleet.validate()?; Ok(fleet) } @@ -181,6 +189,19 @@ impl FleetFile { bail!("sparePools.{pool_name}.cpu must be greater than 0"); } } + // Names are assembled from prefixes and indices, so two pools can produce + // the same one. Bootstrap keys every VM by name: a duplicate silently + // collapses two planned nodes into one and gives it whichever role and + // pool the plan visits last. + let mut seen = BTreeSet::new(); + for node in self.to_plan().nodes { + if !seen.insert(node.name.clone()) { + bail!( + "fleet produces two VMs named {}; change a vmPrefix so every node has its own name", + node.name + ); + } + } Ok(()) } diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 68ad846..dc3a32a 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -9,8 +9,12 @@ pub(super) struct KubernetesNode { pub(super) taints: BTreeSet, } -/// JSON keys that can hold a VM name, most specific first. -const VM_NAME_KEYS: [&str; 5] = ["name", "vm", "vmname", "vmName", "vm_name"]; +/// JSON keys that can hold a VM name, most authoritative first. +/// +/// exe.dev names the field `vm_name`; the rest are older or generic spellings. +/// Checking `name` first would index a record carrying both under whatever the +/// display name happens to be, and attach its destination to the wrong node. +const VM_NAME_KEYS: [&str; 5] = ["vm_name", "vmName", "vmname", "name", "vm"]; pub(super) fn parse_vm_names(response: &str) -> Result> { let trimmed = response.trim(); @@ -132,14 +136,31 @@ fn ssh_destination_from_object(object: &serde_json::Map) -> Optio .map(str::trim) .filter(|value| !value.is_empty()) }; - if let Some(dest) = text("ssh_dest").or_else(|| text("sshDest")) { + if let Some(dest) = text("ssh_dest") + .or_else(|| text("sshDest")) + .filter(|dest| is_ssh_destination(dest)) + { return Some(dest.to_string()); } let host = text("ssh_host").or_else(|| text("sshHost"))?; - match text("ssh_user").or_else(|| text("sshUser")) { - Some(user) => Some(format!("{user}@{host}")), - None => Some(host.to_string()), - } + let destination = match text("ssh_user").or_else(|| text("sshUser")) { + Some(user) => format!("{user}@{host}"), + None => host.to_string(), + }; + is_ssh_destination(&destination).then_some(destination) +} + +/// Whether a reported destination is something ssh can be handed as one target. +/// +/// A value carrying whitespace or control characters is not a destination, and +/// passing it on produces an ssh failure that reads as the VM being unreachable. +/// Leaving it out instead keeps the `.exe.xyz` fallback, which usually works. +fn is_ssh_destination(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && !value + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control()) } pub(super) fn parse_vm_names_from_text(text: &str) -> BTreeSet { diff --git a/k8s_cli/src/manager/process.rs b/k8s_cli/src/manager/process.rs index bdbaf58..3eee14d 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -143,7 +143,9 @@ pub(super) async fn remote_command_output( let wrapped_script = remote_status_script(vm, script); let args = remote_ssh_args(&targets.dest(vm)); let refs = args.iter().map(String::as_str).collect::>(); - let output = capture_remote_ssh_output(&refs, &wrapped_script).await?; + let output = capture_remote_ssh_output(&refs, &wrapped_script) + .await + .with_context(|| format!("ssh to {vm} failed"))?; parse_remote_command_output(vm, output) } @@ -185,6 +187,8 @@ pub(super) async fn run_command(program: &str, args: &[&str], stdout: Stdio) -> .stdin(Stdio::null()) .stdout(stdout) .stderr(Stdio::inherit()) + // Cancelling this future must not leave the child behind. + .kill_on_drop(true) .status() .await .with_context(|| format!("failed to run {program}"))?; @@ -356,7 +360,13 @@ pub(super) fn parse_remote_stdout(vm: &str, stdout: &str) -> Result<(String, i32 let marker_start = stdout .rfind(REMOTE_EXIT_PREFIX) .with_context(|| format!("remote command on {vm} did not report an exit status"))?; - let command_stdout = stdout[..marker_start].trim_end_matches('\n').to_string(); + // Exactly the newline the wrapper prints before the marker: trimming every + // trailing newline would rewrite the stdout of a command that ends in a blank + // line. + let command_stdout = stdout[..marker_start] + .strip_suffix('\n') + .unwrap_or(&stdout[..marker_start]) + .to_string(); let status_text = stdout[marker_start + REMOTE_EXIT_PREFIX.len()..] .lines() .next() diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 2f40de2..c4544a7 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -64,6 +64,13 @@ fn fnv1a(bytes: &[u8]) -> u64 { pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { let path = generated_token_path(cluster_name); + // Before any read follows it: a symlinked `.exedev-k8s` or cluster directory + // would otherwise have an outside file adopted as this cluster's credential. + if let Some(parent) = path.parent() + && parent.exists() + { + ensure_real_directories(parent)?; + } if let Ok(token) = env::var(K3S_TOKEN_ENV) { // An exported but empty value would otherwise become the cluster // credential for the server and every agent. @@ -71,7 +78,7 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { bail!("{K3S_TOKEN_ENV} is set but empty"); } if path.exists() { - let file_token = read_regular_file(&path)?; + let file_token = read_secret_file(&path)?; if file_token.trim() != token { write_secret_file(&path, &token) .with_context(|| format!("failed to update {}", path.display()))?; @@ -103,13 +110,15 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { /// different credentials. The staged file is linked into place instead, which /// fails rather than replaces when the name is already taken, so whoever loses /// the race reads the winner's token. -fn create_k3s_token(path: &Path) -> Result { +pub(super) fn create_k3s_token(path: &Path) -> Result { prepare_secret_parent(path)?; let token = random_token(); let staged = stage_secret(path, &token)?; match fs::hard_link(&staged, path) { Ok(()) => { + let sync = sync_parent_dir(path); let _ = fs::remove_file(&staged); + sync?; Ok(token) } Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { @@ -140,7 +149,26 @@ pub(super) fn write_secret_file(path: &Path, contents: &str) -> Result<()> { let _ = fs::remove_file(&staged); return Err(err).with_context(|| format!("failed to replace {}", path.display())); } - Ok(()) + sync_parent_dir(path) +} + +/// Flushes the directory entry a rename or link just created. +/// +/// `sync_all` on the staged file persists its contents, not the name it was +/// published under, so a crash could otherwise leave a bootstrapped cluster whose +/// token this tool no longer has. +fn sync_parent_dir(path: &Path) -> Result<()> { + let parent = match path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + Some(parent) => parent.to_path_buf(), + None => PathBuf::from("."), + }; + let dir = fs::File::open(&parent) + .with_context(|| format!("failed to open {} to flush it", parent.display()))?; + dir.sync_all() + .with_context(|| format!("failed to flush {}", parent.display())) } fn prepare_secret_parent(path: &Path) -> Result<()> { @@ -245,18 +273,6 @@ fn random_suffix() -> String { .collect() } -/// Reads a file that must be a real file this tool wrote. -/// -/// `fs::read_to_string` follows symlinks, so an entry swapped for a link to -/// another readable file would have that file's contents adopted as the cluster -/// token. Inspecting the path and then reading it would still leave a gap for the -/// entry to be swapped in between, so the contents are read through one handle -/// and that handle is confirmed to be the same object the no-follow inspection -/// accepted. -pub(super) fn read_regular_file(path: &Path) -> Result { - Ok(open_regular_file(path)?.0) -} - fn open_regular_file(path: &Path) -> Result<(String, fs::File)> { let before = fs::symlink_metadata(path) .with_context(|| format!("failed to inspect {}", path.display()))?; diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 33151d2..1f3197f 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -1,4 +1,4 @@ -use super::super::fleet::NodeSpec; +use super::super::fleet::{FleetFile, NodeSpec}; use super::kubectl::kubeconfig_args; use super::parsing::{ parse_kubernetes_nodes, parse_ssh_destinations, parse_vm_names, parse_vm_names_from_text, @@ -11,7 +11,8 @@ use super::scripts::{ k3s_agent_install_command, k3s_server_install_command, tailscale_install_command, }; use super::state::{ - generated_kubeconfig_path, generated_token_path, read_regular_file, write_secret_file, + create_k3s_token, generated_kubeconfig_path, generated_token_path, read_secret_file, + write_secret_file, }; use super::*; use std::{ @@ -412,10 +413,18 @@ fn secret_write_failure_leaves_the_previous_secret_intact() { write_secret_file(&path, "good-token").unwrap(); // A read-only directory fails the staged create, standing in for any I/O - // error partway through replacing the file. + // error partway through replacing the file. Root and mode-ignoring + // filesystems can still write there, so the denial is confirmed rather than + // assumed before the outcome is asserted. std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); - let err = write_secret_file(&path, "replacement").unwrap_err(); + let denied = std::fs::File::create(dir.join(".probe")).is_err(); + let result = denied.then(|| write_secret_file(&path, "replacement")); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let Some(result) = result else { + std::fs::remove_dir_all(&dir).unwrap(); + return; + }; + let err = result.unwrap_err(); assert!(err.to_string().contains("failed to create")); assert_eq!(std::fs::read_to_string(&path).unwrap(), "good-token"); @@ -439,7 +448,7 @@ fn symlinked_token_is_rejected_rather_than_followed() { let token_path = dir.join("k3s-token"); std::os::unix::fs::symlink(&secret_elsewhere, &token_path).unwrap(); - let err = read_regular_file(&token_path).unwrap_err(); + let err = read_secret_file(&token_path).unwrap_err(); assert!(err.to_string().contains("not a regular file")); std::fs::remove_dir_all(&dir).unwrap(); @@ -538,91 +547,70 @@ fn cluster_endpoints_compare_by_host_and_port() { /// directory and consults the environment, both of which are process-wide. static STATE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Enters a scratch directory and restores everything on the way out. +/// +/// Restoring after the assertions would leave the whole process parked in a +/// deleted directory when one of them fails, which breaks unrelated tests rather +/// than just this one. +struct StateSandbox { + _guard: std::sync::MutexGuard<'static, ()>, + previous_dir: std::path::PathBuf, + dir: std::path::PathBuf, +} + +impl StateSandbox { + fn enter(label: &str) -> Self { + let guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + let previous_dir = std::env::current_dir().unwrap(); + let dir = std::env::temp_dir().join(format!("exedev-k8s-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_current_dir(&dir).unwrap(); + unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + Self { + _guard: guard, + previous_dir, + dir, + } + } +} + +impl Drop for StateSandbox { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.previous_dir); + unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + let _ = std::fs::remove_dir_all(&self.dir); + } +} + #[test] fn an_empty_token_file_is_refused() { - let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - let previous_dir = std::env::current_dir().unwrap(); - let dir = std::env::temp_dir().join(format!("exedev-k8s-emptytok-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::env::set_current_dir(&dir).unwrap(); - unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + let _sandbox = StateSandbox::enter("emptytok"); write_secret_file(&generated_token_path("c1"), " \n").unwrap(); - let result = read_or_create_k3s_token("c1"); - - // A fresh cluster still generates one; only an empty file is refused. - let generated = read_or_create_k3s_token("c2"); - - std::env::set_current_dir(&previous_dir).unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - - let err = result.unwrap_err().to_string(); + let err = read_or_create_k3s_token("c1").unwrap_err().to_string(); assert!(err.contains("is empty"), "unexpected error: {err}"); - assert!(!generated.unwrap().is_empty()); -} - -#[test] -fn trailing_dot_hosts_compare_equal_with_an_explicit_port() { - assert!(is_same_cluster( - "https://k3s.example.:6443", - "https://k3s.example:6443" - )); - assert!(is_same_cluster("https://k3s.example.", "k3s.example")); - assert!(!is_same_cluster( - "https://k3s.example.:6443", - "https://other.example:6443" - )); -} - -#[test] -fn stale_owned_taints_are_scheduled_for_removal() { - let mut nodes = BTreeMap::new(); - nodes.insert( - "vm-1".to_string(), - parse_kubernetes_nodes( - r#"{"items":[{"metadata":{"name":"vm-1"},"spec":{"taints":[ - {"key":"exedev.dev/pool","value":"blue","effect":"NoSchedule"}, - {"key":"node.kubernetes.io/unreachable","value":"","effect":"NoExecute"} - ]}}]}"#, - ) - .unwrap() - .remove("vm-1") - .unwrap(), - ); - // Dropping the isolation removes our taint and leaves Kubernetes' own alone. - assert_eq!( - stale_owned_taints(&nodes, "vm-1", None), - vec!["exedev.dev/pool-".to_string()] - ); - // Keeping the same key is not stale. - assert!(stale_owned_taints(&nodes, "vm-1", Some("exedev.dev/pool=blue:NoSchedule")).is_empty()); - // Switching keys retires the previous one. - assert_eq!( - stale_owned_taints(&nodes, "vm-1", Some("exedev.dev/role=x:NoSchedule")), - vec!["exedev.dev/pool-".to_string()] - ); + // A fresh cluster still generates one; only an empty file is refused. + assert!(!read_or_create_k3s_token("c2").unwrap().is_empty()); } #[test] fn secret_writes_reject_a_symlinked_state_directory() { - // The directory is read under the lock: another test holding it has the - // process chdir'd into a directory it is about to delete. - let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - let previous_dir = std::env::current_dir().unwrap(); - let root = std::env::temp_dir().join(format!("exedev-k8s-statelink-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(root.join("elsewhere")).unwrap(); - std::fs::create_dir_all(&root).unwrap(); - std::env::set_current_dir(&root).unwrap(); + let sandbox = StateSandbox::enter("statelink"); + std::fs::create_dir_all(sandbox.dir.join("elsewhere")).unwrap(); std::os::unix::fs::symlink("elsewhere", ".exedev-k8s").unwrap(); - let result = write_secret_file(&generated_token_path("c1"), "secret"); + let err = write_secret_file(&generated_token_path("c1"), "secret") + .unwrap_err() + .to_string(); + assert!(err.contains("not a real directory"), "unexpected: {err}"); - std::env::set_current_dir(&previous_dir).unwrap(); - let err = result.unwrap_err().to_string(); - std::fs::remove_dir_all(&root).unwrap(); + // The read path refuses it too, rather than adopting whatever it points at. + std::fs::write(sandbox.dir.join("elsewhere/k3s-token"), "someone-elses").unwrap(); + std::fs::create_dir_all(sandbox.dir.join("elsewhere/c1")).unwrap(); + std::fs::write(sandbox.dir.join("elsewhere/c1/k3s-token"), "someone-elses").unwrap(); + let err = read_or_create_k3s_token("c1").unwrap_err().to_string(); assert!(err.contains("not a real directory"), "unexpected: {err}"); } @@ -703,21 +691,80 @@ fn stale_owned_labels_are_scheduled_for_removal() { #[test] fn a_losing_concurrent_token_creation_adopts_the_winner() { - let _guard = STATE_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - let previous_dir = std::env::current_dir().unwrap(); - let dir = std::env::temp_dir().join(format!("exedev-k8s-tokrace-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::env::set_current_dir(&dir).unwrap(); - unsafe { std::env::remove_var(K3S_TOKEN_ENV) }; + let _sandbox = StateSandbox::enter("tokrace"); + let path = generated_token_path("c1"); + + // Stand in for the process that won the race: the file exists by the time + // this one tries to link its own token into place, which is the branch a + // second sequential call would never reach. + write_secret_file(&path, "winner-token").unwrap(); + let adopted = create_k3s_token(&path).unwrap(); + assert_eq!(adopted, "winner-token"); + + // And the loser left nothing behind. + let staged = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")) + .count(); + assert_eq!(staged, 0); +} - let first = read_or_create_k3s_token("c1"); - // A second run of the same cluster must not mint a competing credential. - let second = read_or_create_k3s_token("c1"); +#[test] +fn vm_name_wins_over_a_generic_display_name() { + let response = r#"[{"name":"display-name","vm_name":"authoritative","ssh_dest":"vm+authoritative@exe.dev"}]"#; + // exe.dev's own field decides which node this record is, so the destination + // cannot be filed under a display name that belongs to nothing. + assert_eq!( + parse_vm_names(response).unwrap(), + BTreeSet::from(["authoritative".to_string()]) + ); + let destinations = parse_ssh_destinations(response); + assert_eq!( + destinations.get("authoritative").unwrap(), + "vm+authoritative@exe.dev" + ); + assert!(!destinations.contains_key("display-name")); +} - std::env::set_current_dir(&previous_dir).unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - let (first, second) = (first.unwrap(), second.unwrap()); - assert!(!first.is_empty()); - assert_eq!(first, second); +#[test] +fn malformed_destinations_fall_back_to_the_hostname() { + let destinations = parse_ssh_destinations( + r#"{"vms":[ + {"vm_name":"spaced","ssh_dest":"vm-1.exe.xyz other-arg"}, + {"vm_name":"controlled","ssh_dest":"vm-1.exe.xyz\ttab"}, + {"vm_name":"good","ssh_dest":"vm+good@exe.dev"} + ]}"#, + ); + // A value ssh cannot take as one target is not a destination; leaving it out + // keeps the usable `.exe.xyz` fallback. + assert!(!destinations.contains_key("spaced")); + assert!(!destinations.contains_key("controlled")); + assert_eq!(destinations.get("good").unwrap(), "vm+good@exe.dev"); + let targets = SshTargets::new(destinations); + assert_eq!(targets.dest("spaced"), "spaced.exe.xyz"); +} + +#[test] +fn duplicate_generated_vm_names_are_rejected() { + // Both the control plane and the task expand to `node-1`. + let err = FleetFile::from_yaml_str( + r#" +cluster: + name: dup + controlPlane: + nodes: 1 + vmPrefix: node +projects: + project1: + tasks: + a: + nodes: 1 + replicas: 1 + vmPrefix: node +"#, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("two VMs named node-1"), "unexpected: {err}"); } diff --git a/scripts/release/check-version.sh b/scripts/release/check-version.sh index 1e24cf1..b0eefb6 100755 --- a/scripts/release/check-version.sh +++ b/scripts/release/check-version.sh @@ -8,7 +8,14 @@ set -euo pipefail # the point it is resolved, before four matrix builds check out and install a # toolchain only to fail on the same string. -VERSION="${1:-${RELEASE_TAG:-}}" +# `${1:-...}` would treat an explicitly passed empty tag as no argument at all and +# validate RELEASE_TAG instead, reporting success for a version the caller never +# asked about. +if [[ $# -ge 1 ]]; then + VERSION="$1" +else + VERSION="${RELEASE_TAG:-}" +fi if [[ -z "$VERSION" ]]; then echo "usage: $(basename "$0") " >&2 diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 080086d..56b3165 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -59,6 +59,16 @@ set_path_dep_version() { # two versions whenever a later member or the lockfile refresh failed, which is # worse than not running at all: the build then reports a version mismatch rather # than the actual failure. +# `mkdir` is the atomic create-or-fail primitive available everywhere this runs. +# Two concurrent invocations would otherwise both pass the sibling checks below, +# interleave their moves, and overwrite each other's backups so neither could be +# rolled back. +LOCK_DIR="$REPO_ROOT/.set-version.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "another set-version.sh is running (or $LOCK_DIR is stale); remove it if not" >&2 + exit 1 +fi + TARGETS=() APPLIED=0 REFRESHED=0 @@ -66,6 +76,7 @@ LOCKFILE="" LOCKFILE_CREATED=0 cleanup_staged() { local target + release_lock # Nothing registered yet: `${TARGETS[@]}` on an empty array is an unbound # variable under `set -u`, and an early failure would exit through this. if [[ "${#TARGETS[@]}" -eq 0 ]]; then @@ -87,6 +98,10 @@ cleanup_staged() { rm -f "$target.tmp" "$target.next" "$target.bak" done } + +release_lock() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} trap cleanup_staged EXIT trap 'exit 1' INT TERM From 74073e5aa5a2b80eff20e3498f114d371b950a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:24:19 +0800 Subject: [PATCH 17/18] fix: reserve generated labels and stop guessing inventory from prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExeDevClient::new` swallowed a builder failure and fell back to `Client::new()`, which follows redirects — the exact behaviour the builder was configured to prevent. It returns a Result now and both callers propagate it. set-version.sh released its lock at the top of cleanup, so a second run could start while the first was still restoring backups. The lock is now released last, on every path. The K3S_TOKEN branch compared a trimmed file against an untrimmed variable, rewriting the file on every run when the value carried a newline, and passed the untrimmed value to the server and the agents. It is normalized once. A response that is not JSON is no longer read as a table. exe.dev answers /exec with JSON, so a non-JSON body is an error page, and turning its words into VM names made a planned VM look like it already existed. A rendered table inside the `output` wrapper is still read, and now merges with outer records instead of being dropped whenever the outer object named anything. Taint reconciliation excluded stale taints by key, so a key kept with a different effect ended up carrying both: `--overwrite` does not clear the other effect. Stale taints are compared whole and removed before the desired one is applied. Fleet files can no longer supply the four labels the tool generates, which let a worker present itself as a control-plane node, and their label keys and values are checked against the Kubernetes grammar during validation rather than by kubectl after every VM has been created and bootstrapped. Only the generated keys are reserved, not the whole prefix: the repo's own fixtures use other `exedev.dev/*` keys for their own bookkeeping. --- cli/src/lib.rs | 2 +- core/src/client.rs | 23 +++++---- k8s_cli/src/fleet.rs | 87 +++++++++++++++++++++++++++++++ k8s_cli/src/manager/mod.rs | 38 ++++++++------ k8s_cli/src/manager/parsing.rs | 13 +++-- k8s_cli/src/manager/state.rs | 22 ++++---- k8s_cli/src/manager/tests.rs | 94 +++++++++++++++++++++++++++++++++- scripts/release/set-version.sh | 4 +- 8 files changed, 240 insertions(+), 43 deletions(-) diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 7107d31..a065c99 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -45,7 +45,7 @@ async fn run_cli(cli: cli::Cli) -> Result<()> { let api_key = env::var(API_KEY_ENV) .with_context(|| format!("missing {API_KEY_ENV}; export an exe.dev HTTPS API key first"))?; - let response = ExeDevClient::new(cli.endpoint, api_key) + let response = ExeDevClient::new(cli.endpoint, api_key)? .exec(&command_string) .await?; output::print_response(&response, cli.json)?; diff --git a/core/src/client.rs b/core/src/client.rs index 5aafa59..9c08daa 100644 --- a/core/src/client.rs +++ b/core/src/client.rs @@ -29,18 +29,21 @@ pub struct ExeDevClient { } impl ExeDevClient { - pub fn new(endpoint: String, token: String) -> Self { - Self { + pub fn new(endpoint: String, token: String) -> Result { + // Redirects are not followed: the endpoint is checked for https once, and + // a 307 from there would otherwise resend the command, and the bearer + // token on a same-host hop, to somewhere never validated. A build failure + // is reported rather than silently swapped for a client that does follow + // them. + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .context("failed to build the exe.dev HTTPS client")?; + Ok(Self { endpoint, token, - // Redirects are not followed: the endpoint is checked for https once, - // and a 307 from there would otherwise resend the command, and the - // bearer token on a same-host hop, to somewhere never validated. - http: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap_or_else(|_| reqwest::Client::new()), - } + http, + }) } pub async fn exec(&self, command: &str) -> Result { diff --git a/k8s_cli/src/fleet.rs b/k8s_cli/src/fleet.rs index d716bdb..fe19fdf 100644 --- a/k8s_cli/src/fleet.rs +++ b/k8s_cli/src/fleet.rs @@ -122,6 +122,73 @@ pub(crate) struct FleetPlan { pub(crate) nodes: Vec, } +/// The subset of the Kubernetes label grammar kubectl accepts on the command line. +fn is_label_key(key: &str) -> bool { + let (prefix, name) = match key.split_once('/') { + Some((prefix, name)) => (Some(prefix), name), + None => (None, key), + }; + if let Some(prefix) = prefix + && (prefix.is_empty() || prefix.len() > 253 || !is_dns_subdomain(prefix)) + { + return false; + } + is_label_value(name) && !name.is_empty() && name.len() <= 63 +} + +fn is_dns_subdomain(value: &str) -> bool { + !value.is_empty() + && value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +fn is_label_value(value: &str) -> bool { + if value.is_empty() { + return true; + } + value.len() <= 63 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') + && value.starts_with(|ch: char| ch.is_ascii_alphanumeric()) + && value.ends_with(|ch: char| ch.is_ascii_alphanumeric()) +} + +/// The labels `to_plan` sets itself. Other keys under the tool's prefix stay +/// available to fleet files, which use them for their own bookkeeping. +const GENERATED_LABEL_KEYS: [&str; 4] = [ + "exedev.dev/role", + "exedev.dev/pool", + "exedev.dev/project", + "exedev.dev/task", +]; + +impl FleetFile { + /// Every label a fleet file supplies, with where it came from. + fn user_labels(&self) -> Vec<(String, &BTreeMap)> { + let mut sources = Vec::new(); + for (project_name, project) in &self.projects { + for (task_name, task) in &project.tasks { + sources.push(( + format!("projects.{project_name}.tasks.{task_name}"), + &task.labels, + )); + } + } + for (pool_name, pool) in &self.spare_pools { + sources.push((format!("sparePools.{pool_name}"), &pool.labels)); + } + sources + } +} + impl FleetFile { pub(crate) fn load(path: &Path) -> Result { let text = fs::read_to_string(path) @@ -189,6 +256,26 @@ impl FleetFile { bail!("sparePools.{pool_name}.cpu must be greater than 0"); } } + // Labels reach `kubectl label` untouched after the fleet is provisioned, so + // a bad key is otherwise discovered only once VMs exist and are + // bootstrapped. The tool's own prefix is reserved: a worker declaring + // `exedev.dev/role: control-plane` would be represented as one. + for (source, labels) in self.user_labels() { + for (key, value) in labels { + if GENERATED_LABEL_KEYS.contains(&key.as_str()) { + bail!( + "{source} sets {key}, which exedev-k8s generates; a worker declaring it could present itself as another role or pool" + ); + } + if !is_label_key(key) { + bail!("{source} sets an invalid Kubernetes label key: {key}"); + } + if !is_label_value(value) { + bail!("{source} sets an invalid Kubernetes label value for {key}: {value}"); + } + } + } + // Names are assembled from prefixes and indices, so two pools can produce // the same one. Bootstrap keys every VM by name: a duplicate silently // collapses two planned nodes into one and gives it whichever role and diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index e96e9be..43b4710 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -50,8 +50,9 @@ const KUBERNETES_API_WAIT_ATTEMPTS: usize = 24; const KUBERNETES_NODE_WAIT_ATTEMPTS: usize = 30; const KUBERNETES_WAIT_DELAY: Duration = Duration::from_secs(5); const LOCAL_K8S_API_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(3); -/// Labels and taints under this prefix are this tool's to reconcile. -const NODE_LABEL_PREFIX: &str = "exedev.dev/"; +/// Labels and taints under this prefix are this tool's to reconcile, and a fleet +/// file may not supply them. +pub(crate) const NODE_LABEL_PREFIX: &str = "exedev.dev/"; pub(crate) async fn run(cli: K8sCli) -> Result<()> { match cli.command { K8sCommands::Plan(cmd) => run_plan(&cli.endpoint, cmd).await, @@ -172,7 +173,7 @@ fn load_plan(path: &Path) -> Result { fn exe_client(endpoint: &str) -> Result { let api_key = env::var(API_KEY_ENV) .with_context(|| format!("missing {API_KEY_ENV}; export an exe.dev HTTPS API key first"))?; - Ok(ExeDevClient::new(endpoint.to_string(), api_key)) + ExeDevClient::new(endpoint.to_string(), api_key) } /// The exe.dev VMs this account can see, and how to reach each over SSH. @@ -753,6 +754,17 @@ async fn apply_node_metadata( label_args.push("--overwrite".into()); kubectl_run_owned(kubeconfig, label_args).await?; + // Removals first: a pool changed to unisolated would otherwise keep its old + // NoSchedule, and a key kept with a different effect would end up carrying + // both, since `key-` clears every effect for that key. + for stale in stale_owned_taints(&actual, &node.name, node.taint.as_deref()) { + kubectl_run_owned( + kubeconfig, + vec!["taint".into(), "node".into(), node.name.clone(), stale], + ) + .await?; + } + if let Some(taint) = &node.taint { kubectl_run_owned( kubeconfig, @@ -766,18 +778,6 @@ async fn apply_node_metadata( ) .await?; } - - // Applying the desired taint says nothing about the one before it. A pool - // changed to unisolated would keep its old NoSchedule and stay unschedulable - // while the plan says otherwise, so taints this tool owns and no longer - // wants are removed. - for stale in stale_owned_taints(&actual, &node.name, node.taint.as_deref()) { - kubectl_run_owned( - kubeconfig, - vec!["taint".into(), "node".into(), node.name.clone(), stale], - ) - .await?; - } } Ok(()) } @@ -810,16 +810,20 @@ fn stale_owned_taints( name: &str, desired: Option<&str>, ) -> Vec { - let desired_key = desired.and_then(taint_key); nodes .get(name) .map(|node| { node.taints .iter() + // Compared whole, not by key: the same key with another effect is + // a different taint, and leaving it would keep the node more + // restricted than the plan asks. + .filter(|taint| Some(taint.as_str()) != desired) .filter_map(|taint| taint_key(taint)) .filter(|key| key.starts_with(NODE_LABEL_PREFIX)) - .filter(|key| Some(*key) != desired_key) .map(|key| format!("{key}-")) + .collect::>() + .into_iter() .collect() }) .unwrap_or_default() diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index dc3a32a..da2961a 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; @@ -31,8 +31,10 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { // rest, and bootstrap would recreate VMs that already exist. if let Ok(inner) = serde_json::from_str::(output.trim()) { collect_vm_names_from_json(&inner, &mut names); - } else if names.is_empty() { - return Ok(parse_vm_names_from_text(output)); + } else { + // A rendered table is merged like a serialized one; skipping it + // when the outer object already named something dropped every row. + names.extend(parse_vm_names_from_text(output)); } } // A response that parsed as JSON has already been searched. Handing its @@ -40,7 +42,10 @@ pub(super) fn parse_vm_names(response: &str) -> Result> { // a VM named after the JSON itself; an empty list is simply empty. return Ok(names); } - Ok(parse_vm_names_from_text(trimmed)) + // Not JSON at all. exe.dev answers `/exec` with JSON, so this is an error page + // or a transport failure rather than a listing; reading it as a table invents + // VMs out of prose and makes a planned VM look like it already exists. + bail!("exe.dev returned a response that is not JSON: {trimmed}") } fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index c4544a7..7f5d079 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -72,19 +72,23 @@ pub(super) fn read_or_create_k3s_token(cluster_name: &str) -> Result { ensure_real_directories(parent)?; } if let Ok(token) = env::var(K3S_TOKEN_ENV) { + // Normalized once: comparing a trimmed file against an untrimmed variable + // rewrote the file on every run when the value carried a newline, and the + // untrimmed value went on to the server and the agents. + let token = token.trim().to_string(); // An exported but empty value would otherwise become the cluster // credential for the server and every agent. - if token.trim().is_empty() { + if token.is_empty() { bail!("{K3S_TOKEN_ENV} is set but empty"); } - if path.exists() { - let file_token = read_secret_file(&path)?; - if file_token.trim() != token { - write_secret_file(&path, &token) - .with_context(|| format!("failed to update {}", path.display()))?; - } - } else { - write_secret_file(&path, &token)?; + let stored = path + .exists() + .then(|| read_secret_file(&path)) + .transpose()? + .map(|text| text.trim().to_string()); + if stored.as_deref() != Some(token.as_str()) { + write_secret_file(&path, &token) + .with_context(|| format!("failed to update {}", path.display()))?; } return Ok(token); } diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 1f3197f..ad4e41e 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -500,7 +500,12 @@ fn error_and_status_text_is_not_taken_for_inventory() { .unwrap() .is_empty() ); - assert!(parse_vm_names("VM vm-1 is unavailable").unwrap().is_empty()); + // A body that is not JSON is not a listing at all: reporting it beats + // guessing an inventory out of it and deciding a planned VM already exists. + let err = parse_vm_names("VM vm-1 is unavailable") + .unwrap_err() + .to_string(); + assert!(err.contains("not JSON"), "unexpected: {err}"); let names = parse_vm_names_from_text("NAME STATUS\nvm-1 running\nnameserver stopped\n"); assert_eq!(names.len(), 2); assert!(names.contains("vm-1")); @@ -768,3 +773,90 @@ projects: .to_string(); assert!(err.contains("two VMs named node-1"), "unexpected: {err}"); } + +#[test] +fn wrapped_table_rows_merge_with_outer_records() { + let names = + parse_vm_names(r#"{"vms":[{"vm_name":"outer"}],"output":"NAME STATUS\nrow-1 running\n"}"#) + .unwrap(); + assert!( + names.contains("outer") && names.contains("row-1"), + "{names:?}" + ); +} + +#[test] +fn a_reused_taint_key_with_a_new_effect_is_retired_first() { + let nodes = parse_kubernetes_nodes( + r#"{"items":[{"metadata":{"name":"vm-1"},"spec":{"taints":[ + {"key":"exedev.dev/pool","value":"pool","effect":"PreferNoSchedule"} + ]}}]}"#, + ) + .unwrap(); + // Same key, different effect: `kubectl taint key=value:Effect --overwrite` + // leaves the other effect in place, so the key has to be cleared first. + assert_eq!( + stale_owned_taints(&nodes, "vm-1", Some("exedev.dev/pool=pool:NoSchedule")), + vec!["exedev.dev/pool-".to_string()] + ); + assert!( + stale_owned_taints( + &nodes, + "vm-1", + Some("exedev.dev/pool=pool:PreferNoSchedule") + ) + .is_empty() + ); +} + +#[test] +fn fleet_labels_are_validated_before_anything_is_created() { + let fleet = |labels: &str| { + format!( + r#" +cluster: + name: c + controlPlane: + nodes: 1 + vmPrefix: ctl +projects: + p1: + tasks: + a: + nodes: 1 + replicas: 1 + vmPrefix: w + labels: +{labels} +"# + ) + }; + let reserved = FleetFile::from_yaml_str(&fleet(" exedev.dev/role: control-plane")) + .unwrap_err() + .to_string(); + assert!( + reserved.contains("which exedev-k8s generates"), + "{reserved}" + ); + + let bad_key = FleetFile::from_yaml_str(&fleet(" \"bad key\": value")) + .unwrap_err() + .to_string(); + assert!( + bad_key.contains("invalid Kubernetes label key"), + "{bad_key}" + ); + + let bad_value = FleetFile::from_yaml_str(&fleet(" team: \"has space\"")) + .unwrap_err() + .to_string(); + assert!( + bad_value.contains("invalid Kubernetes label value"), + "{bad_value}" + ); + + // A label of the user's own is accepted, including one under the tool's + // prefix that the tool does not generate: the repo's own fixtures use those. + assert!(FleetFile::from_yaml_str(&fleet(" team: platform")).is_ok()); + assert!(FleetFile::from_yaml_str(&fleet(" exedev.dev/test-case: shared")).is_ok()); +} diff --git a/scripts/release/set-version.sh b/scripts/release/set-version.sh index 56b3165..31b350f 100755 --- a/scripts/release/set-version.sh +++ b/scripts/release/set-version.sh @@ -76,10 +76,10 @@ LOCKFILE="" LOCKFILE_CREATED=0 cleanup_staged() { local target - release_lock # Nothing registered yet: `${TARGETS[@]}` on an empty array is an unbound # variable under `set -u`, and an early failure would exit through this. if [[ "${#TARGETS[@]}" -eq 0 ]]; then + release_lock return fi # An exit between applying the manifests and refreshing the lockfile — an error, @@ -97,6 +97,8 @@ cleanup_staged() { for target in "${TARGETS[@]}"; do rm -f "$target.tmp" "$target.next" "$target.bak" done + # Last: another run starting mid-restore would see a half-rolled-back workspace. + release_lock } release_lock() { From c4e1d136fb0d831fe8652828788c1c4ba420ab94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:15:18 +0800 Subject: [PATCH 18/18] fix: repair the k3s pid check and the state digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `k3s_pidfile_alive` compared the recorded pid's `comm` against `k3s`, but the pid belongs to the backgrounded wrapper — `sudo` when the SSH user is not root, `nohup` otherwise — so it never matched. The agent wait loop would have burned all 30 iterations and reported `k3s agent did not stay running` against a healthy agent, and the server path would have started a second k3s on the next run. The full argument vector names k3s through those wrappers and still catches a pid reused by something else. The FNV-1a multiplier was 0x1000000001b3, one digit too long and 16x the actual prime, so the digest keeping two sanitized cluster names apart mixed far less than the comment claimed. Reference vectors now pin it. Sanitizing the state directory name relocated existing state: a cluster named `prod.example` would have started from an empty directory, minted a new token and installed a server its agents could not join. Its previous directory is moved across on first use, marked TODO for removal. `require_env` validated the trimmed value and returned the untrimmed one, so the same K3S_TOKEN yielded one credential in New mode and another in Existing mode, and a TS_AUTHKEY with a trailing newline reached `tailscale up` inside quotes. Bootstrap re-read `ls` immediately after creating VMs, when provisioning has not necessarily surfaced their destinations yet, so the flow that creates VMs was the one least likely to get them. The creation responses now supply them. `team settings auto-join` took any string while the guard matches only the literal `on`, so `ON` widened team membership without a prompt. `resize` and `cp` change the bill as directly as the commands already in the guard's spending category, which the skill publishes as a table. An empty response body is no longer read as an empty inventory, and a generic `name` field must look like a VM name before it becomes one; `vm_name` is still taken as given. Also: the dispatch tag is validated before it is interpolated into an API path, `same_cluster_endpoint` returns the bool its callers use, the readiness loop no longer carries two copies of its retry tail, and the removal of `new --command` is documented where the coverage lists are. --- .github/workflows/release.yml | 12 ++- cli/README.md | 4 + cli/README.zh-CN.md | 3 + cli/src/cli.rs | 1 + core/src/shell.rs | 10 +- k8s_cli/src/fleet.rs | 3 + k8s_cli/src/manager/mod.rs | 101 ++++++++++++--------- k8s_cli/src/manager/parsing.rs | 15 ++- k8s_cli/src/manager/process.rs | 5 + k8s_cli/src/manager/scripts.rs | 8 +- k8s_cli/src/manager/state.rs | 45 +++++++-- k8s_cli/src/manager/tests.rs | 54 ++++++++--- skills/exedev-ctl/references/exedev-ctl.md | 2 +- 13 files changed, 190 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc13ed1..afd6f7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,10 @@ jobs: set -euo pipefail if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then tag="${INPUT_TAG_NAME}" + # Validated before it is used, not after: the tag goes into an API path + # below, where `../../owner/repo/git/ref/tags/v1` would traverse to + # another repository and resolve a SHA there. + scripts/release/check-version.sh "${tag}" > /dev/null # One read of the ref, then one of the tag object: asking twice can # pair a SHA from before a tag move with a type from after it. ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")" @@ -66,11 +70,9 @@ jobs: tag="${GITHUB_REF_NAME}" sha="${GITHUB_SHA}" fi - # Rejected here on either path rather than in each matrix build, where - # set-version would fail four times over after the checkouts and - # toolchain setup. The push trigger only filters `v*`, so a pushed - # `v1.2` reaches this point too. - scripts/release/check-version.sh "${tag}" + # The push path only filters `v*`, so a pushed `v1.2` is rejected here + # rather than in each matrix build, after four checkouts and toolchains. + scripts/release/check-version.sh "${tag}" > /dev/null echo "tag=${tag}" >> "${GITHUB_OUTPUT}" echo "sha=${sha}" >> "${GITHUB_OUTPUT}" diff --git a/cli/README.md b/cli/README.md index 0c0ddb9..78e000f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -172,6 +172,10 @@ them, so a command that prompts server-side (`team disable`, still applies, because it selects the output format rather than changing what the command does. +`new --command` was removed: exe.dev no longer lists it among `new`'s options, +so forwarding it only produced a server-side error. If your account still accepts +it, `exedev-ctl exec -- new --command ...` sends it unchanged. + Two documented commands are intentionally left to `exec`. Both are one-time onboarding steps with no automation value, and both take a token as a positional argument or flag value, which a typed wrapper would not make any safer: diff --git a/cli/README.zh-CN.md b/cli/README.zh-CN.md index 2c8ece2..b88599f 100644 --- a/cli/README.zh-CN.md +++ b/cli/README.zh-CN.md @@ -168,6 +168,9 @@ arguments 原样发送,不会被注入任何 flag,因此会在服务端要 全局 `--yes` 仍然会跳过本 CLI 自身的确认提示;全局 `--json` 也仍然生效,因为它 选择的是输出格式,不改变命令本身的行为。 +`new --command` 已移除:exe.dev 的 `new` 选项列表中已无此项,转发只会得到服务端 +错误。如果你的账号仍接受它,可用 `exedev-ctl exec -- new --command ...` 原样发送。 + 有两个已文档化的 command 不提供 typed wrapper:它们都是一次性接入操作,没有 automation 价值,并且都以 argument 传递 token —— 包装成 typed wrapper 并不会让它 更安全。 diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 9bd24ca..421d425 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -488,6 +488,7 @@ pub(crate) struct TeamVmSharingCmd { #[derive(Debug, Args)] pub(crate) struct TeamAutoJoinCmd { /// One of on, off. + #[arg(value_parser = ["on", "off"])] pub(crate) value: String, } diff --git a/core/src/shell.rs b/core/src/shell.rs index 303f945..89e5f45 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -77,8 +77,12 @@ fn is_dangerous(command: &str) -> bool { "team settings vm-sharing", "team disable", "team settings auto-join on", - // Both sides of reserved capacity: creating a pool reserves it, and the - // list already prompts before `billing capacity` changes the subscription. + // Anything that changes what the account is billed for. `resize` and `cp` + // take effect immediately, and creating a pool reserves capacity, so they + // belong with the subscription commands below rather than outside the + // "spending" category the skill documents. + "resize", + "cp", "pool new", "pool delete", "billing capacity", @@ -178,6 +182,8 @@ mod tests { assert!(is_dangerous("share set-private mybox")); assert!(is_dangerous("domain add mybox app.example.com")); assert!(is_dangerous("pool new builders --cpus 16 --region fra")); + assert!(is_dangerous("resize mybox --cpu 64")); + assert!(is_dangerous("cp mybox mybox-2")); assert!(is_dangerous("billing payment default 4f1c2a9b")); assert!(is_dangerous("share receive-email mybox on")); assert!(!is_dangerous("ls")); diff --git a/k8s_cli/src/fleet.rs b/k8s_cli/src/fleet.rs index fe19fdf..f0bf68d 100644 --- a/k8s_cli/src/fleet.rs +++ b/k8s_cli/src/fleet.rs @@ -280,6 +280,9 @@ impl FleetFile { // the same one. Bootstrap keys every VM by name: a duplicate silently // collapses two planned nodes into one and gives it whichever role and // pool the plan visits last. + // Names come from `to_plan` rather than a second expansion here: a copy of + // the naming rules would eventually disagree with the plan it is meant to + // check. let mut seen = BTreeSet::new(); for node in self.to_plan().nodes { if !seen.insert(node.name.clone()) { diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 43b4710..a8b0486 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -88,7 +88,7 @@ async fn run_bootstrap(endpoint: &str, yes: bool, cmd: BootstrapCmd) -> Result<( confirm("Run this bootstrap plan?", yes)?; let client = exe_client(endpoint)?; - create_missing_vms( + let created = create_missing_vms( &client, &plan, include_control_plane, @@ -96,8 +96,12 @@ async fn run_bootstrap(endpoint: &str, yes: bool, cmd: BootstrapCmd) -> Result<( &cmd.fleet, ) .await?; - // Re-read the VM list so VMs created above contribute their SSH destination. - let inventory = fetch_inventory(endpoint).await?; + // Re-read the VM list, then let the creation responses win: provisioning is + // asynchronous, so a VM made moments ago may not carry a destination in `ls` + // yet, and falling back to the hostname is what the destination map exists to + // avoid. + let mut inventory = fetch_inventory(endpoint).await?; + inventory.ssh_targets.extend(created); let new_cluster_access = bootstrap_k3s( &plan, cmd.mode, @@ -338,32 +342,39 @@ async fn create_missing_vms( include_control_plane: bool, inventory: &VmInventory, fleet_path: &Path, -) -> Result<()> { +) -> Result> { + let mut created = BTreeMap::new(); for node in plan.bootstrap_nodes(include_control_plane) { if inventory.names.contains(&node.name) { continue; } let command = exe_new_command(node); println!("{} {command}", output::label("exe.dev:")); - if let Err(err) = client.exec(&command).await { - if is_vm_name_unavailable_error(&err, &node.name) { - println!( - "{} VM name {} is not available; verifying SSH access before continuing", - output::warn("exe.dev:"), - output::vm(&node.name) - ); - verify_vm_access(&inventory.ssh_targets, &node.name, fleet_path).await?; - println!( - "{} verified SSH access to {}; continuing", - output::success("exe.dev:"), - output::vm(&node.name) - ); - continue; + match client.exec(&command).await { + // The response describes the VM that was just made. Taking its + // destination from here does not depend on the next `ls` having caught + // up with provisioning. + Ok(response) => created.extend(parse_ssh_destinations(&response)), + Err(err) => { + if is_vm_name_unavailable_error(&err, &node.name) { + println!( + "{} VM name {} is not available; verifying SSH access before continuing", + output::warn("exe.dev:"), + output::vm(&node.name) + ); + verify_vm_access(&inventory.ssh_targets, &node.name, fleet_path).await?; + println!( + "{} verified SSH access to {}; continuing", + output::success("exe.dev:"), + output::vm(&node.name) + ); + continue; + } + return Err(err); } - return Err(err); } } - Ok(()) + Ok(created) } async fn bootstrap_k3s( @@ -456,7 +467,7 @@ async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str "kubectl has no cluster server configured; pass --kubeconfig or set KUBECONFIG so {K3S_URL_ENV} and kubectl agree" ); } - if same_cluster_endpoint(server, k3s_url).is_none() { + if !same_cluster_endpoint(server, k3s_url) { bail!( "kubectl points at {server} but {K3S_URL_ENV} is {k3s_url}; pass --kubeconfig for that cluster rather than labelling and deploying to another one" ); @@ -470,7 +481,7 @@ async fn ensure_kubectl_targets_cluster(kubeconfig: Option<&Path>, k3s_url: &str /// The scheme is part of the identity: `http://host:6443` is not the HTTPS API /// endpoint that `https://host:6443` names, and treating them as equal would let /// a mistyped K3S_URL through the only check made before workers are joined. -fn same_cluster_endpoint(left: &str, right: &str) -> Option<(String, String, String)> { +fn same_cluster_endpoint(left: &str, right: &str) -> bool { fn parts(url: &str) -> Option<(String, String, String)> { let (scheme, rest) = url.split_once("://").unwrap_or(("https", url)); let scheme = scheme.to_ascii_lowercase(); @@ -494,8 +505,10 @@ fn same_cluster_endpoint(left: &str, right: &str) -> Option<(String, String, Str } Some((scheme, host.to_ascii_lowercase(), port.to_string())) } - let left = parts(left)?; - parts(right).filter(|right| *right == left) + match (parts(left), parts(right)) { + (Some(left), Some(right)) => left == right, + _ => false, + } } async fn install_tailscale(targets: &SshTargets, vm: &str, authkey: &str) -> Result<()> { @@ -676,19 +689,17 @@ async fn wait_for_kubernetes_nodes( // cannot read, and giving up on the first one would spend none of the // retry window and report a parse error instead of the cluster state. Ok(output) => { - let nodes = match parse_kubernetes_nodes(&output) { - Ok(nodes) => nodes, - Err(err) => { - last_error = err.to_string(); - if attempt < KUBERNETES_NODE_WAIT_ATTEMPTS { - println!( - "{} Kubernetes nodes are not ready yet ({last_error}); retrying ({attempt}/{KUBERNETES_NODE_WAIT_ATTEMPTS})", - output::warn("waiting:") - ); - sleep(KUBERNETES_WAIT_DELAY).await; - } - continue; + // A probe that answers with something unreadable is retried like + // any other failed probe, through the shared tail below rather + // than a copy of it. + let Ok(nodes) = parse_kubernetes_nodes(&output).inspect_err(|err| { + last_error = err.to_string(); + }) else { + if attempt < KUBERNETES_NODE_WAIT_ATTEMPTS { + report_node_wait(attempt, &last_error); + sleep(KUBERNETES_WAIT_DELAY).await; } + continue; }; let missing = expected .iter() @@ -718,16 +729,20 @@ async fn wait_for_kubernetes_nodes( Err(err) => last_error = err.to_string(), } if attempt < KUBERNETES_NODE_WAIT_ATTEMPTS { - println!( - "{} Kubernetes nodes are not ready yet ({last_error}); retrying ({attempt}/{KUBERNETES_NODE_WAIT_ATTEMPTS})", - output::warn("waiting:") - ); + report_node_wait(attempt, &last_error); sleep(KUBERNETES_WAIT_DELAY).await; } } bail!("Kubernetes nodes did not become ready: {last_error}"); } +fn report_node_wait(attempt: usize, last_error: &str) { + println!( + "{} Kubernetes nodes are not ready yet ({last_error}); retrying ({attempt}/{KUBERNETES_NODE_WAIT_ATTEMPTS})", + output::warn("waiting:") + ); +} + async fn apply_node_metadata( plan: &FleetPlan, include_control_plane: bool, @@ -915,9 +930,13 @@ fn require_env(name: &str) -> Result { // A present-but-empty variable would otherwise pass this check and reach the // VM as `tailscale up --auth-key ''` or an empty k3s URL/token, failing only // after the plan was confirmed and VMs were created. - if value.trim().is_empty() { + let value = value.trim().to_string(); + if value.is_empty() { bail!("{name} is set but empty"); } + // Trimmed, not just checked trimmed: a token or auth key that keeps a trailing + // newline reaches the VM inside quotes and is rejected there, while the same + // variable read through read_or_create_k3s_token is trimmed. Ok(value) } diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index da2961a..bf0b613 100644 --- a/k8s_cli/src/manager/parsing.rs +++ b/k8s_cli/src/manager/parsing.rs @@ -16,10 +16,16 @@ pub(super) struct KubernetesNode { /// display name happens to be, and attach its destination to the wrong node. const VM_NAME_KEYS: [&str; 5] = ["vm_name", "vmName", "vmname", "name", "vm"]; +/// The keys that identify a record as a VM rather than merely naming something. +const AUTHORITATIVE_VM_NAME_KEYS: [&str; 3] = ["vm_name", "vmName", "vmname"]; + pub(super) fn parse_vm_names(response: &str) -> Result> { let trimmed = response.trim(); if trimmed.is_empty() { - return Ok(BTreeSet::new()); + // Not an empty listing: a truncated or dropped response would otherwise + // read as "this account has no VMs" and have bootstrap recreate all of + // them, or destroy report nothing to do. + bail!("exe.dev returned an empty response where a VM listing was expected"); } if let Ok(value) = serde_json::from_str::(trimmed) { let mut names = BTreeSet::new(); @@ -67,7 +73,12 @@ fn collect_vm_names_from_json(value: &Value, names: &mut BTreeSet) { Value::Object(object) => { for key in VM_NAME_KEYS { if let Some(name) = object.get(key).and_then(Value::as_str) { - names.insert(name.to_string()); + // `vm_name` says what it is; `name` on some other record — a + // team, a pool, an error object — does not, so it has to look + // like a VM name before it becomes inventory. + if AUTHORITATIVE_VM_NAME_KEYS.contains(&key) || is_vm_name(name) { + names.insert(name.to_string()); + } break; } } diff --git a/k8s_cli/src/manager/process.rs b/k8s_cli/src/manager/process.rs index 3eee14d..41d0414 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -51,6 +51,11 @@ impl SshTargets { Self(destinations) } + /// Adds destinations that take precedence over what is already known. + pub(super) fn extend(&mut self, destinations: BTreeMap) { + self.0.extend(destinations); + } + /// The destination reported by exe.dev, or the `.exe.xyz` hostname when /// exe.dev did not report one (for example a VM outside this account's `ls`). pub(super) fn dest(&self, vm: &str) -> String { diff --git a/k8s_cli/src/manager/scripts.rs b/k8s_cli/src/manager/scripts.rs index ff05563..f712393 100644 --- a/k8s_cli/src/manager/scripts.rs +++ b/k8s_cli/src/manager/scripts.rs @@ -59,8 +59,12 @@ k3s_pidfile_alive() { ''|*[!0-9]*) return 1 ;; esac ${SUDO} kill -0 "$k3s_recorded_pid" 2>/dev/null || return 1 - k3s_recorded_comm="$(${SUDO} ps -p "$k3s_recorded_pid" -o comm= 2>/dev/null || true)" - case "$k3s_recorded_comm" in + # The recorded pid is the backgrounded job, which is the `sudo`/`nohup`/`env` + # wrapper rather than k3s itself, so its comm never says k3s. The full argument + # vector does, and still tells an unrelated process that inherited the pid from + # the one this wrote down. + k3s_recorded_args="$(${SUDO} ps -p "$k3s_recorded_pid" -o args= 2>/dev/null || true)" + case "$k3s_recorded_args" in *k3s*) return 0 ;; *) return 1 ;; esac diff --git a/k8s_cli/src/manager/state.rs b/k8s_cli/src/manager/state.rs index 7f5d079..657d212 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -11,19 +11,48 @@ use std::{ const STATE_DIR: &str = ".exedev-k8s"; pub(super) fn generated_kubeconfig_path(cluster_name: &str) -> PathBuf { - Path::new(STATE_DIR) - .join(state_dir_name(cluster_name)) - .join("kubeconfig") + state_dir(cluster_name).join("kubeconfig") } pub(super) fn generated_token_path(cluster_name: &str) -> PathBuf { - Path::new(STATE_DIR) - .join(state_dir_name(cluster_name)) - .join("k3s-token") + state_dir(cluster_name).join("k3s-token") +} + +fn state_dir(cluster_name: &str) -> PathBuf { + let dir = Path::new(STATE_DIR).join(state_dir_name(cluster_name)); + adopt_legacy_state_dir(cluster_name, &dir); + dir +} + +/// TODO(remove after the next release): moves state written under the raw cluster +/// name to the sanitized directory. +/// +/// Without it a cluster named `prod.example` silently starts from an empty state +/// directory, mints a fresh token, and installs a server the existing agents +/// cannot join. Only an exact rename is attempted; anything else is left alone +/// for the operator to resolve. +fn adopt_legacy_state_dir(cluster_name: &str, current: &Path) { + let legacy = Path::new(STATE_DIR).join(cluster_name); + if legacy == current || current.exists() || !legacy.is_dir() { + return; + } + if fs::rename(&legacy, current).is_ok() { + eprintln!( + "note: moved cluster state from {} to {}", + legacy.display(), + current.display() + ); + } } /// Keeps a cluster name from reaching outside the state directory. /// +/// TODO(remove after the next release): a cluster whose name contains anything +/// outside `[A-Za-z0-9_-]` used its raw name as the directory before this, so its +/// kubeconfig and token are still at `.exedev-k8s//`. `adopt_legacy_state_dir` +/// moves them across on first use; delete it, and this note, once no such +/// directory is expected to exist. +/// /// The name comes from fleet.yaml, which only requires it to be non-empty, so /// `../../elsewhere` would otherwise place the token and kubeconfig outside /// `.exedev-k8s`. Anything that is not a plain name component is replaced. @@ -53,11 +82,11 @@ fn state_dir_name(cluster_name: &str) -> String { /// FNV-1a, spelled out so the directory a cluster uses never changes with the /// toolchain the way `DefaultHasher` would. -fn fnv1a(bytes: &[u8]) -> u64 { +pub(super) fn fnv1a(bytes: &[u8]) -> u64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in bytes { hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x1000_0000_01b3); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } hash } diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index ad4e41e..20d502f 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -11,7 +11,7 @@ use super::scripts::{ k3s_agent_install_command, k3s_server_install_command, tailscale_install_command, }; use super::state::{ - create_k3s_token, generated_kubeconfig_path, generated_token_path, read_secret_file, + create_k3s_token, fnv1a, generated_kubeconfig_path, generated_token_path, read_secret_file, write_secret_file, }; use super::*; @@ -527,22 +527,21 @@ fn distinct_cluster_names_get_distinct_state_directories() { assert_eq!(generated_token_path("a/b"), generated_token_path("a/b")); } -fn is_same_cluster(left: &str, right: &str) -> bool { - same_cluster_endpoint(left, right).is_some() -} - #[test] fn cluster_endpoints_compare_by_host_and_port() { - assert!(is_same_cluster( + assert!(same_cluster_endpoint( "https://100.64.0.1:6443", "https://100.64.0.1:6443" )); - assert!(is_same_cluster("https://k3s.example", "k3s.example:6443")); - assert!(!is_same_cluster( + assert!(same_cluster_endpoint( + "https://k3s.example", + "k3s.example:6443" + )); + assert!(!same_cluster_endpoint( "https://100.64.0.1:6443", "https://100.64.0.2:6443" )); - assert!(!is_same_cluster( + assert!(!same_cluster_endpoint( "https://100.64.0.1:6443", "https://100.64.0.1:7443" )); @@ -647,16 +646,16 @@ fn bare_json_strings_must_look_like_vm_names() { #[test] fn cluster_endpoints_require_a_matching_scheme() { // http:// is not the HTTPS API endpoint the kubeconfig names. - assert!(!is_same_cluster( + assert!(!same_cluster_endpoint( "https://cluster.example:6443", "http://cluster.example:6443" )); - assert!(!is_same_cluster( + assert!(!same_cluster_endpoint( "ssh://cluster.example:6443", "https://cluster.example:6443" )); // More than one trailing dot is not a hostname. - assert!(!is_same_cluster( + assert!(!same_cluster_endpoint( "https://k3s.example...:6443", "https://k3s.example:6443" )); @@ -860,3 +859,34 @@ projects: assert!(FleetFile::from_yaml_str(&fleet(" team: platform")).is_ok()); assert!(FleetFile::from_yaml_str(&fleet(" exedev.dev/test-case: shared")).is_ok()); } + +#[test] +fn generic_name_fields_must_look_like_vm_names() { + // An error object carrying `name` is not a VM; `vm_name` is taken as given. + assert!( + parse_vm_names(r#"[{"name":"QuotaExceeded","message":"no capacity"}]"#) + .unwrap() + .is_empty() + ); + assert_eq!( + parse_vm_names(r#"[{"vm_name":"UPPER-vm"}]"#).unwrap(), + BTreeSet::from(["UPPER-vm".to_string()]) + ); +} + +#[test] +fn an_empty_listing_response_is_an_error() { + // Distinct from an empty list: nothing came back at all. + assert!(parse_vm_names("").is_err()); + assert!(parse_vm_names(" \n").is_err()); + assert!(parse_vm_names("[]").unwrap().is_empty()); +} + +#[test] +fn fnv1a_matches_the_reference_vectors() { + // The published FNV-1a 64-bit digests; a wrong prime silently weakens the + // digest that keeps two sanitized cluster names apart. + assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325); + assert_eq!(fnv1a(b"a"), 0xaf63_dc4c_8601_ec8c); + assert_eq!(fnv1a(b"foobar"), 0x8594_4171_f739_67e8); +} diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index b658576..899476e 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -299,7 +299,7 @@ with `--yes`. | Narrowing access | `share set-private`, `integrations detach` | | Credentials | `ssh-key add`, `ssh-key generate-api-key`, `integrations add`, `integrations attach`, `integrations setup`, `integrations edit`, `team auth set` | | Domains | `domain add` | -| Spending | `billing capacity`, `billing credits buy`, `billing payment remove`, `billing payment default`, `pool new` | +| Spending | `resize`, `cp`, `pool new`, `billing capacity`, `billing credits buy`, `billing payment remove`, `billing payment default` | | Ownership | `team role`, `team transfer`, `team disable` | `integrations setup --list` and `--verify` are exempt, since they only