diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 23c2646..c6455ae 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -47,11 +47,13 @@ pub(crate) enum Commands { Rename(RenameCmd), /// Add or remove a tag on a VM. Tag(TagCmd), + /// Set or clear a short comment on a VM. + Comment(CommentCmd), /// Show VM metrics. Stat(StatCmd), /// Copy an existing VM. Cp(CpCmd), - /// Resize a VM disk. + /// Resize a VM's resources (memory, CPU, disk). Resize(ResizeCmd), /// Share HTTPS VM access. Share(ShareCmd), @@ -59,6 +61,8 @@ pub(crate) enum Commands { Domain(DomainCmd), /// View and manage your team. Team(TeamCmd), + /// Manage your invite link and rewards. + Invite(InviteCmd), /// Show current user information. Whoami, /// Manage SSH keys. @@ -99,10 +103,10 @@ pub(crate) struct DocCmd { #[derive(Debug, Args)] pub(crate) struct LsCmd { - #[arg(short = 'a', long = "a")] - pub(crate) all: bool, #[arg(short = 'l', long = "l")] pub(crate) long: bool, + #[arg(long)] + pub(crate) group: Option, pub(crate) pattern: Option, } @@ -111,6 +115,10 @@ pub(crate) struct NewCmd { #[arg(long)] pub(crate) command: Option, #[arg(long)] + pub(crate) comment: Option, + #[arg(long)] + pub(crate) cpu: Option, + #[arg(long)] pub(crate) disk: Option, #[arg(long = "env")] pub(crate) envs: Vec, @@ -119,13 +127,19 @@ pub(crate) struct NewCmd { #[arg(long)] pub(crate) integration: Vec, #[arg(long)] + pub(crate) memory: Option, + #[arg(long)] pub(crate) name: Option, #[arg(long)] pub(crate) no_email: bool, #[arg(long)] pub(crate) prompt: Option, #[arg(long)] + pub(crate) registry_auth: Option, + #[arg(long)] pub(crate) setup_script: Option, + #[arg(long = "tag")] + pub(crate) tags: Vec, } #[derive(Debug, Args)] @@ -149,7 +163,15 @@ pub(crate) struct TagCmd { #[arg(short = 'd', long = "d")] pub(crate) delete: bool, pub(crate) vm: String, - pub(crate) tag_name: String, + #[arg(required = true, num_args = 1..)] + pub(crate) tag_names: Vec, +} + +#[derive(Debug, Args)] +pub(crate) struct CommentCmd { + pub(crate) vm: String, + /// Comment text; pass an empty string to clear the comment. + pub(crate) text: String, } #[derive(Debug, Args)] @@ -166,14 +188,22 @@ pub(crate) struct CpCmd { #[arg(long)] pub(crate) copy_tags: Option, #[arg(long)] + pub(crate) cpu: Option, + #[arg(long)] pub(crate) disk: Option, + #[arg(long)] + pub(crate) memory: Option, } #[derive(Debug, Args)] pub(crate) struct ResizeCmd { pub(crate) vmname: String, #[arg(long)] - pub(crate) disk: String, + pub(crate) cpu: Option, + #[arg(long)] + pub(crate) disk: Option, + #[arg(long)] + pub(crate) memory: Option, } #[derive(Debug, Args)] @@ -262,13 +292,22 @@ pub(crate) struct DomainCmd { #[derive(Debug, Subcommand)] pub(crate) enum DomainSubcommand { /// Register a custom domain for a VM. - Add(DomainVmDomainCmd), + Add(DomainAddCmd), /// List registered custom domains. Ls(DomainLsCmd), /// Remove a custom domain from a VM. Rm(DomainVmDomainCmd), } +#[derive(Debug, Args)] +pub(crate) struct DomainAddCmd { + /// Issue a wildcard (*.) certificate via DNS-01 delegation. + #[arg(long)] + pub(crate) wildcard: bool, + pub(crate) vm: String, + pub(crate) domain: String, +} + #[derive(Debug, Args)] pub(crate) struct DomainVmDomainCmd { pub(crate) vm: String, @@ -285,14 +324,33 @@ pub(crate) struct DomainLsCmd { #[derive(Debug, Args)] pub(crate) struct TeamCmd { #[command(subcommand)] - pub(crate) command: TeamSubcommand, + pub(crate) command: Option, } #[derive(Debug, Subcommand)] pub(crate) enum TeamSubcommand { + /// Disband your team. + Disable, + /// List team members. Members, + /// Add a team member. Add(EmailCmd), + /// Remove a team member. Remove(EmailCmd), + /// Change a team member's role. + Role(TeamRoleCmd), + /// Rename your team. + Rename(TeamRenameCmd), + /// View and manage team billing information. + Billing(TeamBillingCmd), + /// Transfer a VM to another team member. + Transfer(TeamTransferCmd), + /// View and manage team auth settings. + Auth(TeamAuthCmd), + /// View and manage team settings. + Settings(TeamSettingsCmd), + /// View team members' VMs. + Vm(TeamVmCmd), } #[derive(Debug, Args)] @@ -300,6 +358,156 @@ pub(crate) struct EmailCmd { pub(crate) email: String, } +#[derive(Debug, Args)] +pub(crate) struct TeamRoleCmd { + pub(crate) email: String, + /// One of user, admin, billing_owner. + pub(crate) role: String, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamRenameCmd { + pub(crate) name: String, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamBillingCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum TeamBillingSubcommand { + /// Update team billing information. + Update(TeamBillingUpdateCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct TeamBillingUpdateCmd { + #[arg(long)] + pub(crate) name: Option, + #[arg(long)] + pub(crate) business_name: Option, + #[arg(long)] + pub(crate) phone: Option, + #[arg(long)] + pub(crate) address_line1: Option, + #[arg(long)] + pub(crate) address_line2: Option, + #[arg(long)] + pub(crate) address_city: Option, + #[arg(long)] + pub(crate) address_state: Option, + #[arg(long)] + pub(crate) address_postal_code: Option, + #[arg(long)] + pub(crate) address_country: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamTransferCmd { + pub(crate) vm_name: String, + pub(crate) target_email: String, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamAuthCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum TeamAuthSubcommand { + /// Set the team auth provider. + Set(TeamAuthSetCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct TeamAuthSetCmd { + /// One of default, google, oidc. + pub(crate) provider: String, + #[arg(long)] + pub(crate) issuer_url: Option, + #[arg(long)] + pub(crate) client_id: Option, + #[arg(long)] + pub(crate) client_secret: Option, + #[arg(long)] + pub(crate) display_name: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamSettingsCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum TeamSettingsSubcommand { + /// Set who can share team VMs. + #[command(name = "vm-sharing")] + VmSharing(TeamVmSharingCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct TeamVmSharingCmd { + /// One of admins-only, all-members. + pub(crate) value: String, +} + +#[derive(Debug, Args)] +pub(crate) struct TeamVmCmd { + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum TeamVmSubcommand { + /// List all VMs across your team. + #[command(alias = "list")] + Ls(TeamVmLsCmd), +} + +#[derive(Debug, Args)] +pub(crate) struct TeamVmLsCmd { + #[arg(short = 'l', long = "l")] + pub(crate) long: bool, + #[arg(long)] + pub(crate) group: Option, + pub(crate) pattern: Option, +} + +#[derive(Debug, Args)] +pub(crate) struct InviteCmd { + #[command(subcommand)] + pub(crate) command: InviteSubcommand, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum InviteSubcommand { + /// Show your active invite link and reward. + Show, + /// Print only your active invite link. + Link, + /// List invite rewards you can use. + Rewards, + /// Choose the reward for your invite link. + #[command(name = "set-reward")] + SetReward(InviteSetRewardCmd), + /// Show signups, upgrades, and reward status. + Activity, + /// Request more trial invites. + Request, + /// Open the invites page. + Manage, +} + +#[derive(Debug, Args)] +pub(crate) struct InviteSetRewardCmd { + /// One of standard, bonus-credits, extra-memory, extra-disk. + pub(crate) reward: String, +} + #[derive(Debug, Args)] pub(crate) struct SshKeyCmd { #[command(subcommand)] @@ -318,6 +526,9 @@ pub(crate) enum SshKeySubcommand { #[derive(Debug, Args)] pub(crate) struct SshKeyAddCmd { + /// Scope the key to VMs with this tag. + #[arg(long)] + pub(crate) tag: Option, pub(crate) public_key: String, } @@ -360,6 +571,7 @@ pub(crate) enum IntegrationsSubcommand { List, Setup(IntegrationSetupCmd), Add(IntegrationAddCmd), + Edit(IntegrationEditCmd), Remove(NameCmd), Attach(IntegrationAttachCmd), Detach(IntegrationAttachCmd), @@ -376,6 +588,8 @@ pub(crate) struct IntegrationSetupCmd { #[arg(long)] pub(crate) list: bool, #[arg(long)] + pub(crate) name: Option, + #[arg(long)] pub(crate) verify: bool, } @@ -387,12 +601,20 @@ pub(crate) struct IntegrationAddCmd { #[arg(long)] pub(crate) team: bool, #[arg(long)] + pub(crate) act_as_user: bool, + #[arg(long)] pub(crate) attach: Vec, #[arg(long)] pub(crate) bearer: Option, #[arg(long)] + pub(crate) comment: Option, + #[arg(long)] + pub(crate) fields: Option, + #[arg(long)] pub(crate) header: Vec, #[arg(long)] + pub(crate) no_auth: bool, + #[arg(long)] pub(crate) peer: bool, #[arg(long)] pub(crate) repository: Option, @@ -402,21 +624,56 @@ pub(crate) struct IntegrationAddCmd { pub(crate) args: Vec, } +#[derive(Debug, Args)] +pub(crate) struct IntegrationEditCmd { + pub(crate) name: String, + #[arg(long)] + pub(crate) team: bool, + #[arg(long)] + pub(crate) act_as_user: bool, + #[arg(long)] + pub(crate) bearer: Option, + #[arg(long)] + pub(crate) clear_header: bool, + #[arg(long)] + pub(crate) comment: Option, + #[arg(long)] + pub(crate) fields: Option, + #[arg(long)] + pub(crate) header: Vec, + #[arg(long)] + pub(crate) no_auth: bool, + #[arg(long)] + pub(crate) repository: Option, + #[arg(long)] + pub(crate) target: Option, + #[arg(long)] + pub(crate) webhook_url: Option, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub(crate) args: Vec, +} + #[derive(Debug, Args)] pub(crate) struct NameCmd { pub(crate) name: String, + #[arg(long)] + pub(crate) team: bool, } #[derive(Debug, Args)] pub(crate) struct IntegrationAttachCmd { pub(crate) name: String, pub(crate) spec: String, + #[arg(long)] + pub(crate) team: bool, } #[derive(Debug, Args)] pub(crate) struct IntegrationRenameCmd { pub(crate) name: String, pub(crate) new_name: String, + #[arg(long)] + pub(crate) team: bool, } #[derive(Debug, Args)] @@ -427,12 +684,31 @@ pub(crate) struct BillingCmd { #[derive(Debug, Subcommand)] pub(crate) enum BillingSubcommand { + /// Show your current plan and resource limits. Plan, - Update, + /// Show resource usage against your plan. + Usage(BillingUsageCmd), + /// Show Shelley credit balances. + Credits, + /// Show invite rewards you've earned. + Rewards, + /// Change your subscription capacity. + Capacity, + /// Open the billing page. + Manage, + /// Show invoices. Invoices, + /// Show receipts for credit purchases. Receipts, } +#[derive(Debug, Args)] +pub(crate) struct BillingUsageCmd { + /// Time range: cycle, 24h, 7d, or 30d. + #[arg(long)] + pub(crate) range: 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 4f1bcd2..106b648 100644 --- a/cli/src/cli_command.rs +++ b/cli/src/cli_command.rs @@ -22,17 +22,17 @@ pub(crate) fn build_command(command: &Commands) -> Result { } Commands::Ls(cmd) => { words.push("ls".into()); - if cmd.all { - words.push("-a".into()); - } if cmd.long { words.push("-l".into()); } + push_flag_value(&mut words, "--group", cmd.group.as_ref()); push_opt(&mut words, cmd.pattern.as_ref()); } 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()); for env in &cmd.envs { push_flag_value(&mut words, "--env", Some(env)); @@ -41,6 +41,7 @@ pub(crate) fn build_command(command: &Commands) -> Result { for integration in &cmd.integration { push_flag_value(&mut words, "--integration", Some(integration)); } + push_flag_value(&mut words, "--memory", cmd.memory.as_ref()); push_flag_value(&mut words, "--name", cmd.name.as_ref()); if cmd.no_email { words.push("--no-email".into()); @@ -51,7 +52,11 @@ pub(crate) fn build_command(command: &Commands) -> Result { fallback_ssh = true; } push_flag_value(&mut words, "--prompt", cmd.prompt.as_ref()); + push_flag_value(&mut words, "--registry-auth", cmd.registry_auth.as_ref()); push_flag_value(&mut words, "--setup-script", cmd.setup_script.as_ref()); + for tag in &cmd.tags { + push_flag_value(&mut words, "--tag", Some(tag)); + } } Commands::Rm(cmd) => { ensure_non_empty(&cmd.vmnames, "rm requires at least one VM name")?; @@ -67,7 +72,11 @@ pub(crate) fn build_command(command: &Commands) -> Result { if cmd.delete { words.push("-d".into()); } - words.extend([cmd.vm.clone(), cmd.tag_name.clone()]); + words.push(cmd.vm.clone()); + words.extend(cmd.tag_names.clone()); + } + Commands::Comment(cmd) => { + words.extend(["comment".into(), cmd.vm.clone(), cmd.text.clone()]); } Commands::Stat(cmd) => { words.extend(["stat".into(), cmd.vm_name.clone()]); @@ -77,16 +86,23 @@ pub(crate) fn build_command(command: &Commands) -> Result { words.extend(["cp".into(), cmd.source_vm.clone()]); push_opt(&mut words, cmd.new_name.as_ref()); push_flag_value(&mut words, "--copy-tags", cmd.copy_tags.as_ref()); + push_flag_value(&mut words, "--cpu", cmd.cpu.as_ref()); push_flag_value(&mut words, "--disk", cmd.disk.as_ref()); + push_flag_value(&mut words, "--memory", cmd.memory.as_ref()); + } + Commands::Resize(cmd) => { + if cmd.cpu.is_none() && cmd.disk.is_none() && cmd.memory.is_none() { + bail!("resize requires at least one of --memory, --cpu, --disk"); + } + words.extend(["resize".into(), cmd.vmname.clone()]); + push_flag_value(&mut words, "--cpu", cmd.cpu.as_ref()); + push_flag_value(&mut words, "--disk", cmd.disk.as_ref()); + push_flag_value(&mut words, "--memory", cmd.memory.as_ref()); } - Commands::Resize(cmd) => words.extend([ - "resize".into(), - cmd.vmname.clone(), - format!("--disk={}", cmd.disk), - ]), 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), + Commands::Team(cmd) => build_team_command(&mut words, cmd.command.as_ref()), + 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), Commands::SetRegion(cmd) => { @@ -175,7 +191,11 @@ fn build_domain_command(words: &mut Vec, command: &DomainSubcommand) -> words.push("domain".into()); match command { DomainSubcommand::Add(cmd) => { - words.extend(["add".into(), cmd.vm.clone(), cmd.domain.clone()]); + words.push("add".into()); + if cmd.wildcard { + words.push("--wildcard".into()); + } + words.extend([cmd.vm.clone(), cmd.domain.clone()]); } DomainSubcommand::Ls(cmd) => { match (cmd.all, cmd.vm.as_ref()) { @@ -196,12 +216,92 @@ fn build_domain_command(words: &mut Vec, command: &DomainSubcommand) -> Ok(()) } -fn build_team_command(words: &mut Vec, command: &TeamSubcommand) { +fn build_team_command(words: &mut Vec, command: Option<&TeamSubcommand>) { words.push("team".into()); + let Some(command) = command else { + 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::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()]), + TeamSubcommand::Role(cmd) => { + words.extend(["role".into(), cmd.email.clone(), cmd.role.clone()]); + } + TeamSubcommand::Rename(cmd) => words.extend(["rename".into(), cmd.name.clone()]), + TeamSubcommand::Billing(cmd) => { + 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()); + } + } + TeamSubcommand::Transfer(cmd) => { + words.extend([ + "transfer".into(), + cmd.vm_name.clone(), + cmd.target_email.clone(), + ]); + } + TeamSubcommand::Auth(cmd) => { + words.push("auth".into()); + if let Some(TeamAuthSubcommand::Set(set)) = &cmd.command { + words.extend(["set".into(), set.provider.clone()]); + push_flag_value(words, "--issuer-url", set.issuer_url.as_ref()); + push_flag_value(words, "--client-id", set.client_id.as_ref()); + push_flag_value(words, "--client-secret", set.client_secret.as_ref()); + push_flag_value(words, "--display-name", set.display_name.as_ref()); + } + } + TeamSubcommand::Settings(cmd) => { + words.push("settings".into()); + if let Some(TeamSettingsSubcommand::VmSharing(sharing)) = &cmd.command { + words.extend(["vm-sharing".into(), sharing.value.clone()]); + } + } + TeamSubcommand::Vm(cmd) => { + words.push("vm".into()); + if let Some(TeamVmSubcommand::Ls(ls)) = &cmd.command { + words.push("ls".into()); + if ls.long { + words.push("-l".into()); + } + push_flag_value(words, "--group", ls.group.as_ref()); + push_opt(words, ls.pattern.as_ref()); + } + } + } +} + +fn build_invite_command(words: &mut Vec, command: &InviteSubcommand) { + words.push("invite".into()); + match command { + InviteSubcommand::Show => words.push("show".into()), + InviteSubcommand::Link => words.push("link".into()), + InviteSubcommand::Rewards => words.push("rewards".into()), + InviteSubcommand::SetReward(cmd) => { + words.extend(["set-reward".into(), cmd.reward.clone()]); + } + InviteSubcommand::Activity => words.push("activity".into()), + InviteSubcommand::Request => words.push("request".into()), + InviteSubcommand::Manage => words.push("manage".into()), } } @@ -209,7 +309,11 @@ fn build_ssh_key_command(words: &mut Vec, command: &SshKeySubcommand) { words.push("ssh-key".into()); match command { SshKeySubcommand::List => words.push("list".into()), - SshKeySubcommand::Add(cmd) => words.extend(["add".into(), cmd.public_key.clone()]), + SshKeySubcommand::Add(cmd) => { + words.push("add".into()); + push_flag_value(words, "--tag", cmd.tag.as_ref()); + words.push(cmd.public_key.clone()); + } SshKeySubcommand::Remove(cmd) => words.extend(["remove".into(), cmd.key.clone()]), SshKeySubcommand::Rename(cmd) => { words.extend(["rename".into(), cmd.old_name.clone(), cmd.new_name.clone()]); @@ -239,6 +343,7 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub if cmd.list { words.push("--list".into()); } + push_flag_value(words, "--name", cmd.name.as_ref()); if cmd.verify { words.push("--verify".into()); } @@ -249,13 +354,21 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub if cmd.team { words.push("--team".into()); } + if cmd.act_as_user { + words.push("--act-as-user".into()); + } for attach in &cmd.attach { push_flag_value(words, "--attach", Some(attach)); } push_flag_value(words, "--bearer", cmd.bearer.as_ref()); + push_flag_value(words, "--comment", cmd.comment.as_ref()); + push_flag_value(words, "--fields", cmd.fields.as_ref()); for header in &cmd.header { push_flag_value(words, "--header", Some(header)); } + if cmd.no_auth { + words.push("--no-auth".into()); + } if cmd.peer { words.push("--peer".into()); } @@ -263,30 +376,73 @@ fn build_integrations_command(words: &mut Vec, command: &IntegrationsSub push_flag_value(words, "--target", cmd.target.as_ref()); words.extend(cmd.args.clone()); } - IntegrationsSubcommand::Remove(cmd) => words.extend(["remove".into(), cmd.name.clone()]), + IntegrationsSubcommand::Edit(cmd) => { + words.extend(["edit".into(), cmd.name.clone()]); + if cmd.team { + words.push("--team".into()); + } + if cmd.act_as_user { + words.push("--act-as-user".into()); + } + push_flag_value(words, "--bearer", cmd.bearer.as_ref()); + if cmd.clear_header { + words.push("--clear-header".into()); + } + push_flag_value(words, "--comment", cmd.comment.as_ref()); + push_flag_value(words, "--fields", cmd.fields.as_ref()); + for header in &cmd.header { + push_flag_value(words, "--header", Some(header)); + } + if cmd.no_auth { + words.push("--no-auth".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()); + words.extend(cmd.args.clone()); + } + IntegrationsSubcommand::Remove(cmd) => { + words.extend(["remove".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()); + } } IntegrationsSubcommand::Detach(cmd) => { words.extend(["detach".into(), cmd.name.clone(), cmd.spec.clone()]); + if cmd.team { + words.push("--team".into()); + } } IntegrationsSubcommand::Rename(cmd) => { words.extend(["rename".into(), cmd.name.clone(), cmd.new_name.clone()]); + if cmd.team { + words.push("--team".into()); + } } } } fn build_billing_command(words: &mut Vec, command: &BillingSubcommand) { words.push("billing".into()); - words.push( - match command { - BillingSubcommand::Plan => "plan", - BillingSubcommand::Update => "update", - BillingSubcommand::Invoices => "invoices", - BillingSubcommand::Receipts => "receipts", - } - .into(), - ); + match command { + BillingSubcommand::Plan => words.push("plan".into()), + BillingSubcommand::Usage(cmd) => { + words.push("usage".into()); + push_flag_value(words, "--range", cmd.range.as_ref()); + } + BillingSubcommand::Credits => words.push("credits".into()), + BillingSubcommand::Rewards => words.push("rewards".into()), + BillingSubcommand::Capacity => words.push("capacity".into()), + BillingSubcommand::Manage => words.push("manage".into()), + BillingSubcommand::Invoices => words.push("invoices".into()), + BillingSubcommand::Receipts => words.push("receipts".into()), + } } fn build_shelley_command(words: &mut Vec, command: &ShelleySubcommand) { @@ -408,4 +564,202 @@ mod tests { let built = command_from(&["exedev-ctl", "exec", "--", "whoami"]); assert_eq!(shell_join(&built.words), "whoami"); } + + #[test] + fn builds_ls_with_group() { + let built = command_from(&["exedev-ctl", "ls", "-l", "--group", "tag", "p1-*"]); + assert_eq!(shell_join(&built.words), "ls -l --group tag 'p1-*'"); + } + + #[test] + fn builds_new_command_with_resources_and_tags() { + let built = command_from(&[ + "exedev-ctl", + "new", + "--name", + "p1-a-1", + "--cpu", + "4", + "--memory", + "16GB", + "--tag", + "prod", + "--tag", + "web", + ]); + assert_eq!( + shell_join(&built.words), + "new --cpu 4 --memory 16GB --name p1-a-1 --tag prod --tag web" + ); + } + + #[test] + fn builds_comment_command() { + let built = command_from(&["exedev-ctl", "comment", "mybox", "staging copy"]); + assert_eq!(shell_join(&built.words), "comment mybox 'staging copy'"); + + let built = command_from(&["exedev-ctl", "comment", "mybox", ""]); + assert_eq!(shell_join(&built.words), "comment mybox ''"); + } + + #[test] + fn builds_tag_command_with_multiple_tags() { + let built = command_from(&["exedev-ctl", "tag", "-d", "mybox", "prod", "web"]); + assert_eq!(shell_join(&built.words), "tag -d mybox prod web"); + } + + #[test] + fn builds_resize_command() { + let built = command_from(&["exedev-ctl", "resize", "mybox", "--memory", "8GB"]); + assert_eq!(shell_join(&built.words), "resize mybox --memory 8GB"); + } + + #[test] + fn rejects_resize_without_flags() { + let cli = Cli::parse_from(["exedev-ctl", "resize", "mybox"]); + let err = build_command(&cli.command).unwrap_err(); + assert_eq!( + err.to_string(), + "resize requires at least one of --memory, --cpu, --disk" + ); + } + + #[test] + fn builds_domain_add_wildcard() { + let built = command_from(&[ + "exedev-ctl", + "domain", + "add", + "--wildcard", + "mybox", + "app.example.com", + ]); + assert_eq!( + shell_join(&built.words), + "domain add --wildcard mybox app.example.com" + ); + } + + #[test] + fn builds_ssh_key_add_with_tag() { + let built = command_from(&[ + "exedev-ctl", + "ssh-key", + "add", + "--tag", + "prod", + "ssh-ed25519 AAAA key", + ]); + assert_eq!( + shell_join(&built.words), + "ssh-key add --tag prod 'ssh-ed25519 AAAA key'" + ); + } + + #[test] + fn builds_bare_team_command() { + let built = command_from(&["exedev-ctl", "team"]); + assert_eq!(shell_join(&built.words), "team"); + } + + #[test] + fn team_disable_forwards_yes() { + let built = command_from(&["exedev-ctl", "team", "disable"]); + assert_eq!(shell_join(&built.words), "team disable --yes"); + } + + #[test] + fn builds_team_commands() { + let built = command_from(&["exedev-ctl", "team", "role", "a@b.c", "admin"]); + assert_eq!(shell_join(&built.words), "team role a@b.c admin"); + + let built = command_from(&["exedev-ctl", "team", "transfer", "mybox", "a@b.c"]); + assert_eq!(shell_join(&built.words), "team transfer mybox a@b.c"); + + let built = command_from(&[ + "exedev-ctl", + "team", + "billing", + "update", + "--name", + "ACME", + "--address-city", + "Berlin", + ]); + assert_eq!( + shell_join(&built.words), + "team billing update --name ACME --address-city Berlin" + ); + + let built = command_from(&[ + "exedev-ctl", + "team", + "auth", + "set", + "oidc", + "--issuer-url", + "https://accounts.google.com", + ]); + assert_eq!( + shell_join(&built.words), + "team auth set oidc --issuer-url https://accounts.google.com" + ); + + let built = command_from(&[ + "exedev-ctl", + "team", + "settings", + "vm-sharing", + "all-members", + ]); + assert_eq!( + shell_join(&built.words), + "team settings vm-sharing all-members" + ); + + let built = command_from(&["exedev-ctl", "team", "vm", "ls", "-l", "--group", "user"]); + assert_eq!(shell_join(&built.words), "team vm ls -l --group user"); + } + + #[test] + fn builds_invite_commands() { + let built = command_from(&["exedev-ctl", "invite", "show"]); + assert_eq!(shell_join(&built.words), "invite show"); + + let built = command_from(&["exedev-ctl", "invite", "set-reward", "bonus-credits"]); + assert_eq!(shell_join(&built.words), "invite set-reward bonus-credits"); + } + + #[test] + fn builds_billing_commands() { + let built = command_from(&["exedev-ctl", "billing", "usage", "--range", "cycle"]); + assert_eq!(shell_join(&built.words), "billing usage --range cycle"); + + let built = command_from(&["exedev-ctl", "billing", "capacity"]); + assert_eq!(shell_join(&built.words), "billing capacity"); + } + + #[test] + fn builds_integrations_edit_command() { + let built = command_from(&[ + "exedev-ctl", + "integrations", + "edit", + "myproxy", + "--team", + "--target", + "http://localhost:9000", + "--clear-header", + ]); + assert_eq!( + shell_join(&built.words), + "integrations edit myproxy --team --clear-header --target http://localhost:9000" + ); + + let built = command_from(&["exedev-ctl", "integrations", "remove", "myproxy", "--team"]); + assert_eq!( + shell_join(&built.words), + "integrations remove myproxy --team" + ); + } } diff --git a/core/src/shell.rs b/core/src/shell.rs index 1566145..fc05dcf 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -50,8 +50,13 @@ fn is_dangerous(command: &str) -> bool { "integrations remove ", "integrations setup ", "integrations detach ", + "integrations edit ", "team remove ", - "billing update", + "team role ", + "team transfer ", + "team disable", + "domain rm ", + "billing capacity", ]; prefixes .iter() @@ -82,6 +87,13 @@ mod tests { assert!(is_dangerous("share set-public vm1")); assert!(is_dangerous("share add-link vm1")); assert!(is_dangerous("ssh-key remove abc")); + assert!(is_dangerous("team disable --yes")); + assert!(is_dangerous("team transfer vm1 a@b.c")); + 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("ls")); + assert!(!is_dangerous("team members")); + assert!(!is_dangerous("domain ls -a")); } } diff --git a/docs/exe-dev-api-reference.md b/docs/exe-dev-api-reference.md index 561159c..c4318d6 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-05-18 from these source pages: +on. It was last checked on 2026-07-10 from these source pages: - - @@ -9,11 +9,15 @@ on. It was last checked on 2026-05-18 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 . + ## API shape exe.dev exposes two programmatic access styles: -1. SSH command automation: +1. SSH command automation (the primary API): ```sh ssh exe.dev ls --json @@ -31,6 +35,19 @@ exe.dev command that would be typed into `ssh exe.dev` or run through `ssh exe.dev `. API responses enable JSON output by default, equivalent to passing `--json`. +Example `ls --json` VM object shape: + +```json +{ + "https_url": "https://bloggy.exe.xyz", + "region": "lon", + "region_display": "London, UK", + "ssh_dest": "bloggy.exe.xyz", + "status": "running", + "vm_name": "bloggy" +} +``` + Minimal HTTPS request: ```sh @@ -47,6 +64,9 @@ Operational limits: - Commands time out after 30 seconds. - Commands that require interactive input do not fit this API. +Any command can be introspected without side effects by passing `--help` +(for example `new --help`), which returns its flags and examples as JSON. + ## exe.dev API tokens The exe.dev HTTPS API uses bearer tokens. A token can be generated by exe.dev: @@ -81,16 +101,21 @@ it. Supported top-level permission fields: -- `exp`: UTC Unix timestamp after which the token is invalid. +- `exp`: UTC Unix timestamp after which the token is invalid. The default is + the distant future (never expires); exe.dev strongly recommends always + setting `exp`. - `nbf`: UTC Unix timestamp before which the token is not valid yet. -- `cmds`: exe.dev command names this token may run. -- `ctx`: signed JSON context uninterpreted by exe.dev. +- `cmds`: exe.dev command names this token may run. `cmds` controls command + names only; flags, arguments, and options (like `--json`) are always allowed + when the base command is permitted. +- `ctx`: signed JSON context uninterpreted by exe.dev. Must itself be valid + JSON complying with the restrictions below. The empty object `{}` uses defaults. For least-privilege automation, specify -`cmds` explicitly. Subcommands must be granted explicitly; allowing `ssh-key` -does not allow `ssh-key list`. +`cmds` explicitly. Subcommands must be granted explicitly as a single string +such as `"ssh-key list"`; allowing `ssh-key` does not allow `ssh-key list`. -Useful default commands include: +The default `cmds` grant is: - `help` - `ls` @@ -105,17 +130,21 @@ Useful default commands include: Commands such as `rm`, `rename`, `restart`, `share port`, `share set-public`, and `share set-private` should be added only when automation needs them. -Permission JSON restrictions: +Permission JSON restrictions (also enforced inside `ctx`): -- Compact JSON is recommended. +- Compact JSON is recommended (pipe through `jq -c`). - No leading/trailing whitespace. - No newlines or null bytes. -- No duplicate keys. +- No duplicate keys, at any level. - Only `exp`, `nbf`, `cmds`, and `ctx` are allowed at the top level. -- `exp` and `nbf` must be integer timestamps between 2000-01-01 and - 2100-01-01 UTC. +- `exp` and `nbf` must be plain integers (no decimals, no exponents) between + 2000-01-01 (946684800) and 2100-01-01 (4102444800) UTC. - The whole token must not exceed 8KB. +There is no built-in replay protection (no nonce or `jti`). Use short-lived +tokens (small `exp`) to limit the replay window, and separate SSH keys per +token family for revocability. + Short opaque `exe1` tokens can be issued from a valid `exe0` token: ```sh @@ -128,19 +157,25 @@ For a VM-scoped token, validate the source token against the VM: ssh exe.dev exe0-to-exe1 --vm=vm-name "$EXE_DEV_API_KEY" ``` +`exe1` tokens work everywhere `exe0` tokens work, in exactly the same way. An +`exe1` token is validated through its underlying `exe0` token on every use; to +revoke an `exe1` token, revoke the underlying `exe0` token. + ## HTTPS error handling Common responses from `POST /exec`: -- `400`: request body is empty, missing, or syntactically invalid. +- `400`: request body is empty, missing, or has invalid command syntax (e.g. + unbalanced quotes). - `401`: token is malformed, expired, signed by an unknown key, or fails signature verification. - `403`: token permissions do not allow the requested command. -- `404`: unknown exe.dev command. +- `404`: unknown exe.dev command; check `ssh exe.dev help` for the full list. - `405`: non-POST method. - `413`: request body exceeds 64KB. -- `422`: command ran and returned a non-zero exit code. -- `429`: per-key rate limit. +- `422`: command ran and returned a non-zero exit code; the body contains the + error message. +- `429`: per-key rate limit; use separate SSH keys for independent workloads. - `500`: unexpected server-side error. - `504`: command exceeded the 30-second timeout. @@ -148,9 +183,11 @@ Debug checklist for `401`: - Confirm the signing public key is listed with `ssh exe.dev ssh-key list`. - Confirm `exp` is not in the past. -- Confirm the exact compact JSON payload was signed. +- Confirm the exact compact JSON payload was signed byte-for-byte; avoid + editors that add trailing newlines. - Use a private key file with `ssh-keygen -Y sign -f`; an agent-only key is not - enough for this command. + enough for this command. If the key only lives in `ssh-agent`, export the + public part with `ssh-add -L` and use the private key file directly. Debug checklist for `403`: @@ -161,8 +198,8 @@ Debug checklist for `403`: ## VM HTTPS tokens The exe.dev HTTPS auth proxy can accept bearer tokens for programmatic access to -individual VM HTTPS endpoints. This is separate from authenticating to -`POST https://exe.dev/exec`. +individual VM HTTPS endpoints (API servers, `git push` over HTTPS). This is +separate from authenticating to `POST https://exe.dev/exec`. Generate a VM token through exe.dev: @@ -180,8 +217,9 @@ export SIG=$(printf '%s' "$PERMISSIONS" | ssh-keygen -Y sign -f ~/.ssh/exe_dev_a VM token differences: - The signing namespace scopes the token to one VM. -- The token payload `ctx` is forwarded to the VM HTTP server as - `X-ExeDev-Token-Ctx`. +- The token payload `ctx` is forwarded verbatim to the VM HTTP server as + `X-ExeDev-Token-Ctx`. Its contents are signed, so the VM server can use them + for its own authorization rules. Preferred VM proxy authentication header: @@ -215,7 +253,10 @@ git clone https://myvm.exe.xyz/repo.git ## Login with exe Applications served through the exe.dev HTTP proxy can use exe.dev -authentication instead of managing their own login system. +authentication instead of managing their own login system. This is +complementary with sharing: public sites can bounce users through the login +URL to require an email address, while private sites always carry the +authentication headers because access requires the site to have been shared. When a request is authenticated by exe.dev, the proxy adds: @@ -229,8 +270,8 @@ Special URLs: - `https://vmname.exe.xyz/__exe.dev/login?redirect={path}` redirects the user to log in, then back to `{path}`. -- `POST https://vmname.exe.xyz/__exe.dev/logout` logs the user out for that - domain. +- `POST https://vmname.exe.xyz/__exe.dev/logout` logs the user out, removing + the cookie for that domain. Local development can simulate these headers with a reverse proxy, for example: @@ -242,6 +283,10 @@ mitmdump \ --set modify_headers='/~q/X-Exedev-Userid/usr1234' ``` +Server-side authorization can gate on `X-ExeDev-Email` directly; the upstream +docs include an nginx example that returns `403` unless +`$http_x_exedev_email` matches an allowlist. + VM services must not trust user-controlled copies of these headers when traffic can bypass the exe.dev proxy. Bind private services to localhost or firewall direct VM ports if the header values are part of authorization. @@ -251,9 +296,15 @@ direct VM ports if the header values are part of authorization. This repository uses the exe.dev HTTPS command API through `EXE_DEV_API_KEY` for non-interactive VM management: -- `exedev-ctl` wraps individual exe.dev commands. +- `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`. - `exedev-k8s` uses exe.dev VM commands as the infrastructure layer for k3s - fleet bootstrapping. + fleet bootstrapping; against exe.dev itself it only needs `ls`, `new`, and + `rm` (node provisioning happens over direct SSH to the VMs). For local scripts and manual debugging, prefer: @@ -270,7 +321,13 @@ curl -X POST https://exe.dev/exec \ ``` For new automation tokens, grant only the commands that the workflow needs. A -typical VM lifecycle token for this repository needs at least: +minimal `exedev-k8s` fleet token needs: + +```json +{"cmds":["ls","new","rm"],"exp":1798761600} +``` + +A broader VM lifecycle token for `exedev-ctl` workflows typically adds: ```json { @@ -278,11 +335,14 @@ typical VM lifecycle token for this repository needs at least: "ls", "new", "rm", + "restart", + "rename", "whoami", "share show", "share port", "share set-public", - "share set-private" + "share set-private", + "domain ls" ], "exp": 1798761600 } diff --git a/docs/exedev-automation.md b/docs/exedev-automation.md index a27cda8..24ccc5f 100644 --- a/docs/exedev-automation.md +++ b/docs/exedev-automation.md @@ -32,7 +32,8 @@ Recommended approach: 4. Compare the desired VM list with the current VM list from exe.dev. 5. Run `new` for missing VMs. 6. Mark extra VMs and require explicit confirmation before running `rm`. -7. After VM creation, hand nodes to Ansible for Tailscale, k3s, and node labels. +7. After VM creation, bootstrap nodes over direct SSH: Tailscale, k3s, and + Kubernetes labels/taints. The `exedev-k8s` binary implements this flow. ## HTTPS API Usage @@ -49,9 +50,12 @@ Create a VM: ```sh curl -X POST https://exe.dev/exec \ -H "Authorization: Bearer $EXE_DEV_API_KEY" \ - -d 'new --name=p1-a-1 --image=ubuntu:22.04 --no-email' + -d 'new --name=p1-a-1 --image=exeuntu --no-email' ``` +Prefer the `exeuntu` image for k3s nodes; generic minimal images such as +`ubuntu:22.04` may lack `sudo`, `curl`, or a process supervisor. + Set the proxy port: ```sh @@ -66,15 +70,23 @@ Important limits: - There is no stdin. - There is no pty. - Request body size is limited to 64KB. -- A single command times out after 30 seconds. -- Failed commands return `422`. +- A single command times out after 30 seconds (`504`). +- Failed commands return `422` with the error message in the body. - Token permission failures return `403`. -- Rate limits are applied per SSH key. +- Rate limits are applied per SSH key; use separate keys for independent + workloads. ## API Token -exe.dev tokens are generated by signing permissions locally with an SSH private -key. For automation, use a dedicated SSH key so it can be revoked independently. +exe.dev can generate a token server-side: + +```sh +ssh exe.dev ssh-key generate-api-key --exp=30d +``` + +Tokens can also be generated by signing permissions locally with an SSH private +key. For automation, use a dedicated SSH key so it can be revoked +independently. Create a dedicated key: @@ -107,12 +119,17 @@ Common permission fields: - `exp`: expiration time as a UTC Unix timestamp. Always set it when possible. - `nbf`: not-before time as a UTC Unix timestamp. -- `cmds`: list of exe.dev commands this token may run. +- `cmds`: list of exe.dev command names this token may run. Only command names + are checked; flags and arguments (like `--json`) are always allowed once the + base command is permitted. - `ctx`: uninterpreted context for exe.dev, passable to VM HTTP services. -Default `cmds` include `help`, `ls`, `new`, `whoami`, `ssh-key list`, -`share show`, and `exe0-to-exe1`. Commands such as `rm`, `rename`, `restart`, -and `share port` must be added explicitly. +Default `cmds` are `help`, `ls`, `new`, `whoami`, `ssh-key list`, +`share show`, `exe0-to-exe1`, `team`, and `team members`. Commands such as +`rm`, `rename`, `restart`, and `share port` must be added explicitly. + +See [exe-dev-api-reference.md](exe-dev-api-reference.md) for the full token +format, JSON restrictions, and error handling reference. ## Common Commands @@ -125,7 +142,7 @@ ssh exe.dev ls --json Create a VM: ```sh -ssh exe.dev new --name=p1-a-1 --image=ubuntu:22.04 --no-email --json +ssh exe.dev new --name=p1-a-1 --image=exeuntu --no-email --json ``` Create a VM with environment variables: @@ -166,10 +183,9 @@ fleet.yaml -> ssh exe.dev ls --json to fetch current VM list -> diff -> create missing VMs - -> write Ansible inventory - -> initialize nodes with Ansible + -> bootstrap nodes over SSH (Tailscale, k3s) -> kubectl label/taint nodes - -> deploy apps with Flux CD or Argo CD + -> deploy apps with kubectl apply, Flux CD, or Argo CD ``` ## Kubernetes Boundary @@ -182,7 +198,7 @@ Do not let Kubernetes create exe.dev VMs directly. You could build a controller that calls `POST /exec`, but the cleaner boundary is: ```text -external scripts/Ansible: manage VMs +exedev-k8s (or external scripts): manage VMs Kubernetes: manage Pods Flux CD or Argo CD: manage application declarations ``` @@ -199,19 +215,20 @@ Do not start with a Kubernetes Cluster Autoscaler provider. exe.dev resources are shared quota, so the platform fits declarative batch management better than a cloud-provider model that assumes fixed dedicated CPU/RAM for each VM. -## Future Scripts +## Implemented Tooling -This repository can later add: +The Rust `exedev-k8s` binary implements this flow for k3s fleets. See +[`../k8s_cli/README.md`](../k8s_cli/README.md). -```text -scripts/render-inventory -scripts/exedev-sync --dry-run -scripts/exedev-sync --apply -scripts/exedev-label-nodes +```sh +exedev-k8s plan --fleet fleet.yaml --mode new # read-only dry run +exedev-k8s bootstrap --fleet fleet.yaml --mode new # create VMs, k3s, labels +exedev-k8s status --fleet fleet.yaml # VM + node status +exedev-k8s deploy --manifests k8s/examples # kubectl apply -f +exedev-k8s destroy --fleet fleet.yaml # delete fleet VMs ``` -`exedev-sync --dry-run` must print a plan first and must not delete any VM. -VM deletion must require separate confirmation to avoid accidental disk loss. - -The Rust `exedev-k8s` binary now covers the first integrated version of this -flow for k3s fleets. See [`../k8s_cli/README.md`](../k8s_cli/README.md). +The original safety requirements hold in the implementation: `plan` never +mutates anything, `bootstrap` prints its plan and asks for confirmation unless +`--yes` is passed and never deletes extra VMs, and `destroy` always asks for +its own confirmation to avoid accidental disk loss. diff --git a/docs/node-labeling.md b/docs/node-labeling.md index 393342b..a1cb5f8 100644 --- a/docs/node-labeling.md +++ b/docs/node-labeling.md @@ -1,6 +1,45 @@ # Node Labels and Taints -Every exe.dev VM that joins the cluster should receive deterministic labels. +Every VM created or managed by `exedev-k8s bootstrap` receives deterministic +labels applied automatically from `fleet.yaml`; the manual `kubectl` commands +below are only needed for out-of-band nodes or repairs (`exedev-k8s status` +reports `labels=ok taint=ok` when a node matches its spec). + +## Label scheme + +Control-plane nodes: + +```text +exedev.dev/role=control-plane +exedev.dev/pool=control-plane +``` + +Control-plane nodes are not tainted by this scheme (k3s size-1 clusters +schedule workloads on the server node). + +Worker nodes from a `projects..tasks.` pool, where the pool +name is `-`: + +```text +exedev.dev/project= +exedev.dev/task= +exedev.dev/pool=- +``` + +Spare pools from `sparePools.` get `exedev.dev/pool=` plus any +custom `labels` from the fleet file. + +Pools with `isolated: true` (the default via `defaults.isolated`) also get a +`NoSchedule` taint so only matching workloads run there: + +```text +exedev.dev/pool=:NoSchedule +``` + +Shared pools should set `isolated: false` so unmatched workloads can schedule +onto them. + +## Manual commands For `project1/a`: @@ -22,5 +61,19 @@ kubectl taint node p2-b-1 exedev.dev/pool=project2-b:NoSchedule Repeat the same labels and taint for `p2-b-2` through `p2-b-5`. -Shared pools should not be tainted by default unless they are reserved for -specific workloads. +## Workload scheduling + +Workloads pin to a pool with a matching selector and toleration, as in +[`../k8s/examples/project-task-deployment.yaml`](../k8s/examples/project-task-deployment.yaml): + +```yaml +spec: + nodeSelector: + exedev.dev/project: project1 + exedev.dev/task: a + tolerations: + - key: exedev.dev/pool + operator: Equal + value: project1-a + effect: NoSchedule +``` diff --git a/fleet.example.yaml b/fleet.example.yaml index b9bef39..1ca5fd6 100644 --- a/fleet.example.yaml +++ b/fleet.example.yaml @@ -3,6 +3,10 @@ # nodes = number of exe.dev VMs reserved for this project/task pool. # replicas = default Kubernetes Pod replica count for workloads in this pool. # isolated = whether to add a NoSchedule taint so only matching workloads run here. +# cpu / memory = optional VM sizing forwarded to `new --cpu` / `new --memory`; +# pool-level values override defaults. +# tags = optional exe.dev VM tags forwarded to `new --tag` (distinct from +# Kubernetes labels); pool-level tags are appended to defaults.tags. cluster: name: exedev-main @@ -16,6 +20,10 @@ defaults: kubernetes: k3s image: exeuntu isolated: true + # cpu: 2 + # memory: 4GB + # tags: + # - k8s projects: project1: @@ -24,6 +32,10 @@ projects: nodes: 1 replicas: 1 vmPrefix: p1-a + # cpu: 4 + # memory: 16GB + # tags: + # - prod b: nodes: 2 replicas: 2 diff --git a/k8s_cli/README.md b/k8s_cli/README.md index c9a6ea3..712b3e5 100644 --- a/k8s_cli/README.md +++ b/k8s_cli/README.md @@ -92,6 +92,20 @@ If the pool is isolated, it also applies: exedev.dev/pool=project1-a:NoSchedule ``` +Optional VM sizing and exe.dev tags can be set in `defaults`, on +`cluster.controlPlane`, or per pool. `cpu` and `memory` are forwarded to +`new --cpu`/`new --memory` (pool-level values override `defaults`); `tags` +are exe.dev VM tags forwarded to `new --tag` (pool-level tags are appended +to `defaults.tags`). These are distinct from Kubernetes `labels`. + +```yaml +defaults: + cpu: 2 + memory: 4GB + tags: + - k8s +``` + For the workload-side scheduling pattern, see [`../docs/node-labeling.md`](../docs/node-labeling.md). diff --git a/k8s_cli/src/fleet.rs b/k8s_cli/src/fleet.rs index dcecf93..234e626 100644 --- a/k8s_cli/src/fleet.rs +++ b/k8s_cli/src/fleet.rs @@ -30,6 +30,10 @@ pub(crate) struct ControlPlane { pub(crate) nodes: usize, pub(crate) vm_prefix: String, pub(crate) image: Option, + pub(crate) cpu: Option, + pub(crate) memory: Option, + #[serde(default)] + pub(crate) tags: Vec, } #[derive(Debug, Default, Deserialize)] @@ -38,6 +42,11 @@ pub(crate) struct Defaults { pub(crate) kubernetes: Option, pub(crate) isolated: Option, pub(crate) image: Option, + pub(crate) cpu: Option, + pub(crate) memory: Option, + // exe.dev VM tags passed to `new --tag`, distinct from Kubernetes labels. + #[serde(default)] + pub(crate) tags: Vec, } #[derive(Debug, Deserialize)] @@ -57,6 +66,10 @@ pub(crate) struct TaskPool { pub(crate) vm_prefix: String, pub(crate) isolated: Option, pub(crate) image: Option, + pub(crate) cpu: Option, + pub(crate) memory: Option, + #[serde(default)] + pub(crate) tags: Vec, #[serde(default)] pub(crate) labels: BTreeMap, } @@ -68,6 +81,10 @@ pub(crate) struct SparePool { pub(crate) vm_prefix: String, pub(crate) isolated: Option, pub(crate) image: Option, + pub(crate) cpu: Option, + pub(crate) memory: Option, + #[serde(default)] + pub(crate) tags: Vec, #[serde(default)] pub(crate) labels: BTreeMap, } @@ -85,6 +102,10 @@ pub(crate) struct NodeSpec { pub(crate) role: NodeRole, pub(crate) pool: String, pub(crate) image: String, + pub(crate) cpu: Option, + pub(crate) memory: Option, + // exe.dev VM tags passed to `new --tag`, distinct from Kubernetes labels. + pub(crate) tags: Vec, pub(crate) labels: BTreeMap, pub(crate) taint: Option, } @@ -130,6 +151,12 @@ impl FleetFile { bail!("fleet requests {worker_count} worker VMs but workerVmBudget is {budget}"); } } + if self.defaults.cpu == Some(0) { + bail!("defaults.cpu must be greater than 0"); + } + if self.cluster.control_plane.cpu == Some(0) { + bail!("cluster.controlPlane.cpu must be greater than 0"); + } for (project_name, project) in &self.projects { for (task_name, task) in &project.tasks { if task.nodes == 0 { @@ -138,6 +165,9 @@ impl FleetFile { if task.vm_prefix.trim().is_empty() { bail!("projects.{project_name}.tasks.{task_name}.vmPrefix must not be empty"); } + if task.cpu == Some(0) { + bail!("projects.{project_name}.tasks.{task_name}.cpu must be greater than 0"); + } } } for (pool_name, pool) in &self.spare_pools { @@ -147,6 +177,9 @@ impl FleetFile { if pool.vm_prefix.trim().is_empty() { bail!("sparePools.{pool_name}.vmPrefix must not be empty"); } + if pool.cpu == Some(0) { + bail!("sparePools.{pool_name}.cpu must be greater than 0"); + } } Ok(()) } @@ -167,6 +200,9 @@ impl FleetFile { .image .clone() .unwrap_or_else(|| default_image.clone()), + cpu: self.resolve_cpu(self.cluster.control_plane.cpu), + memory: self.resolve_memory(self.cluster.control_plane.memory.as_ref()), + tags: self.merge_tags(&self.cluster.control_plane.tags), labels: control_labels, taint: None, }); @@ -184,6 +220,9 @@ impl FleetFile { role: NodeRole::Worker, pool: pool.clone(), image: task.image.clone().unwrap_or_else(|| default_image.clone()), + cpu: self.resolve_cpu(task.cpu), + memory: self.resolve_memory(task.memory.as_ref()), + tags: self.merge_tags(&task.tags), labels, taint: self .is_isolated(task.isolated) @@ -202,6 +241,9 @@ impl FleetFile { role: NodeRole::Spare, pool: pool_name.clone(), image: pool.image.clone().unwrap_or_else(|| default_image.clone()), + cpu: self.resolve_cpu(pool.cpu), + memory: self.resolve_memory(pool.memory.as_ref()), + tags: self.merge_tags(&pool.tags), labels, taint: self .is_isolated(pool.isolated) @@ -225,6 +267,26 @@ impl FleetFile { .unwrap_or_else(|| DEFAULT_IMAGE.into()) } + fn resolve_cpu(&self, value: Option) -> Option { + value.or(self.defaults.cpu) + } + + fn resolve_memory(&self, value: Option<&String>) -> Option { + value.cloned().or_else(|| self.defaults.memory.clone()) + } + + /// Default tags apply to every VM; pool-level tags are appended after them. + /// The result is deduplicated in first-occurrence order. + fn merge_tags(&self, tags: &[String]) -> Vec { + let mut merged = Vec::new(); + for tag in self.defaults.tags.iter().chain(tags) { + if !merged.contains(tag) { + merged.push(tag.clone()); + } + } + merged + } + fn is_isolated(&self, value: Option) -> bool { value.unwrap_or(self.defaults.isolated.unwrap_or(false)) } @@ -286,11 +348,19 @@ cluster: controlPlane: nodes: 1 vmPrefix: cp + tags: + - cp + - k8s workerVmBudget: 3 defaults: network: tailscale kubernetes: k3s isolated: true + cpu: 2 + memory: 4GB + tags: + - k8s + - k8s projects: p1: tasks: @@ -298,6 +368,10 @@ projects: nodes: 2 replicas: 2 vmPrefix: p1-a + cpu: 4 + memory: 16GB + tags: + - prod sparePools: shared: nodes: 1 @@ -333,6 +407,104 @@ sparePools: assert_eq!(shared.taint, None); } + #[test] + fn resolves_resources_and_merges_tags() { + let plan = sample().to_plan(); + + // Pool-level values override defaults; tags are the union. + let worker = plan + .nodes + .iter() + .find(|node| node.name == "p1-a-1") + .unwrap(); + assert_eq!(worker.cpu, Some(4)); + assert_eq!(worker.memory.as_deref(), Some("16GB")); + assert_eq!(worker.tags, vec!["k8s".to_string(), "prod".to_string()]); + + // Pools without overrides inherit the defaults; the duplicate default + // tag in the sample is deduplicated. + let shared = plan + .nodes + .iter() + .find(|node| node.name == "shared-1") + .unwrap(); + assert_eq!(shared.cpu, Some(2)); + assert_eq!(shared.memory.as_deref(), Some("4GB")); + assert_eq!(shared.tags, vec!["k8s".to_string()]); + + // Control-plane tags merge after the defaults, dropping duplicates. + let control = plan.control_plane().unwrap(); + assert_eq!(control.cpu, Some(2)); + assert_eq!(control.memory.as_deref(), Some("4GB")); + assert_eq!(control.tags, vec!["k8s".to_string(), "cp".to_string()]); + } + + #[test] + fn rejects_zero_cpu() { + let cases = [ + ( + r#" +cluster: + name: demo + controlPlane: + nodes: 1 + vmPrefix: cp +defaults: + cpu: 0 +"#, + "defaults.cpu must be greater than 0", + ), + ( + r#" +cluster: + name: demo + controlPlane: + nodes: 1 + vmPrefix: cp + cpu: 0 +"#, + "cluster.controlPlane.cpu must be greater than 0", + ), + ( + r#" +cluster: + name: demo + controlPlane: + nodes: 1 + vmPrefix: cp +projects: + p1: + tasks: + a: + nodes: 1 + vmPrefix: p1-a + cpu: 0 +"#, + "projects.p1.tasks.a.cpu must be greater than 0", + ), + ( + r#" +cluster: + name: demo + controlPlane: + nodes: 1 + vmPrefix: cp +sparePools: + shared: + nodes: 1 + vmPrefix: shared + cpu: 0 +"#, + "sparePools.shared.cpu must be greater than 0", + ), + ]; + for (yaml, expected) in cases { + let fleet: FleetFile = serde_yaml::from_str(yaml).unwrap(); + let err = fleet.validate().unwrap_err(); + assert_eq!(err.to_string(), expected); + } + } + #[test] fn rejects_ha_control_plane() { let fleet: FleetFile = serde_yaml::from_str( diff --git a/k8s_cli/src/manager/mod.rs b/k8s_cli/src/manager/mod.rs index 30954c7..ca7dc7a 100644 --- a/k8s_cli/src/manager/mod.rs +++ b/k8s_cli/src/manager/mod.rs @@ -180,10 +180,11 @@ fn print_bootstrap_plan( if !current.contains(&node.name) { any = true; println!( - " - {} [{}] image={}", + " - {} [{}] image={}{}", output::vm(&node.name), output::role(role_name(node.role)), - output::label(&node.image) + output::label(&node.image), + output::label(node_resource_suffix(node)) ); } } @@ -611,14 +612,38 @@ fn kubeconfig_for_bootstrap( } fn exe_new_command(node: &NodeSpec) -> String { - shell::shell_join(&[ + let mut words: Vec = vec![ "new".into(), "--name".into(), node.name.clone(), "--image".into(), node.image.clone(), - "--no-email".into(), - ]) + ]; + if let Some(cpu) = node.cpu { + words.extend(["--cpu".into(), cpu.to_string()]); + } + if let Some(memory) = &node.memory { + words.extend(["--memory".into(), memory.clone()]); + } + for tag in &node.tags { + words.extend(["--tag".into(), tag.clone()]); + } + words.push("--no-email".into()); + shell::shell_join(&words) +} + +fn node_resource_suffix(node: &NodeSpec) -> String { + let mut suffix = String::new(); + if let Some(cpu) = node.cpu { + suffix.push_str(&format!(" cpu={cpu}")); + } + if let Some(memory) = &node.memory { + suffix.push_str(&format!(" memory={memory}")); + } + if !node.tags.is_empty() { + suffix.push_str(&format!(" tags=[{}]", node.tags.join(","))); + } + suffix } fn is_vm_name_unavailable_error(err: &anyhow::Error, vm_name: &str) -> bool { diff --git a/k8s_cli/src/manager/tests.rs b/k8s_cli/src/manager/tests.rs index 9a7cb3c..6297f41 100644 --- a/k8s_cli/src/manager/tests.rs +++ b/k8s_cli/src/manager/tests.rs @@ -44,6 +44,9 @@ fn builds_exedev_new_command() { role: NodeRole::Worker, pool: "project1-a".into(), image: "ubuntu:22.04".into(), + cpu: None, + memory: None, + tags: Vec::new(), labels: BTreeMap::new(), taint: None, }; @@ -53,6 +56,25 @@ fn builds_exedev_new_command() { ); } +#[test] +fn builds_exedev_new_command_with_resources_and_tags() { + let node = NodeSpec { + name: "p1-a-1".into(), + role: NodeRole::Worker, + pool: "project1-a".into(), + image: "exeuntu".into(), + cpu: Some(4), + memory: Some("16GB".into()), + tags: vec!["k8s".into(), "prod".into()], + labels: BTreeMap::new(), + taint: None, + }; + assert_eq!( + exe_new_command(&node), + "new --name p1-a-1 --image exeuntu --cpu 4 --memory 16GB --tag k8s --tag prod --no-email" + ); +} + #[test] fn tailscale_install_command_starts_daemon_before_up() { let command = tailscale_install_command("tskey-auth-test"); diff --git a/skills/exedev-ctl/SKILL.md b/skills/exedev-ctl/SKILL.md index e03b6a9..a036fb3 100644 --- a/skills/exedev-ctl/SKILL.md +++ b/skills/exedev-ctl/SKILL.md @@ -24,7 +24,7 @@ If `exedev-ctl` is not installed, direct users to the latest GitHub Release: https://github.com/lollipopkit/exedev-cli/releases/latest ``` -Release archives are named `exedev-clis--.tar.gz` and include the `exedev-ctl` binary. +Release archives are named `exedev-clis--.tar.gz` and include both the `exedev-ctl` and `exedev-k8s` binaries; archive member names carry a `./` prefix (extract with `./exedev-ctl`). ## Before Acting @@ -38,7 +38,7 @@ Check the current environment and scope before proposing changes: ## Command Selection -Use typed wrappers for supported commands: `ls`, `new`, `rm`, `restart`, `rename`, `tag`, `stat`, `cp`, `resize`, `share`, `domain`, `team`, `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`, `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. diff --git a/skills/exedev-ctl/references/exedev-ctl.md b/skills/exedev-ctl/references/exedev-ctl.md index df26ce5..104ff0c 100644 --- a/skills/exedev-ctl/references/exedev-ctl.md +++ b/skills/exedev-ctl/references/exedev-ctl.md @@ -17,13 +17,16 @@ exedev-clis--macos-amd64.tar.gz exedev-clis--macos-arm64.tar.gz ``` -Each archive contains the `exedev-ctl` binary. +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`. Manual install pattern: ```sh mkdir -p ~/.local/bin -tar -xzf exedev-clis--.tar.gz exedev-ctl +tar -xzf exedev-clis--.tar.gz ./exedev-ctl mv exedev-ctl ~/.local/bin/ chmod +x ~/.local/bin/exedev-ctl ``` @@ -33,11 +36,11 @@ Make sure `~/.local/bin` is on `PATH`, or move the binary to another directory a macOS Apple Silicon example: ```sh -tag="" +tag="" # for example v0.1.16 curl -L -o exedev-clis.tar.gz \ "https://github.com/lollipopkit/exedev-cli/releases/download/${tag}/exedev-clis-${tag}-macos-arm64.tar.gz" mkdir -p ~/.local/bin -tar -xzf exedev-clis.tar.gz exedev-ctl +tar -xzf exedev-clis.tar.gz ./exedev-ctl mv exedev-ctl ~/.local/bin/ chmod +x ~/.local/bin/exedev-ctl ``` @@ -45,15 +48,18 @@ chmod +x ~/.local/bin/exedev-ctl Linux amd64 example: ```sh -tag="" +tag="" # for example v0.1.16 curl -L -o exedev-clis.tar.gz \ "https://github.com/lollipopkit/exedev-cli/releases/download/${tag}/exedev-clis-${tag}-linux-amd64.tar.gz" mkdir -p ~/.local/bin -tar -xzf exedev-clis.tar.gz exedev-ctl +tar -xzf exedev-clis.tar.gz ./exedev-ctl mv exedev-ctl ~/.local/bin/ chmod +x ~/.local/bin/exedev-ctl ``` +Users who also want the k3s fleet CLI can extract `./exedev-k8s` from the same +archive; see `k8s_cli/README.md` in the source repository. + Verify installation: ```sh @@ -116,15 +122,20 @@ List VMs: ```sh exedev-ctl --json ls +exedev-ctl --json ls --group tag exedev-ctl --transport http --json ls ``` Create a VM: ```sh -exedev-ctl new --name p1-a-1 --image ubuntu:22.04 --no-email +exedev-ctl new --name p1-a-1 --image exeuntu --no-email +exedev-ctl new --name p1-a-1 --image exeuntu --cpu 4 --memory 16GB --tag prod --no-email ``` +Prefer the `exeuntu` image for nodes that need `sudo`, `curl`, or systemd; +generic minimal images such as `ubuntu:22.04` may lack them. + Delete a VM: ```sh @@ -143,11 +154,18 @@ Rename a VM: exedev-ctl rename old-name new-name ``` -Tag or untag a VM: +Tag or untag a VM (multiple tags per call are supported): ```sh -exedev-ctl tag p1-a-1 role=worker -exedev-ctl tag -d p1-a-1 role=worker +exedev-ctl tag p1-a-1 prod web +exedev-ctl tag -d p1-a-1 prod web +``` + +Set or clear a short comment on a VM: + +```sh +exedev-ctl comment p1-a-1 "staging copy" +exedev-ctl comment p1-a-1 "" ``` Show VM metrics: @@ -156,10 +174,11 @@ Show VM metrics: exedev-ctl stat p1-a-1 ``` -Resize disk: +Resize resources (at least one of `--memory`, `--cpu`, `--disk`): ```sh exedev-ctl resize p1-a-1 --disk 80G +exedev-ctl resize p1-a-1 --cpu 4 --memory 16GB ``` Share HTTP access: @@ -171,6 +190,15 @@ exedev-ctl share set-public p1-a-1 exedev-ctl share set-private p1-a-1 ``` +Share with specific users or via link: + +```sh +exedev-ctl share add p1-a-1 user@example.com --message "check this" +exedev-ctl share remove p1-a-1 user@example.com +exedev-ctl share add-link p1-a-1 +exedev-ctl share remove-link p1-a-1 +``` + Manage custom domains after DNS points at the VM: ```sh @@ -195,13 +223,19 @@ exedev-ctl exec -- whoami ## Token Generation Helper -The `exedev-ctl` wrapper supports exe.dev token generation: +The `exedev-ctl` wrapper supports exe.dev token generation with `--label`, `--vm`, `--cmds`, and `--exp`: + +```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 +``` + +For a VM-scoped token accepted by the VM HTTPS proxy (not `/exec`): ```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 1798761600 +exedev-ctl ssh-key generate-api-key --vm p1-a-1 --label deploy ``` -For destructive operations, include commands intentionally and narrowly, for example `rm`, `restart`, or `rename` only when needed. +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. ## Triage Checklist