Skip to content
Niche-Knack-AppsPublic

About

A k9s-style terminal UI for Zarf, the airgap-native package manager for Kubernetes. One static binary. By Niche-Knack — https://nk.tools

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

z9s

z9s logotype and the Zarf axolotl

ci release license

Made by Niche-Knack — Smart Tools. Sharp Solutions.

A k9s-style terminal UI for Zarf packages. Single static binary. Stable as of v1.0.0 — semver from here on.

z9s does not shell out to zarf — it links Zarf in as a Go library, so the tool is Zarf. One version, no sidecar to extract, and structured output instead of scraped terminal text. Native CLI operations honor context cancellation; TUI deployments and removals run as cancellable tasks with captured logs.

What is Zarf?

Zarf (GitHub, docs) is the free, Apache-2.0 airgap-native package manager for Kubernetes. You build a Zarf package while connected — it gathers the container images, Helm charts, manifests, Git repositories and files an application needs into one declarative, compressed file — then carry that single file into a disconnected network and deploy it the same way every time. zarf init gives a cluster its own registry and Git server so nothing reaches back out to the internet; packages can also be signed, carry SBOMs, and move through OCI registries.

Zarf already ships k9s for watching a cluster. z9s is the same idea pointed at Zarf itself: browse the packages on disk and in the cluster, inspect them, and build, deploy, and remove them from one keyboard-driven terminal UI.

Install

Download the binary for your platform from the latest release — z9s_linux_amd64, z9s_linux_arm64, z9s_darwin_amd64 or z9s_darwin_arm64 — check it against SHA256SUMS, and put it on your PATH:

curl -LO https://github.com/Niche-Knack-Apps/z9s/releases/latest/download/z9s_linux_amd64
curl -LO https://github.com/Niche-Knack-Apps/z9s/releases/latest/download/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
install -m 0755 z9s_linux_amd64 ~/.local/bin/z9s

It is one static file with no runtime dependencies, so it can be carried to an air-gapped host as-is.

Build

Requires Go 1.26.5+. The build stamps the version from git describe.

make build      # -> ./z9s
make check      # gofmt, vet, tests — what CI runs
make release    # amd64 + arm64 for Linux and macOS into dist/

Use

z9s <path>...           # interactive UI
z9s --read-only <path>  # browse without deploy/remove actions
z9s ls <path>...        # scan paths and print the catalog
z9s status              # Zarf version skew between this binary and the cluster
z9s build <dir>         # build a package
z9s deploy <path|part000|https://|oci://> [--components a,b] [--values values.yaml] \
  [--set-values .key=VALUE] [--set-variables K=V]
z9s zarf <args>...      # the full embedded Zarf CLI

On start z9s shows a full-screen splash (the mascot, the version, and live scan progress) while the first catalog scan runs, then the k9s-style chrome: an info panel (folder, package, kube context, Zarf local/cluster revisions), a persistent key legend, the block logotype with the mascot at the right, and a bordered viewing area whose title always names the view, its active filter, and its row count — packages(/harbor)[3]. A new / filter replaces the previous one; it never stacks.

Keys: : command (completes inline — Tab/→ accepts, ctrl-f one word), / filter, ? help — tabbed: keys, commands, and config (an annotated example of config.yaml with every setting and environment variable defined), ctrl-e hide/show the top panel (more rows on small terminals), Enter drill in, esc back, :q quit. The selected row and focused form field use the mascot's zarf blue with bold black text. e opens the package's zarf.yaml in $EDITOR (suspending the TUI, reloading on return), v reviews and fills deploy variables straight from the catalog, and :fs is an ncdu-style filesystem navigator — enter/l descend, h up, ~ home, o opens a directory as the new catalog root. In the component view: space or enter toggles, a all, n none, r reset to Zarf's defaults, i edit deploy inputs, ctrl-y show the equivalent CLI. The header carries a permanent legend of the current view's bindings, and the component breadcrumb shows the live selection count (harbor 4/7). The Inputs view combines legacy Variables and typed Package Values; f manages ordered values files and V validates variable patterns and the merged values schema. Use o for advanced namespace, timeout, retry, architecture, cache, checksum, verification, remote-transport, and connected-mode controls; unsafe bypasses are called out in the final plan. Press d to review a local archive deployment plan, then D and type the package name to confirm. :tasks shows progress/logs and c requests cancellation. :deployed reads Zarf's package deployment records from the current cluster and shows namespace overrides, generations, components, charts, connect metadata, and recorded status. On a deployed row, ctrl-d opens a full or partial component-removal plan. It shows Zarf's reverse removal order and requires R plus the exact package name before submitting the task; Zarf's incremental deployment-record updates make failed or canceled removal resumable. Catalog metadata inspection is not a trust decision; the deploy loader performs Zarf's if-possible signature policy, where an invalid present signature fails and an unsigned package is allowed.

:available is the default source browser. It gives each local definition and built archive its own row, shows signature declarations and SBOM presence without presenting either as verification, and lists configured OCI/HTTP sources. Use r to rescan and s/S to change/reverse sorting. Remote sources are configured as exact references in ~/.config/z9s/config.yaml; their metadata remains explicitly unresolved until fetched:

catalogRoots: [/srv/zarf-packages]
packageSources:
  - oci://registry.example/team/package:1.2.3
  - https://packages.example/package.tar.zst

Set readOnly: true in the same config, or pass --read-only, to remove cluster-mutating hints and enforce the same capability policy at their key and confirmation handlers. The header keeps the mode visible.

Press Enter on a remote row to review the transfer, then L to load it as a cancellable task. OCI review pulls Zarf metadata layers only; HTTP tar archives must be downloaded in full. A successful load enters the same component and input workflow as a local archive, and deployment re-loads and re-verifies the exact remote reference. Successful deploy tasks asynchronously refresh the cluster-backed :deployed records.

On a local definition row, c opens package creation. The wizard covers create-time template values, flavor, differential base, registry rewrites, signing, package splitting, SBOM generation/export, cache and OCI concurrency, build-machine metadata, local/OCI output, and transport/version safety controls. The plan shows template keys and only whether a signing password is set; secret values remain masked in memory. Press C, then type CREATE <package-name> to submit the cancellable task. The source browser refreshes after success.

Performance

Scan results are cached in ~/.cache/z9s/packages.json:

  • composed definitions, fingerprinted over every zarf.yaml at or below the package (so editing an imported child invalidates its aggregator), plus the linked Zarf version and flavor
  • archive inspections, fingerprinted by size + mtime — decompressing built .tar.zst archives (some are gigabytes) dominates a cold scan

Measured on the full infrastructure tree (326 sources, 66 archives): cold 3m00s, warm 0.6s. The cache is disposable; delete the file to force a full re-scan. During a cold scan the walk and load phases report progress on the splash screen (stderr for z9s ls).

Where the binary lives matters more than anything above. z9s links all of Zarf (~190 MB); cold-loading that from a spinning disk takes ~45 seconds at 1% CPU before main() even runs — indistinguishable from a hang. Run the installed copy on fast storage (make install → ~/.local/bin/z9s). Every launch writes its timeline to ~/.local/state/z9s/startup.log, whose first line (exec-to-main) shows exactly this cost; z9s also flashes a warning when it detects it.

--debug (or Z9S_DEBUG=1) appends timing telemetry to ~/.local/state/z9s/debug.log — scan phases, slow package loads, editor sessions — for diagnosing lag with data instead of guesses.

Registries

:reg manages the OCI registries z9s knows: a adds one (name, endpoint, plain-HTTP / TLS-skip flags — saved once to registries: in the config), p pins the default, ctrl-d deletes the z9s entry (never the registry itself), and enter browses read-only: repositories, then tags, with / filtering live at each level. The built-in cluster (zarf) row tunnels to the in-cluster registry with Zarf state's read-only pull credentials. Configured registries authenticate via your docker login keychain.

Deploy options — the editable command

Press o on a deploy plan (or the variables view) for the options sheet: a grouped toggle list — general, transport & verification, and expert flags in warning colour — over the editable equivalent command. The two stay in sync: toggling a row rewrites the command; tab (or a mouse click) moves into the command itself, where any value can be edited inline and any real zarf package deploy flag is legal, not just the listed rows. Enter applies — the line is parsed by the embedded Zarf command's own flags, so types, aliases, and deprecations match the CLI exactly. A bad line never half-applies: the error names the offending token, the last good line is restored, and focus returns to the options. Sensitive variable values render as *** and keep their real value unless replaced. The plan shows a one-line options: n changed delta. Set mouse: false in the config if capture fights your terminal's text selection.

Every deploy is remembered as a profile — the exact command line, keyed by package and kube context, in the XDG state dir (never config.yaml, never a zarf-config.yaml). The next plan for that package pre-seeds from it: components, variables, values, options. Sensitive values are never persisted (the stored line carries the mask, stripped on seeding). x on the plan forgets a profile.

Within a session, a package's working state — components, variables, values, options — is one draft shared by every way in: v from the catalog, d from anywhere, enter into components. Set a variable under v, press d, and it is still set. Drafts live until z9s exits (they may hold sensitive input, so they are never written anywhere); the profile is the durable record of a deploy that actually ran. Editing a package's zarf.yaml resets its draft, since the old selection no longer describes the package.

A running task's log follows its tail like tail -f. Scroll up and it stays where you put it; G or End resumes following.

Version skew

The -zarf-XXXXXXXX suffix Zarf appends to image tags is derived from the image name; the Zarf CLI version is not an input to that CRC. A local/cluster version mismatch is still useful compatibility information: review Zarf upgrade notes and package version requirements before changing cluster state.

z9s reads the cluster's version from the agent-hook deployment's image tag and shows the relationship permanently in the header:

$ z9s status
z9s        0.2.0
zarf       v0.83.0  (linked)
cluster    v0.62.0  ✗ SKEW
agent      127.0.0.1:31999/zarf-dev/zarf/agent:v0.62.0

No cluster, or Zarf not initialized on it, is a normal state — z9s stays fully usable for browsing packages with nothing to connect to.

Resolving skew

z9s init deploys the init package matching the linked Zarf, which upgrades the cluster's Zarf and re-seeds the registry via the injector — so it also recovers a registry wedged in ImagePullBackOff (it pulls its own image from itself, and cannot start unaided).

Preflight is the default; nothing happens without --confirm:

$ z9s init --search-dir /path/to/init-packages
INIT PLAN
  init package   .../zarf-init-amd64-v0.83.0.tar.zst
  package ver    v0.83.0
  z9s zarf       v0.83.0
  cluster now    v0.62.0  ✗ skew → will become v0.83.0

  warnings:
    • this UPGRADES the cluster's Zarf from v0.62.0 to v0.83.0: the registry,
      agent and injector are redeployed
    • already-deployed packages are not redeployed or otherwise changed

this is a plan only — re-run with --confirm to execute

:init shows the same plan in the TUI. Press I, then type INIT <linked-zarf-version> exactly, to execute it as a cancellable logged task. Read-only mode removes that action. Two guards apply: an init package whose version differs from the linked Zarf is refused (initializing with it would just recreate the skew) unless --allow-version-mismatch is passed, and the package must be kind: ZarfInitConfig.

Why the init package is pointed at, not bundled

The init package is ~420 MB — more than double this binary — and the great majority of z9s sessions never init anything. More importantly it is site-specific: it is built locally with a zarf-config.toml pinning the registry, gitea and socat image tags. A bundled copy would ship images that contradict that config. Upstream Zarf does not bundle it either.

So z9s locates it, and makes locating it a one-time cost:

  • searched in the working directory, next to the binary, in the Zarf cache, in the catalog roots, and in any remembered directory
  • a directory where one is found is remembered in ~/.config/z9s/config.yaml, so later runs need no arguments
  • a not-found error lists every path tried
  • press p on the :init plan (or run z9s init --list / --remember) to choose among every discovered init package — the stock one, older versions, or a custom build with site-specific components — and pin it as initPackage: in the config. The choice is made once; every later init uses it without asking. Z9S_CONFIG_DIR overrides the config location, mirroring k9s's K9S_CONFIG_DIR.
  • air-gap posture: z9s never downloads an init package. Upstream zarf init falls back to fetching from GitHub when none is found; z9s searches local disk (or an internal oci:// reference you configured yourself) and fails loudly otherwise.
  • --init-package accepts an oci:// reference, which is the better answer across several nodes: publish the init package to the internal registry once and pull it anywhere, instead of copying 420 MB around. Set initPackageRef in the config to make it the default.

z9s keeps its own config rather than writing a zarf-config.yaml, because Zarf resolves that from the working directory — a stray one at a repo root silently applies to every package built beneath it.

In the TUI, :init shows the plan and I plus a typed INIT <version> confirmation runs it as a cancellable task with a captured log (:tasks). z9s init --confirm remains the CLI path.

z9s ls output columns: package name, version, component/variable/image counts, architecture of built artifacts, and load status.

Status Meaning
ok Loaded via Zarf's canonical loader: imports composed, flavors applied, validated
raw The loader refused it (usually a lint error) but the YAML parsed. Listed so it can be fixed. Imports are not composed
broken The YAML could not be parsed at all. Still listed so it can be found
artifact A built archive not uniquely linked to a source definition; assembled metadata remains inspectable

The raw tier exists because Zarf's loader is fail-closed on lint errors — a browser that hides broken packages is useless exactly when you need it.

Why it embeds Zarf

Component actions frequently call ./zarf tools kubectl and ./zarf tools yq. When Zarf runs as a library, those must resolve to something real, so z9s sets ActionsUseSystemZarf=false and ActionsCommandZarfPrefix="zarf" and mounts Zarf's whole command tree under z9s zarf. That is why the binary is large: it contains the same package, cluster, registry, Git, signing, and inspection code as the corresponding Zarf release.

Layout

cmd/z9s          entrypoint; cobra root, mounts the embedded Zarf CLI
internal/zarfx   the ONLY package that imports Zarf — facade over the library
internal/ui      the nines shell (github.com/Niche-Knack-Apps/nines), aliased + z9s art
internal/view    screens
internal/config  XDG config

internal/zarfx is a deliberate boundary: Zarf's library API is pre-1.0 and the v1alpha1 → v1beta1 schema migration is in flight, so a Zarf bump stays a single-package change.

Releases

Every merge to main runs make check. When the merge carries a new version in VERSION, the release workflow cross-compiles static amd64 and arm64 binaries for Linux and macOS, tags vX.Y.Z, and publishes the binaries with SHA256SUMS as a GitHub release. A pull request that does not bump VERSION fails its check, so every merge ships.

Logo

The header above is the same art z9s draws in the terminal (internal/ui/logo.go), rendered to SVG by hack/art-to-svg.py so the colours survive Markdown. docs/logo-large.svg is the splash mascot. Regenerate after editing the art: python3 hack/art-to-svg.py docs/logo.svg && python3 hack/art-to-svg.py docs/logo-large.svg --large.

Support

z9s is free and open. Its terminal shell is nines, also open. Niche-Knack also makes small, sharp, offline-first apps — see them at nk.tools.

Really believe in this project's potential and want to contribute to its development? Buy Me a Coffee.

License

Apache-2.0. See LICENSE and NOTICE.

About

A k9s-style terminal UI for Zarf, the airgap-native package manager for Kubernetes. One static binary. By Niche-Knack — https://nk.tools

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages