diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 245147b..afd6f7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,67 @@ 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: + - 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. + - 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}" + # 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}")" + 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, + # 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')" + done + 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}" + fi + # 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}" + build: name: build ${{ matrix.platform }} + needs: resolve runs-on: ${{ matrix.os }} strategy: @@ -41,17 +100,24 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref }} + # 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. + 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 }} @@ -72,21 +138,31 @@ 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 + env: + # Passed through the environment rather than interpolated into the script, + # so a crafted dispatch input cannot inject shell commands. + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + run: | + set -euo pipefail + 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 - name: Package release archive id: package shell: bash + env: + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${{ inputs.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" @@ -102,41 +178,106 @@ 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 }} if-no-files-found: error + # 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 + + steps: + # 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: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + BUILT_SHA: ${{ needs.resolve.outputs.sha }} + run: | + set -euo pipefail + # 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')" + # 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 + fi + publish: name: publish release - needs: build + needs: [resolve, verify] runs-on: ubuntu-24.04 permissions: contents: write steps: - name: Download release archives - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: release-* path: dist merge-multiple: true - - name: Resolve release tag - id: meta - shell: bash - run: | - set -euo pipefail - tag="${GITHUB_REF_NAME}" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then - tag="${{ inputs.tag_name }}" - fi - 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 }} + tag_name: ${{ needs.resolve.outputs.tag }} files: dist/*.tar.gz generate_release_notes: true + + + # 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: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.resolve.outputs.tag }} + BUILT_SHA: ${{ needs.resolve.outputs.sha }} + run: | + set -euo pipefail + # 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')" + # 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 + fi 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..78e000f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -159,10 +159,33 @@ 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. +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. + +`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: + +```sh +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 9040db8..b88599f 100644 --- a/cli/README.zh-CN.md +++ b/cli/README.zh-CN.md @@ -157,9 +157,29 @@ 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。 +`exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。它的 +arguments 原样发送,不会被注入任何 flag,因此会在服务端要求确认的命令 +(`team disable`、`billing credits buy`)需要自己在 raw command 里带 `--yes`。 +全局 `--yes` 仍然会跳过本 CLI 自身的确认提示;全局 `--json` 也仍然生效,因为它 +选择的是输出格式,不改变命令本身的行为。 + +`new --command` 已移除:exe.dev 的 `new` 选项列表中已无此项,转发只会得到服务端 +错误。如果你的账号仍接受它,可用 `exedev-ctl exec -- new --command ...` 原样发送。 + +有两个已文档化的 command 不提供 typed wrapper:它们都是一次性接入操作,没有 +automation 价值,并且都以 argument 传递 token —— 包装成 typed wrapper 并不会让它 +更安全。 + +```sh +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/cli/src/cli.rs b/cli/src/cli.rs index c6455ae..421d425 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,12 @@ pub(crate) struct ShareRemoveLinkCmd { #[derive(Debug, Args)] 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)] + pub(crate) reply_policy: Option, } #[derive(Debug, Args)] @@ -379,11 +399,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 +423,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 +474,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 +485,13 @@ pub(crate) struct TeamVmSharingCmd { pub(crate) value: String, } +#[derive(Debug, Args)] +pub(crate) struct TeamAutoJoinCmd { + /// One of on, off. + #[arg(value_parser = ["on", "off"])] + pub(crate) value: String, +} + #[derive(Debug, Args)] pub(crate) struct TeamVmCmd { #[command(subcommand)] @@ -477,6 +514,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 +644,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 +708,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 +741,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 +764,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 +799,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 +809,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 +835,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..9edf427 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), @@ -137,12 +147,33 @@ pub(crate) fn build_command(command: &Commands) -> Result { 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()); + } + 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 +195,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 +217,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 +260,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 +272,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 +294,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 +318,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 +396,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 +442,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 +470,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 +484,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 +510,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 +525,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 +869,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/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 d51d1fe..9c08daa 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; @@ -29,15 +29,33 @@ 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, - http: reqwest::Client::new(), - } + http, + }) } 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/core/src/shell.rs b/core/src/shell.rs index fc05dcf..89e5f45 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -40,28 +40,100 @@ 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 add-link ", - "share add-share-link ", - "grant-support-root ", - "ssh-key remove ", - "integrations remove ", - "integrations setup ", - "integrations detach ", - "integrations edit ", - "team remove ", - "team role ", - "team transfer ", + 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 generate-api-key", + "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", + // 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", - "domain rm ", + "team settings auto-join on", + // 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", + "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 { + command.starts_with("share add ") && command.split_whitespace().any(|word| word == "--root") } #[cfg(test)] @@ -92,8 +164,64 @@ 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("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("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("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")); + 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")); 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")); + // 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/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/fleet.rs b/k8s_cli/src/fleet.rs index 234e626..f0bf68d 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"; @@ -118,12 +122,83 @@ 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) .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 +256,42 @@ 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 + // 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()) { + 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/mod.rs b/k8s_cli/src/manager/mod.rs index ca7dc7a..a8b0486 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,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::{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, remote_privileged_script, tailscale_install_command, @@ -50,6 +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, 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, @@ -73,21 +76,48 @@ 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 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?; + let created = create_missing_vms( + &client, + &plan, + include_control_plane, + &inventory, + &cmd.fleet, + ) + .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, + &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 { @@ -147,13 +177,26 @@ 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) } -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( @@ -254,10 +297,26 @@ 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 + .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={}", @@ -281,34 +340,41 @@ async fn create_missing_vms( client: &ExeDevClient, plan: &FleetPlan, include_control_plane: bool, - current: &BTreeSet, + inventory: &VmInventory, fleet_path: &Path, -) -> Result<()> { +) -> Result> { + let mut created = BTreeMap::new(); 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); 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(&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( @@ -316,6 +382,7 @@ async fn bootstrap_k3s( mode: ClusterMode, ts_authkey: &str, kubeconfig_arg: Option<&Path>, + targets: &SshTargets, ) -> Result> { match mode { ClusterMode::New => { @@ -323,8 +390,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 +400,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 +420,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(), @@ -364,46 +432,117 @@ 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:") - ); - } + // Already checked in run_bootstrap, before any VM was created. for node in plan .nodes .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<()> { +/// 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 Kubernetes API endpoints, so an explicit `:6443` and the same +/// URL without it are still the same cluster. +/// +/// 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) -> 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(); + 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()) => + { + (host, port) + } + _ => (authority, "6443"), + }; + // 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())) + } + match (parts(left), parts(right)) { + (Some(left), Some(right)) => left == right, + _ => false, + } +} + +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 +552,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 +566,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 +577,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 +606,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 +632,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 +658,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( @@ -544,29 +684,63 @@ 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)?; + // 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() .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(), } 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 register: {last_error}"); + 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( @@ -574,6 +748,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)) + .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( @@ -581,9 +762,24 @@ 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?; + // 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, @@ -601,6 +797,58 @@ 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 +/// alone. +fn stale_owned_taints( + nodes: &BTreeMap, + name: &str, + desired: Option<&str>, +) -> Vec { + 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)) + .map(|key| format!("{key}-")) + .collect::>() + .into_iter() + .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, @@ -678,7 +926,18 @@ 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. + 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) } fn mode_name(mode: ClusterMode) -> &'static str { diff --git a/k8s_cli/src/manager/parsing.rs b/k8s_cli/src/manager/parsing.rs index 4ec0a5a..bf0b613 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}; @@ -9,22 +9,49 @@ pub(super) struct KubernetesNode { pub(super) taints: BTreeSet, } +/// 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"]; + +/// 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(); 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) { - return Ok(parse_vm_names_from_text(output)); + // The wrapper carries either the serialized listing or a rendered + // 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); + } 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 + // 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)) + // 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) { @@ -32,19 +59,31 @@ 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); } } } 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; + // `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; } } + // 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); @@ -55,16 +94,122 @@ 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(); + let Ok(value) = serde_json::from_str::(response.trim()) else { + return destinations; + }; + collect_ssh_destinations(&value, &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 +} + +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 + && let Some(destination) = ssh_destination_from_object(object) + { + destinations.insert(name.to_string(), destination); + } + 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")) + .filter(|dest| is_ssh_destination(dest)) + { + return Some(dest.to_string()); + } + let host = text("ssh_host").or_else(|| text("sshHost"))?; + 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 { 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()) + // 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) + .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/process.rs b/k8s_cli/src/manager/process.rs index df51afc..41d0414 100644 --- a/k8s_cli/src/manager/process.rs +++ b/k8s_cli/src/manager/process.rs @@ -2,10 +2,10 @@ 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}; +use tokio::time::{Duration, sleep, timeout}; const REMOTE_EXIT_PREFIX: &str = "__EXEDEV_K8S_EXIT__:"; @@ -13,8 +13,22 @@ 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); + +/// 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, @@ -28,9 +42,33 @@ 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) + } + + /// 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 { + 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') { @@ -46,7 +84,14 @@ pub(super) async fn remote_run(vm: &str, script: &str) -> Result<()> { 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 @@ -73,8 +118,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 +140,26 @@ 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?; + let output = capture_remote_ssh_output(&refs, &wrapped_script) + .await + .with_context(|| format!("ssh to {vm} failed"))?; 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() @@ -137,6 +192,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}"))?; @@ -155,12 +212,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 {}: {}", @@ -193,20 +261,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() { @@ -221,7 +309,15 @@ 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. + // Any stdout at all means the remote shell reached the script, whether or + // not the exit marker made it back, so resending would repeat whatever it + // had already done. Only an exchange that produced nothing is retried. + let remote_ran = !output.stdout.is_empty(); + if output.status.code() == Some(255) && !remote_ran && attempt < REMOTE_SSH_ATTEMPTS { eprintln!( "{}", output::stderr_block(format!( @@ -269,7 +365,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() @@ -295,7 +397,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 +407,12 @@ pub(super) fn remote_ssh_args(vm: &str) -> Vec { "StrictHostKeyChecking=accept-new".into(), "-o".into(), "ConnectTimeout=15".into(), - format!("{vm}.exe.xyz"), + // 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/scripts.rs b/k8s_cli/src/manager/scripts.rs index 4823816..f712393 100644 --- a/k8s_cli/src/manager/scripts.rs +++ b/k8s_cli/src/manager/scripts.rs @@ -50,6 +50,26 @@ 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 + # 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 +} + has_k3s_supervisor() { { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; } || [ -x /sbin/openrc-run ] } @@ -163,8 +183,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}')" @@ -190,7 +215,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) ) } @@ -215,13 +240,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 & + 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 fi @@ -271,7 +296,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 +304,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 +314,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 9d73c4f..657d212 100644 --- a/k8s_cli/src/manager/state.rs +++ b/k8s_cli/src/manager/state.rs @@ -1,58 +1,334 @@ use super::K3S_TOKEN_ENV; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use rand::{RngExt, distr::Alphanumeric}; use std::{ env, fs, - os::unix::fs::PermissionsExt, + io::{self, 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") + state_dir(cluster_name).join("kubeconfig") } pub(super) fn generated_token_path(cluster_name: &str) -> PathBuf { - Path::new(STATE_DIR).join(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. +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| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + 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. +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(0x0000_0100_0000_01b3); + } + hash } 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) { - if path.exists() { - let file_token = fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - if file_token.trim() != token { - write_secret_file(&path, &token) - .with_context(|| format!("failed to update {}", path.display()))?; - } - } else { - write_secret_file(&path, &token)?; + // 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.is_empty() { + bail!("{K3S_TOKEN_ENV} is set but empty"); + } + 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); } if path.exists() { - return fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display())) - .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() { + bail!( + "{} is empty; delete it to generate a new cluster token", + path.display() + ); + } + 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. +pub(super) 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 sync = sync_parent_dir(path); + let _ = fs::remove_file(&staged); + sync?; + 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, +/// not even briefly, and never observable half-written. +/// +/// 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()))?; + 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())); + } + 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<()> { + let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + 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)?; } - 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()))?; 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() + .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); + } + Ok(staged) +} + +/// 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", random_suffix()); + match path.parent() { + Some(parent) => parent.join(staged), + None => PathBuf::from(staged), + } +} + +/// 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 { + 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() + ); + } + } + Ok(()) +} + +fn random_suffix() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect() +} + +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() { + bail!( + "{} is not a regular file; remove it and rerun", + 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, file)) +} + 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 6297f41..20d502f 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -1,15 +1,24 @@ -use super::super::fleet::NodeSpec; +use super::super::fleet::{FleetFile, 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, parse_vm_names_from_text, +}; 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::{ k3s_agent_install_command, k3s_server_install_command, tailscale_install_command, }; +use super::state::{ + create_k3s_token, fnv1a, generated_kubeconfig_path, generated_token_path, read_secret_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() { @@ -37,6 +46,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 { @@ -104,7 +124,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\"")); @@ -139,8 +162,8 @@ 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"); - assert_eq!(args.len(), 11); + let args = remote_ssh_args("vm-1.exe.xyz"); + assert_eq!(args.len(), 12); assert_eq!(args[0], "-o"); assert_eq!(args[1], "ControlMaster=no"); assert_eq!(args[2], "-o"); @@ -149,9 +172,68 @@ 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] +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":"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!( + destinations.get("host-only").unwrap(), + "vm+host-only@shard3.exe.dev" + ); + 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( + 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] @@ -268,3 +350,543 @@ 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(); +} + +#[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. 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 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"); + 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_secret_file(&token_path).unwrap_err(); + assert!(err.to_string().contains("not a regular file")); + + 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(); +} + +#[test] +fn error_and_status_text_is_not_taken_for_inventory() { + assert!( + parse_vm_names(r#"{"output":"Error: quota exceeded\n"}"#) + .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")); + 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(()); + +/// 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 _sandbox = StateSandbox::enter("emptytok"); + + write_secret_file(&generated_token_path("c1"), " \n").unwrap(); + let err = read_or_create_k3s_token("c1").unwrap_err().to_string(); + assert!(err.contains("is empty"), "unexpected error: {err}"); + + // 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() { + 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 err = write_secret_file(&generated_token_path("c1"), "secret") + .unwrap_err() + .to_string(); + assert!(err.contains("not a real directory"), "unexpected: {err}"); + + // 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}"); +} + +#[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")); +} + +#[test] +fn cluster_endpoints_require_a_matching_scheme() { + // http:// is not the HTTPS API endpoint the kubeconfig names. + assert!(!same_cluster_endpoint( + "https://cluster.example:6443", + "http://cluster.example:6443" + )); + assert!(!same_cluster_endpoint( + "ssh://cluster.example:6443", + "https://cluster.example:6443" + )); + // More than one trailing dot is not a hostname. + assert!(!same_cluster_endpoint( + "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 _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); +} + +#[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")); +} + +#[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}"); +} + +#[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()); +} + +#[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/scripts/release/check-version.sh b/scripts/release/check-version.sh new file mode 100755 index 0000000..b0eefb6 --- /dev/null +++ b/scripts/release/check-version.sh @@ -0,0 +1,42 @@ +#!/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. + +# `${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 + 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}" + +# 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-]+)*)?$" + +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 new file mode 100755 index 0000000..31b350f --- /dev/null +++ b/scripts/release/set-version.sh @@ -0,0 +1,206 @@ +#!/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 + +# 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" + awk -v ver="$VERSION" ' + /^\[/ { section = $0 } + section == "[package]" && !replaced && /^version[[:space:]]*=/ { + print "version = \"" ver "\"" + replaced = 1 + next + } + { print } + END { exit replaced ? 0 : 1 } + ' "$src" > "$dest" +} + +set_path_dep_version() { + 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 } + ' "$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. +# `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 +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 + release_lock + 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 + # 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 + if [[ "$LOCKFILE_CREATED" -eq 1 ]]; then + rm -f "$LOCKFILE" + fi + fi + 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() { + rmdir "$LOCK_DIR" 2>/dev/null || true +} +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 + 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" + TARGETS+=("$manifest") + if ! set_package_version "$manifest" "$manifest.tmp"; then + echo "no [package] version to replace in $manifest" >&2 + exit 1 + fi +done + +ROOT_MANIFEST="$REPO_ROOT/Cargo.toml" +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" +TARGETS+=("$ROOT_MANIFEST") +cp "$ROOT_MANIFEST" "$ROOT_MANIFEST.tmp" +for key in "${PATH_DEP_KEYS[@]}"; do + # 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 "$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" +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 + 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 + +# 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. `--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 + 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 + 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..e9b3e33 --- /dev/null +++ b/scripts/release/sync-homebrew-tap.sh @@ -0,0 +1,303 @@ +#!/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 LICENSE 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 + +# 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. +# +# 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 +fi + +if [[ ! "$FORMULA_NAME" =~ ^[0-9A-Za-z._-]+$ ]]; then + echo "FORMULA_NAME is not a formula name: $FORMULA_NAME" >&2 + 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 +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 + 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 + # `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. + # + # 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 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" + fi +fi + +if [[ -z "$TAP_FORMULA_PATH" ]]; then + echo "TAP_REPO_PATH or TAP_FORMULA_PATH is required" >&2 + 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 [[ -L "$TAP_FORMULA_PATH" ]]; then + echo "TAP_FORMULA_PATH is a symlink; refusing to write through it: $TAP_FORMULA_PATH" >&2 + exit 1 +fi + +# 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 + +# 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. 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 + # 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 + # 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" ' + { + # 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 + exit 1 + fi + done +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")" +# Written beside the target and renamed over it: `cat >` 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" < --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. ## 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 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. @@ -54,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 104ff0c..899476e 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: @@ -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,24 +231,99 @@ 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: +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. + +## 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 | `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 +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. @@ -243,7 +334,20 @@ 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, 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.