CLI command reference

Authoritative reference for every isard subcommand. For end-user examples see ../README.md; for the underlying API endpoints see api-endpoints.md.

Credentials come from GENCAT_USERNAME / GENCAT_PASSWORD (read from the environment, or from a local .env file via dotenvy). The username is auto-suffixed with @edu.gencat.cat if you pass only a local part. If either variable is unset, the CLI prompts on a TTY (and errors out otherwise). No subcommand takes credentials as flags.

~/.config/isard/session.json holds the Gencat SAML AuthSession; ~/.config/isard/isardvdi.json holds the IsardVDI IsardVdiSession JWT. Both are chmod 600 on POSIX. Sessions self-heal: validity is checked locally before every API call, and a 401 from any endpoint transparently clears the cache and re-authenticates (see architecture.md#session-handling).


login

Run the SAML + JWT flow and persist both sessions. Always re-authenticates fresh: any existing cache is cleared before authenticating. No arguments.

The @edu.gencat.cat suffix on the username is added automatically — type just the short form (e.g. 12345678a). A hint line is printed above the prompt as a reminder.

logout

Delete both cached session files and the per-desktop SSH credentials cache. No args.

version

Print package.version from Cargo.toml. No args.

update

isard update [--check] [--version <X.Y.Z>] [--base-url <URL>]

Replace this binary with the latest release from isard.xtec.dev.

  • Fetches https://isard.xtec.dev/api/version, compares the running binary's version against the manifest using semver, downloads the asset for the running platform (std::env::consts::{OS, ARCH} → key like linux-x86_64, darwin-aarch64).
  • Verifies the downloaded file's SHA-256 against the manifest entry before swapping. A mismatch aborts without touching the existing binary.
  • Atomic-replaces the running binary via fs::rename on unix (works while the process is running — the open inode survives). On Windows the running .exe is renamed to isard.exe.old first; subsequent invocations sweep that file.
  • --check: report status only — don't download or install.
  • --version <X.Y.Z>: install a specific release instead of the latest (use this to roll back). Downgrading from a tty prompts for confirmation.
  • --base-url <URL> / ISARD_BASE_URL: override the update server.

isard self-update is a hidden alias kept for the v0.1.4 → v0.1.5 migration. Prefer isard update in new scripts.

Auto update-notification

Since v0.1.8 every isard <subcommand> (except update, version, login, and the hidden background subcommand) silently spawns a detached child that polls /api/version and writes the result to ~/.config/isard/update-check.json. On the next invocation, before the foreground subcommand runs, the CLI reads that file — if a newer release is pending it interactively asks Update now? [Y/n]. Yes runs isard update inline; No records a 24-hour per-version snooze (a brand-new release that lands during the snooze window re-prompts immediately, since the snooze is keyed to the offered version). The background check itself rate-limits to once per 6 hours. In non-TTY contexts (pipes, scripts) the prompt degrades to a one-line note: isard X.Y.Z is available — run \isard update`and never blocks. SetISARD_SKIP_UPDATE_CHECK=1` to opt out of the whole flow.

list

isard list [-H|--hardware] [-n|--name <NAME>]
  • Calls GET /api/v3/user/desktops, sorts by name.
  • --name filters server-side via an exact match.
  • --hardware adds CPU + Memory columns. Hardware comes from GET /api/v3/template/{template_id} for each unique template id (IsardVdiClient::enrich_hardware). Desktops whose template is null show -.

template

The template subcommand is a group. With no action it defaults to list.

template list

isard template [-c|--category <ID>] [-H|--hardware] [--filter <NAME>]
isard template list ...

Calls IsardVdiClient::get_templates, which tries a fallback chain (see api-endpoints.md). --filter is a case-insensitive substring match on name. Disabled templates are hidden from the output.

template create

isard template create [--seed-url <URL>]

Only an optional --seed-url. The desktop name is derived deterministically from the picked Fedora variant and major version, so a re-run with the same Fedora release produces the same name and IsardVDI correctly returns 409 ("desktop already exists"). Naming scheme:

  • fedora-server-<version> (e.g. fedora-server-44)
  • fedora-workstation-<version> (e.g. fedora-workstation-44)

The command:

  1. Reuses or refreshes the IsardVDI session.
  2. Shows an interactive numbered menu:
    Template source:
      1. Fedora Server (Net Install)
      2. Fedora Workstation (Live)
    Select (1-2):
    
  3. Resolves the latest x86_64 ISO via Fedora's releases.json (fedora::latest_fedora_iso). Filter: arch="x86_64", variant="Server" or "Workstation", link contains netinst (Server) or Live (Workstation), highest numeric version. The version is what the derived name uses.
  4. Computes the desktop name (fedora-<variant>-<version>) and prints it.
  5. Looks up media by filename stem (e.g. Fedora-Server-netinst-x86_64-44-1.7). Reuses if Downloaded; if missing, registers via POST /api/v3/media and polls /api/v3/media until status == "Downloaded" (timeout: 1800 s, poll: 5 s, transparent session refresh on 401).
  6. Queries /api/v3/media/installs and picks an xml_id (priority: fedora > linux > centos / rhel / alma / rocky, with virtio preferred within each family). Prints the full available list for visibility.
  7. Calls POST /api/v3/desktop/from/media with hardware constants (TEMPLATE_VCPUS=4, TEMPLATE_MEMORY_MB=8192 → sent as 8 GB, TEMPLATE_DISK_GB=100, TEMPLATE_DISK_BUS=virtio), boot_order=["iso"], interfaces=["default", "wireguard"], plus forced_hyp=false, favourite_hyp=false at top level.
  8. Starts the desktop so you can open the installer via isard view fedora-<variant>-<version>.
  9. Prompts Press Enter when installation is complete. Enter triggers stop → PUT /api/v3/domain/<desktop> with {hardware:{boot_order:["disk"], isos:[]}} (detach) → DELETE /api/v3/media/<id> (and the seed media if any). Ctrl-C leaves everything in place — re-running picks up the same media row.

Unattended install via --seed-url

isard template create --seed-url https://files.example.com/seed.iso

When --seed-url is set, between steps 6 and 7 above the CLI:

  • Registers the URL as a second media row named <desktop-name>-seed (reusing it if already Downloaded).
  • Threads the seed's media id into NewFromMediaRequest.seed_media_id.
  • POST /api/v3/desktop/from/media then attaches both ISOs: hardware.isos = [{id: installer}, {id: seed}]. The installer comes first because boot_order=["iso"] boots the first ISO entry.
  • During post-install cleanup (step 9), after detaching ISOs and deleting the installer media, the seed media is also deleted.

The seed ISO should have one of two volume labels (build it locally with isard seed cidata|oemdrv — see seed):

  • CIDATA for cloud-init NoCloud (Ubuntu autoinstall, etc.).
  • OEMDRV for Anaconda kickstart (Fedora / RHEL family).

Both labels are auto-discovered by the install target — no kernel command line tweaks needed, and IsardVDI's hardware schema has no extra-kernel-args field anyway.

Ready-to-edit starter configs (Ubuntu autoinstall + Fedora kickstart) live under examples/seed/. End-to-end runbook with expected cloud-init / Anaconda checks is at docs/phase3-verification.md.

All defaults are constants at the top of src/cli.rs — to expose them as flags again, add fields back to TemplateCreateArgs and pass them through to NewFromMediaRequest.

template promote

isard template promote <DESKTOP> [-n <NAME>] [-d <DESCRIPTION>] [--disabled]

Promote a stopped desktop into a reusable IsardVDI template via POST /api/v3/template. Resolves the desktop by name (fuzzy match through resolve_desktop), so you can pass any unique prefix.

  • <DESKTOP> — required. Name or ID of the source desktop.
  • -n, --name <NAME> — name for the new template (≤ 50 chars). Defaults to the desktop's name.
  • -d, --description <DESCRIPTION> — ≤ 255 chars.
  • --disabled — create the template disabled (default: enabled and immediately usable for isard create).

Pre-conditions enforced server-side: the source desktop must be in Stopped state AND its storage must be ready (i.e. the disk must have actually been written — a desktop created via template create --seed-url ... and then never booted is not a valid promote source; you'll get an HTTP 500 from upstream's storage_ready check). The CLI prints a warning if desktop.state isn't Stopped but leaves the call to the server, since IsardVDI may add or relax checks over time.

media

Subcommand group. With no action it defaults to list.

media list

isard media [--filter <SUBSTRING>]
isard media list [--filter <SUBSTRING>]

Calls IsardVdiClient::get_media, which merges /api/v3/media (owned) and /api/v3/media_allowed (shared) and dedupes by id. Prints a four-column table (NAME / STATUS / SIZE / OWNER) where OWNER is you or shared.

media add

isard media add <NAME> --url <HTTPS_URL>
                [-d|--description <TEXT>]
                [--no-wait]
                [--timeout <SECONDS>]   # default 1800
  • Validates name length (4-50) and that the URL starts with https://.
  • POST /api/v3/media with {name, url, "url-web": url, kind:"iso", description}. The endpoint returns {}, so the CLI looks the row up by name afterwards (find_media_by_name).
  • Unless --no-wait, polls /api/v3/media until the row reaches status="Downloaded" or status="Failed", surfacing progress/size/ status in the spinner.

media delete

isard media delete <NAME-OR-ID> [-y|--yes]
  • Resolves the target: exact id → exact name (case-insensitive) → substring match.
  • Shows a confirmation block (name/id/state, plus a warning if the media is marked shared). Skip with --yes.
  • DELETE /api/v3/media/<id>. 403 → "your role can't delete this media", 409 → "media is referenced by an existing desktop. Detach it first."

seed

Build a local cloud-init / kickstart seed ISO. Pure local operation — no auth, no IsardVDI API call. The resulting ISO has to be hosted at an HTTPS URL (e.g. on your own webserver) and then registered via media add --url, because IsardVDI ingests media by URL only (there is no multipart upload route).

The seed ISO carries unattended-install configuration that the booting guest auto-discovers from the volume label:

SubcommandLabelFile on ISOPicked up by
cidataCIDATAuser-data + meta-datacloud-init NoCloud datasource (Ubuntu autoinstall, Fedora Cloud Base, etc.)
oemdrvOEMDRVks.cfgAnaconda (Fedora / RHEL / AlmaLinux / Rocky installer)

ISO generation is pure Rust via the hadris-iso crate — no xorriso / genisoimage / mkisofs binary needed on the host.

seed cidata

isard seed cidata --user-data <FILE> [--meta-data <FILE>] -o <OUTPUT.iso>
  • --user-data — cloud-config YAML. Written as user-data on the ISO.
  • --meta-data — optional; if omitted, an empty meta-data file is added (cloud-init's NoCloud requires the file to exist but accepts it empty).
  • -o, --output — where to write the seed ISO.

Typical Ubuntu autoinstall snippet:

#cloud-config
autoinstall:
  version: 1
  identity:
    hostname: ubuntu-template
    username: alice
    password: "$6$..."   # mkpasswd -m sha-512
  ssh:
    install-server: true
    authorized-keys:
      - ssh-ed25519 AAAA... me@laptop
  packages: [vim, htop]

seed oemdrv

isard seed oemdrv --kickstart <FILE> -o <OUTPUT.iso>
  • --kickstart — kickstart file. Written as ks.cfg on the ISO.
  • -o, --output — where to write the seed ISO.

Anaconda automatically picks up ks.cfg from any volume labelled OEMDRV without needing inst.ks= on the kernel command line — handy because IsardVDI's hardware schema has no extra-kernel-args field.

seed publish

isard seed publish --kind <oemdrv|cidata> \
    --hostname <NAME> --username <USER> \
    [--password <PW> | --password-stdin] \
    [--ssh-key <KEY> | --ssh-key-file <FILE>] \
    [--locale <L> --keyboard <K> --timezone <TZ>] \
    [--server <URL>] [--token <TOKEN>] [--json]

POSTs form fields to the /api/seed endpoint hosted on isard.xtec.dev (or a self-hosted copy via --server) and prints the resulting https://…/seeds/seed-<hex>.iso URL on stdout. Same operation as the web form — server renders the kickstart / cloud-init template, SHA-512-crypts the password, writes the ISO under /home/deploy/isard/seeds/, and returns the URL.

  • --kindoemdrv (Fedora/RHEL Anaconda kickstart) or cidata (Ubuntu autoinstall / cloud-init NoCloud).
  • --hostname — RFC-1123 hostname for the new VM.
  • --username — Linux username to create on the new VM.
  • --password / --password-stdin — plaintext password (≥ 8 chars). Server hashes it. Prefer --password-stdin (e.g. printf '%s' "$PW" | isard seed publish --password-stdin …) so the password never appears in ps. Without either flag and a TTY, the CLI prompts via rpassword.
  • --ssh-key / --ssh-key-file — OpenSSH public key. Defaults to ~/.ssh/id_ed25519.pub then ~/.ssh/id_rsa.pub.
  • --locale, --keyboard, --timezone — OEMDRV only. Server defaults: en_US.UTF-8, us, Europe/Madrid.
  • --server — origin override (env ISARD_API). Default https://isard.xtec.dev.
  • --token — bearer token (env ISARD_SEED_TOKEN).
  • --json — print the full response (url, filename, size_bytes, cmd) instead of just the URL.

Idempotent: re-submitting the same fields returns the same URL and does not duplicate the file on disk.

Compose with template create:

URL=$(printf '%s' "$PASSWORD" | isard seed publish \
    --kind oemdrv \
    --hostname fedora-template \
    --username fedora \
    --password-stdin)
isard template create --seed-url "$URL"

End-to-end workflow (manual today)

  1. isard seed cidata --user-data my.yaml -o seed.iso (or seed oemdrv).
  2. Host seed.iso at an HTTPS URL your IsardVDI deployment can reach. Each user-data file may carry SSH keys or hashed passwords, so prefer unguessable paths and short-lived hosting over a public web root.
  3. isard media add my-seed --url https://your-host/seed.iso.
  4. Attach both installer ISO and seed ISO when creating the template. Multi-ISO attach via hardware.isos is structurally supported by the API (schemas/snippets/hardware.yml defines isos as an unbounded list) but is not yet wired into isard template create — that's Phase 2 (see TODO.md).

create

isard create <NAME>
             [-t|--template <NAME-OR-ID>]
             [-d|--description <TEXT>]
             [-c|--category <UUID>]
             [--filter <SUBSTRING>]

Creates a desktop from an existing template via POST /api/v3/persistent_desktop. Distinct from template create, which provisions a new template by downloading and installing an OS.

Selection priority for --template (same as resolve_desktop): case-insensitive id → case-insensitive exact name → Jaro-Winkler fuzzy match (≥ 0.5) → substring match. A typo like fedoa-server still resolves to fedora-server-42. If --template is omitted, an interactive numbered list is shown (optionally filtered by --filter or --category).

Hardware defaults (set inside IsardVdiClient::create_desktop): 2 vCPU, 4 GB RAM, boot_order=["disk"], disk_bus="default", interfaces=["default","wireguard"], videos=["default"].

start <NAME>

isard start <NAME>
            [-w|--wait]
            [--timeout <SECS>]          # default 120
            [-b|--book]
            [--book-minutes <MINUTES>]  # default 60
  • Calls GET /api/v3/desktop/start/{id}.
  • 428 means a booking is required. With --book, the CLI calls POST /api/v3/booking/event for --book-minutes starting now, then retries start. Without --book it prints a friendly error pointing to the web UI.
  • --wait polls get_desktops until state == "Started" or --timeout expires.

stop <NAME>

isard stop <NAME> [-w|--wait] [--timeout <SECS>]

Calls GET /api/v3/desktop/stop/{id}; --wait polls for state=="Stopped".

delete <NAME>

isard delete <NAME> [-y|--yes] [--permanent]
  • Default: DELETE /api/v3/desktop/{id} (moves to trash, recoverable).
  • --permanent: DELETE /api/v3/desktop/{id}/permanent (irreversible).
  • Confirmation prompt unless --yes. Permanent deletion uses a stricter prompt (default=false).

view <NAME>

isard view <NAME>
           [--install / --no-install]   # default true
           [--debug]
  • Resolves desktop name (exact → fuzzy → substring).
  • If the desktop isn't Started, starts it automatically (waits for the transition) before retrieving the SPICE file. No flag needed — this is the default since v0.1.7.
  • GET /api/v3/desktop/{id}/viewer/file-spice → SPICE file. Embedded content JSON field is decoded and the certificate is reformatted for remote-viewer.
  • Installs virt-viewer if missing (unless --no-install): MSI on Windows, Homebrew on macOS, dnf on Fedora, apt on Debian/Ubuntu.
  • Launches remote-viewer with the SPICE file.

ssh <NAME>

isard ssh <NAME>
          [-l|--user <USER>]
          [-i|--identity <PATH>]
          [--password <PASS>]
          [--timeout <SECS>]            # default 120
          [--dry-run]
  • Resolves SSH private key: --identity~/.ssh/id_ed25519~/.ssh/id_rsa → generate a new ed25519 key.
  • Reads/writes the bastion config via GET/PUT /api/v3/desktop/bastion/{id}. Adds the public key to authorized_keys (idempotent).
  • Bastion runs on the IsardVDI hostname, port 443; the SSH "username" is BastionTarget.id (a UUID). The Linux account on the VM is whichever user is recorded in guest_properties.credentials.username.
  • If the desktop isn't Started, starts it automatically before connecting — no --start flag needed since v0.1.7.
  • Guest-credentials confirmation + cache. On the first ssh <name>, the CLI fetches guest_properties.credentials and prints user: … and password: aa****zz (half-masked) for the user to confirm. On Yes the desktop's id is added to ~/.config/isard/desktop-creds.json (0o600) — a tiny {"confirmed":[…]} set — so future runs skip the prompt entirely (no API fetch, no display). The file stores no secrets: only the set of desktop ids whose creds the student has already approved. On No the user is prompted for new credentials which are pushed via the --password flow below. isard logout wipes the file. If the system ssh exits 255 (auth/connection failure), the entry for that desktop is dropped so the next run reprompts.
  • --password <PASS> updates the desktop's guest credentials to (--user or "user", --password) before connecting. Flow: if the desktop is running, stop it (stop_desktop + wait for Stopped); then GET /api/v3/domain/info/{id}, mutate guest_properties.credentials, PUT /api/v3/domain/{id} with the full guest_properties block (only the top-level key is merged server-side); then start the desktop and wait for Started. The new credentials replace the cache. --dry-run still performs the credential update — it only suppresses the final SSH exec.
  • --dry-run prints the SSH argv instead of executing.
  • Embedded SSH fallback. If the system ssh binary is not on PATH, the CLI uses an embedded pure-Rust SSH client (russh) so Windows machines without OpenSSH can still connect. Key auth only, trust-on-first-use host keys, no window-resize forwarding.

Desktop name resolution

resolve_desktop in cli.rs, used by start, stop, delete, view, ssh:

  1. Exact match (case-insensitive).
  2. Best fuzzy match (strsim::jaro_winkler, cutoff 0.5).
  3. First substring match (case-insensitive).