jjx exists for one job: run integration tests for a small Kubernetes charm from local source with real ops and real Pebble, without a Juju controller.
The primary interface is the jjx command, run from the charm project directory:
jjx # run tests, then wait (Ctrl-C to tear down)
jjx -d # run tests, then detach (container stays up; use 'jjx down' to tear down)
jjx down # tear down all models and remove runtime state
Optional flags:
-p HOST:CONTAINER— publish a container port to127.0.0.1:HOST
jjx handles the full lifecycle: preflight cleanup of any stale state, a Docker availability check, creating and removing the placeholder charm artifact, invoking pytest, and tearing down on exit. After pytest finishes, jjx checks whether the charm's workload container is running (virtual charm containers don't count). If it is, jjx waits for Ctrl-C (or detaches with -d); if not, jjx tears down all models and exits.
jjx's own errors and warnings share one format: an Error: or Warning: prefix, a capitalized first word unless that word is a proper name (charm-user, pytest-jubilant), and a trailing period. The juju shim's messages are outside this rule. They follow real Juju's shapes, because jubilant carries their stderr verbatim into the exceptions it raises.
Additional pytest arguments can be appended to jjx's built-in defaults in the charm project's pyproject.toml:
[tool.jjx]
pytest-extra-args = ["-k", "test_deploy"]Extra pytest arguments can also be passed on the command line after --:
jjx -d -- -k test_deploy
Arguments are assembled in this order:
jjx's built-in defaults (tests/integration --no-juju-teardown -v)pytest-extra-argsfrompyproject.toml- extra args from the command line (after
--)
Command-line args come last so they take precedence over pyproject.toml — matching the convention that command-line options override configuration file defaults. jjx always controls the test directory and teardown behavior; both pytest-extra-args and CLI args are appended after the built-in defaults. For full control, use uv run directly (see launch modes below).
jjx invokes pytest via uv run --group integration pytest tests/integration --no-juju-teardown -v [<pytest-extra-args>] [<cli-extra-args>]. How jjx makes itself available to the inner uv run depends on how it was launched:
- Charm venv (user added
jjxto their charm's dependencies and runsuv run jjx):--python <venv>— pin uv to the current interpreter so the charm's existing venv (which already hasjjx) is reused. This mode requires the charm'srequires-pythonto be within jjx's range: uv resolves across the charm's fullrequires-pythonrange. - Local checkout (developer running
uvx --with-editable <repo> jjx):--with-editable <path>— install the same source into the inner venv. - Tool install (user ran
uv tool install jjx): no injection — thejujushim is already onPATHwith a hardcoded shebang pointing at the tool's Python, which has the correctjjx. The inneruv runonly needs the charm's integration dependencies.
The test fixture only needs a .charm file to exist; jjx creates one automatically before running pytest and removes it afterwards. jjx treats it as a deploy trigger and does not unpack it.
No project dependency changes are required. jjx injects itself (or relies on the tool-installed juju shim) and assumes the project already provides its normal test dependencies.
The pytest tree runs in its own session, so a terminal Ctrl-C reaches jjx alone:
jjxrecords the press without raising. Launchers such as uvx relay a duplicate back within milliseconds. Only a press at least a quarter-second later counts as a second one.jjxSIGKILLs theuvwrapper, which would otherwise forward a second interrupt to pytest and never exit on its own.jjxrelays one SIGINT to the tree and waits up to 60 seconds for it to exit. pytest prints its report and runs fixture teardown, which for a failed session includes pytest-jubilant's model debug-log dump.- Only after the tree has exited does
jjxdestroy the models, so the debug-log dump always finds them. A blank line separates the teardown output from the test report. It exits 130, the standard Ctrl-C code.
A second Ctrl-C, or closing the terminal, SIGKILLs the tree immediately instead of waiting. A relayed interrupt also reaches any in-flight juju shim call, which exits 130 rather than printing a traceback.
Supported:
- single application
- single unit (
app/0) - Juju 4.x only — jjx reports a pinned 4.x version (see
juju versionbelow) and emits Juju 4 JSON field names. It is not tested against Juju 3.x and does not try to be cross-version. - deploy via a
.charmargument interpreted as a trigger to run local./src - config updates and status reporting
- hook tools needed by the charm
- real Pebble in Docker
- relations with virtual charms (see below)
- multiple models (e.g. a charm model and a COS model)
- cross-model relations via
juju offerandjuju integrate <model>.<app> - virtual bundles (e.g.
juju deploy cos-lite) juju run(actions) on real and virtual charms- app-managed and user secrets, including
secret-changedevent dispatch
Not supported:
- peers or subordinates
- multi-unit behavior
- controller features beyond this test niche
If a charm needs any of the above, use real Juju.
jjx can recognize certain well-known charm names as "virtual" charms. A virtual charm has no charm code — jjx manages its workload and relation data directly.
All virtual charms are registered in a central registry (_virtual_registry.py) that specifies their start function, relation populate function, endpoint metadata, display name, and teardown priority. Adding a new virtual charm requires only one registration call — no other file needs to change.
Currently supported:
postgresql-k8s— starts a real PostgreSQL 16 container and provides thepostgresql_clientinterface. The database is created lazily at integrate time, using the database name the requirer requests (falling back to the requirer's app name), so charms that request a name other than the default receive credentials for the right database.loki-k8s— starts a real Loki container and provides theloki_push_apiinterface. Workload logs flow via real Pebble log-targets.prometheus-k8s— starts a real Prometheus container (with--web.enable-lifecyclefor config reloads) and consumes theprometheus_scrapeinterface. Configures itself from the charm's relation data.grafana-k8s— starts a real Grafana container and consumes thegrafana_dashboardinterface. Provisions Prometheus and Loki as datasources (via file-based provisioning, applied with a container restart since Grafana does not re-provision datasources on SIGHUP), imports dashboards from relation data, and injects datasource variables (${prometheusds},${lokids}) into imported dashboards so Grafana can resolve the placeholder UIDs — mirroring what the real grafana-k8s charm does.traefik-k8s— a state-only virtual charm (no container) that responds to theshow-proxied-endpointsaction with the URLs of other COS charms.
Virtual bundles (e.g. cos-lite) deploy multiple virtual charms in one juju deploy command.
Virtual charm containers are named <model>-<app> and are cleaned up on model teardown. The real charm's workload container is named <model>-<container> (using the container name from charmcraft.yaml, not the app name). Teardown order is determined by each charm's teardown_priority: COS containers (grafana=10, prometheus=20, loki=30) are removed first, then postgres (40), workload (50), and charm runners (60).
jjx is a short-lived CLI. Each command starts, reads or updates state, performs work, and exits.
State is local to the project working directory.
Charm code is executed from ./src/ inside a persistent Docker container (the "charm runner") that shares the workload container's network namespace. The Python interpreter is bind-mounted from the host uv environment.
The .charm file passed to deploy is a trigger only. jjx does not inspect or extract it.
The charm runner always runs as 170:170. jjx never runs charm code as root. What the charm declares determines whether jjx warns or refuses to run:
charm-user declaration |
Charm runner | jjx behavior |
|---|---|---|
non-root |
170:170 |
silent |
| unset | 170:170 |
warning |
root |
— | jjx refuses to run |
sudoer |
— | jjx refuses to run |
Workload containers follow their own declarations, with no dependence on the charm-runner rules:
Workload declaration (uid:/gid:) |
Workload user |
|---|---|
| set | --user <uid>:<gid> |
| not set | no --user; the image's declared user applies, or root if the image declares none |
jjx follows any declared uid/gid value, with one guard: values in 1000–9999 are rejected at deploy, matching Juju's reserved range.
This diverges from real Juju, which runs charm code as root when charm-user is unset and forces the workload to root in that case too. The divergence is deliberate: it catches, before the charm declares anything, any dependence on the legacy behavior — a dependence that would break on real Juju the moment the charm sets charm-user: non-root. jjx shows the charm its destination, not its legacy.
Virtual charms are exempt: they have no charmcraft.yaml and no charm code, so the gate never runs.
The jjx CLI enforces the gate at preflight, before pytest starts: root/sudoer fail with a clean error, and unset prints the warning. Deploy re-checks as a backstop for direct shim use, but its message would otherwise be lost — under jjx the deploy subprocess runs inside the pytest tree, whose stderr capture turns a deploy-time failure into tracebacks from every test instead of one clean error.
Two mechanisms make the 170 runner work. First, Pebble's socket grants write access only to the daemon's own UID, root, or a seeded identity, so deploy passes pebble run --identities a file granting UID 170 admin access; without it the charm's Pebble writes are silently read-only. The file also grants the host user admin access. Host-side tools over .jjx/socket need it (borescope's ps, for example, uses the admin-only files API), and it grants nothing the host user doesn't already have: they create and destroy the containers and can docker exec into them, so the socket was never a boundary against them. Second, UID 170 owns nothing on the host, so the shared state directories under .jjx/ are group-writable and both containers run with the host group added, letting the runner and the workload's Pebble daemon write files the host can still read and remove.
jjx writes project-local state to ./.jjx/:
./.jjx/.gitignore./.jjx/state.json./.jjx/hook-tools/./.jjx/charm/(staged runtime charm directory withsrc/,lib/,metadata.yaml,config.yaml, and.unit-state.db)./.jjx/socket(Pebble API Unix socket, bind-mounted into both the workload and charm runner containers)./.jjx/<app>.<pid>.deploy(marker files for in-flight background pebble-ready processes; created by deploy, deleted by the process on completion or by teardown)./.jjx/action-<uuid>.json(transient per-action results file; created byjuju run, read/written by the action hook tools, deleted after the hook exits)./.jjx/prom-config-<app>/(Prometheus config directory, bind-mounted into the Prometheus container)./.jjx/grafana-config-<app>/(Grafana provisioning directory, bind-mounted into the Grafana container)
jjx caches the Pebble binary at ~/.cache/jjx/pebble-bin-<version>, where <version> is the Pebble release tag pinned in jjx/_version.py. The binary is downloaded from the canonical/pebble GitHub Release for that tag on first use. The cache is shared across projects and persists across model teardowns to enable reuse across multiple deployments. Bumping the pinned version causes a fresh download (the old versioned cache entry is left behind).
The pinned Pebble version is not the latest Pebble release. Juju builds the Pebble binary it injects into containers from the github.com/canonical/pebble pin in its go.mod, so jjx pins the same version Juju pins at the JUJU_VERSION release tag. Charms under test then see the same Pebble API surface as under real Juju. A scheduled workflow (.github/workflows/latest-juju.yaml, weekly) checks the Snap Store's 4/stable channel for a newer Juju, derives the matching Pebble pin, and opens a PR if the Juju version has moved.
Notes on generated runtime files:
- Pebble runtime files are created inside the workload container under a jjx-managed state path:
./.jjx/pebble/(mounted at/jjx/pebblein the container). Any baked-in Pebble layers from the OCI image (e.g. Rockcraft layers at/var/lib/pebble/default/layers/) are copied into this directory before Pebble starts, so service definitions likestartup: enabledare preserved for charm layers usingoverride: merge. ./.jjx/pebble/identities.yamlis written by deploy and passed topebble run --identitiesat workload startup. It grants admin access to UID 170 (the charm runner) and to the host user. The host identity keeps host-side tools over.jjx/socketworking for any workload user: the files API, which tools like borescope use, is admin-only. (See "container users" above.)./.jjx/socketis the Pebble API Unix socket. It is bind-mounted into the workload container (where Pebble creates it) and into the charm runner container (where charm code and hook tools connect to it).JJX_STATE_DIRis set to/jjx(the in-container mount point of.jjx/) in the charm runner environment. Hook tools call back intojjxto read and write state; this env var lets them locate state directly../.jjx/charm/.unit-state.dbis created by charm runtime state persistence (written byopsviasqlite3toJUJU_CHARM_DIR/.unit-state.db, which inside the charm runner is/charm/.unit-state.db).
When a model is torn down, jjx kills any background pebble-ready processes for that model's apps (via .deploy marker files), removes its containers in priority order, and removes the model from state. When the last model is torn down, the entire ./.jjx/ directory is removed. The ~/.cache/jjx/pebble-bin-<version> cache is kept for reuse across subsequent deployments.
The charm runner is a persistent Docker container that executes charm hooks.
Image: docker.io/ubuntu/dotnet-deps:8.0-24.04_stable — a chiseled Ubuntu image containing only the runtime libraries (glibc, libssl, libz, ca-certs) needed to run Python. No shell, no coreutils, no Python, no Pebble — everything is bind-mounted from the host. Because there is no /bin/sh, hook tool scripts use a direct Python shebang (#!/python/bin/python3.XX) rather than #!/bin/sh.
Naming: <model>-operator (e.g. jjx-default-operator). Like the postgres container naming, this uses a fixed suffix with no app name.
User: Always 170:170. See "container users" above.
Network: The charm runner uses --network=container:<workload>, sharing the workload container's network namespace. This means charm code that connects to loopback addresses (127.0.0.1, localhost, ::1) reaches the workload container directly.
Bind mounts (all read-only unless noted):
- Host Python (from
uv) →/python(ro) - Host venv site-packages →
/venv(ro) - jjx package source →
/jjx-src(ro) — the parent of thejjxpackage directory, soimport jjxworks for both dev checkouts (<repo>/src) and installed tools (site-packages) .jjx/charm/→/charm(rw).jjx/→/jjx(rw)
Environment: The charm runner container is started with PYTHONPATH set to /venv/lib/python3.XX/site-packages:/jjx-src:/charm/lib, along with HOME=/tmp (the chiseled image has no passwd entry for UID 170) and PYTHONDONTWRITEBYTECODE=1 (170-owned bytecode under the bind-mounted /charm would break restaging). However, docker exec does not inherit environment variables from docker run, so jjx passes PATH, PYTHONPATH, HOME and PYTHONDONTWRITEBYTECODE explicitly via docker exec -e for each hook execution. This ensures both the charm process and its hook tool subprocesses can find jjx and the hook tools.
Pebble socket: A symlink is created inside the charm runner at /charm/containers/<workload>/pebble.socket → /jjx/socket, so ops can find the Pebble socket at the path it expects. Deploy pre-creates the parent directory host-side, so it stays owned (and removable) by the host; the background pebble-ready subprocess then creates the symlink via Python (os.symlink) because the charm runner image has no mkdir or ln in PATH. The symlink is not created at container startup or during config-changed — it is created by the background pebble-ready subprocess, after the minimum deploy delay (see "async pebble-ready" below). This models real Juju, where config-changed fires before the workload container has started, so the charm cannot reach Pebble yet. The charm's _replan_workload hits a ConnectionError and returns early, just like it does in real Juju.
Entrypoint: The container runs python -c 'import time; time.sleep(999999)' — it stays alive doing nothing. Charm hooks are executed via docker exec.
Teardown: The charm runner is removed last during model teardown (priority 60), after COS containers, postgres, and workload containers. This ensures it is available for any cleanup hooks that might need it.
Exact sequence:
- user runs
jjx(oruv run jjxif jjx is a charm dependency) jjxinvokesuv run --group integration pytest tests/integration --no-juju-teardown -v(with launch-mode-appropriate args to makejujuavailable — see "under the hood")uvprepares an environment with the charm's test dependenciespytest(viajubilant) invokesjuju ...commands- those commands execute
jjx'sjujushim (from the charm venv, the inner venv, or the tool install, depending on launch mode) - for hook events,
jjxrunsdocker execinto the charm runner container - inside the charm runner,
jjxruns/charm/src/charm.pyusing the bind-mounted Python - the Python interpreter is bind-mounted from the outer
uvenvironment (no nesteduv runper hook) - hook tools are Python scripts (with a
#!/python/bin/python3.XXshebang) thatimport jjxdirectly — they run as subprocesses of the charm process, inheriting itsPATHandPYTHONPATH - charm code interacts with hook tools and Pebble, then exits;
jjxpersists resulting state
Deploy flow:
- ensure
./.jjxexists and load state - stage runtime charm files in
./.jjx/charm/(src/,metadata.yaml,config.yaml) —charm-useris staged intometadata.yaml(where ops reads it), defaulting tonon-rootwhen charmcraft.yaml leaves it unset - gate on
charm-userfrom charmcraft.yaml (see "container users" above): thejjxCLI has already rejectedroot/sudoerat preflight; deploy re-checks as a backstop for direct shim use, and the unset warning has already printed at preflight too. - pre-pull the workload image with retry (see "image pull retries" below), then start workload container and Pebble with explicit
--network bridge(no host networking)- Pebble is started with
run --hold --create-dirs --identities /jjx/pebble/identities.yamlso baked-in layers (e.g. Rockcraft layers withstartup: enabled) do not autostart services before the charm's pebble-ready hook fires — matching real Juju, which also uses--hold. The identities file grants UID 170 admin access (see "container users" above). - the workload container runs with
--user <uid>:<gid>when the charm declares them, otherwise at the image's declared user, or root if the image declares none - if
JJX_PUBLISHis set toHOST_PORT:CONTAINER_PORT, add port publish127.0.0.1:HOST_PORT:CONTAINER_PORT - the workload container's IP is stored in
state.jsonascontainer_ipso hook tools (e.g.network-get) can access it without calling Docker directly
- Pebble is started with
- start charm runner container (
--network=container:<workload>, running as170:170, bind-mounts for Python, venv, jjx source, charm dir, state dir) — its image is pre-pulled the same way - wait for Pebble socket (
/jjx/socket) to be connectable inside charm runner - generate hook tool scripts in
./.jjx/hook-tools/(Python scripts with#!/python/bin/python3.XXshebangs) - run
config-changedhook viadocker execinto charm runner (Pebble socket symlink does not exist yet — charm cannot reach Pebble, matching real Juju) - persist resulting app and unit status
- spawn a detached background subprocess (see "async pebble-ready" below) and return
The background subprocess:
- sleep for 3.0 seconds (the minimum deploy delay)
- create Pebble socket symlink inside charm runner (via Python, not
mkdir/ln) — Pebble is now accessible from the charm - run
<workload>-pebble-readyhook viadocker execinto charm runner - persist resulting app and unit status
- delete the
.jjx/<app>.<pid>.deploymarker file and exit
pebble-ready is dispatched asynchronously after a minimum 3.0-second delay.
This is a deliberate stress-test floor, not a model of real Juju latency. It
exposes integration tests that use jubilant.wait (which needs 3 consecutive
successful status checks at 1.0s intervals — minimum ~2.0s) and then proceed
to interact with the container without verifying that pebble-ready has
actually fired. The delay is not configurable; the floor must be guaranteed.
The background subprocess is tracked via a .jjx/<app>.<pid>.deploy marker
file (not in state.json) so teardown can find and kill it. State writes are
atomic (temp file + os.replace) to prevent torn JSON if a juju status read
overlaps a background write. No file locking — a flock around event dispatch
would deadlock on hook tool subprocesses.
All Docker image pulls (charm runner, workload, and virtual charm images) go
through _docker_pull_with_retry in src/jjx/_engine.py, which retries
docker pull up to 3 times with linear backoff (5s, then 10s) and skips images
already present locally. The Pebble binary download uses the same pattern. The
pull is always separated from the run: jjx pre-pulls, then calls docker run
(which is not retried — retrying container creation would leave orphaned
containers).
When a hook exits non-zero, jjx sets unit and app status to error with the message hook failed: "<hook-name>", matching real Juju's format. The actual exception traceback is in juju debug-log (ops logs it via juju-log). jjx does not retry failed hooks or implement juju resolve — the error status is sticky until the app is removed or a subsequent hook overwrites it.
For the background pebble-ready subprocess, the exception is caught (the process can't report to the test directly), but the error status has already been written to state, so juju wait-for fails fast instead of timing out.
Config flow:
- update state
- run
config-changedhook - persist resulting status
Integrate flow:
- match endpoints by interface
- create relation in state
- populate the remote (virtual) app's databag from the virtual provider, passing the model state the virtual app lives in (so the provider can find sibling virtual apps — e.g. grafana needs Prometheus and Loki, which live in the same COS model)
- fire
relation-created,relation-joined, thenrelation-changedon the local (real) charm - re-populate the remote (virtual) app's databag — the charm may have written data (e.g.
scrape_jobs,dashboards) duringrelation-joined/relation-changedthat the virtual charm needs to read
Destroy flow:
- kill any background pebble-ready processes (via
.jjx/*.deploymarker files) - remove containers in teardown-priority order: COS containers (grafana, prometheus, loki) → postgres → workload → charm runners
- clean up any stragglers (orphaned containers not tracked in state)
- this includes
jjx-layer-copy-*throwaway containers created by deploy to extract baked-in Pebble layers from the OCI image; they are removed in afinallyblock when deploy completes, but a Ctrl-C mid-deploy can orphan them, so full teardown (jjx downor Ctrl-C) sweeps for them by name prefix
- this includes
- remove this model from state; if it's the last model, remove
./.jjx/entirely
When jjx down tears down all models, models are destroyed in reverse creation order so that COS models (created later) are torn down first.
- real
opsframework (ops3.x) - real Pebble API surface through Unix socket
- hook tools invoked as subprocess executables
- synchronous event execution (no queue, no background agent), except
pebble-readywhich is dispatched asynchronously after a minimum 3.0s delay (see "async pebble-ready" above) - hook failures set error status; no retry or
juju resolve(see "hook failures" above) - deterministic single-unit semantics
- charm code that connects to loopback addresses (
127.0.0.1,localhost,::1) reaches the workload container without exposing container ports on the host (the charm runner shares the workload's network namespace)
jjx implements several juju commands that jubilant/pytest-jubilant may call during setup, teardown, or status checks. These are minimal stubs that return just enough data for jubilant to function:
juju offer— records a cross-model offer in model statejuju run— executes actions on real charms (dispatching theactions/<name>hook viadocker exec) and on virtual charms (returning dynamically-computed results)juju switch— no-op (jjx always uses--model)juju version— returns a version response. Theversionstring is inmajor.minor.patch-release-archform (e.g.4.0.14-jjx-amd64), which is what jubilant'sVersion._from_dictparser requires; a baremajor.minor.patchis rejected. The version jjx reports is pinned injjx/_version.py(JUJU_VERSIONfor the bare form passed to charm code viaJUJU_VERSION,juju_version_string()for the jubilant-facing form). The two formats differ because ops'sJujuVersionparser (which readsJUJU_VERSION) rejects the-release-archsuffix.juju show-model— returns model metadata. Uses the Juju 4 field names (short-name,model-uuid,model-type,controller-uuid,controller-name,is-controller,status) that jubilant'sModelInfo._from_dictparser requires; the older 3.x names (type,controller,uuid,model-status) are no longer parsed.juju models— lists all models in state, using the same Juju 4 field names asshow-modeljuju add-secret— creates a user secret owned by the model; prints the secret URIjuju grant-secret— grants an application read access to a user secretjuju update-secret— creates a new revision of a secret's content and dispatchessecret-changedto every observing applicationjuju secrets— lists secrets in the model (JSON format matching jubilant'sSecrettype)juju show-secret— returns secret metadata, optionally with revealed contentjuju remove-secret— removes a secret (or a single revision)
jjx implements the hook tools that ops calls as subprocesses. Each is a Python script in ./.jjx/hook-tools/ with a #!/python/bin/python3.XX shebang that import jjx directly.
Implemented:
config-get,status-get,status-set— config and status managementis-leader— always returnstrue(single-unit model)juju-log— appends to the model's log instate.jsonrelation-ids,relation-list,relation-get,relation-set,relation-model-get— relation data accesssecret-add,secret-get,secret-grant,secret-info-get,secret-ids,secret-remove,secret-revoke,secret-set— secret management (see "secrets" below)network-get— returns the workload container's IP address (fromstate.json, not Docker, since Docker isn't available inside the charm runner). All bindings resolve to the workload's IP.application-version-set— sets the workload version in stateaction-get,action-set,action-fail,action-log— action parameter access and result collection (see "actions" below)
juju run <unit> <action> dispatches the charm's actions/<name> hook via docker exec into the charm runner, with JUJU_ACTION_NAME and JUJU_ACTION_UUID set and JUJU_HOOK_NAME empty (per ops). The action hook tools communicate with juju run through a transient per-action results file (./.jjx/action-<uuid>.json): action-get reads the params, action-set accumulates results, action-fail sets the failure message, and action-log appends progress messages. After the hook exits, juju run reads the file and emits the task JSON jubilant expects.
A hook that exits non-zero (uncaught exception) marks the task failed and sets unit/app status to error, matching real Juju. A hook that calls action-fail marks the task failed without changing unit status. In both cases juju run exits non-zero with task failed on stderr (and the task JSON on stdout) so jubilant raises TaskError.
jjx supports both app-managed secrets (created by the charm via secret-add) and user secrets (created via juju add-secret and granted to the charm via juju grant-secret).
Secrets are stored in state.json under model_state["secrets"]. Each secret tracks:
id— the canonical URI (secret://<model-uuid>/<id>)label— the owner-assigned label (app secrets) ornull(user secrets)name— the user-facing name (user secrets) ornull(app secrets)owner— the owning application name, or"model"for user secretscontent— the latest revision's content (a denormalized copy ofrevisions[-1])revisions— a list of content dicts; index 0 is revision 1revision— the latest revision numbergrants— which applications have read accessrotate,expire,description— optional metadatacreated,updated— ISO timestamps (for jubilant'sSecret.created/Secret.updated)observers— per-app tracking:{app: {label, tracked_revision}}
When a charm calls secret-get with both an id and a label, jjx records the observer label so subsequent secret-changed events can supply it via JUJU_SECRET_LABEL. secret-get with --refresh or --peek returns the latest revision's content; --refresh also updates the observer's tracked revision. Without either flag, the tracked revision's content is returned (or the latest, if the observer hasn't tracked a revision yet).
juju update-secret creates a new revision and dispatches a secret-changed event to every granted application that is observing the secret. The event carries JUJU_SECRET_ID and the observer's JUJU_SECRET_LABEL; the charm re-reads the content with get_content(refresh=True). The secret-set hook tool creates a new revision but does not dispatch secret-changed — it is called by the secret owner mid-hook (e.g. during secret-rotate), and jjx is single-app so there are no other observers to notify.
secret-info-get returns a single-entry dict keyed by the secret URI ({uri: {revision, label, ...}}), matching the format ops parses.
- requires Docker
- requires Docker socket access from the calling shell (for example via docker group membership)
- requires Linux (Unix sockets + Docker
--network=container:assumptions) - assumes charm source is present in
./src - assumes charm metadata files are present in project
State isolation rule:
./.jjx/state.jsonis internal runtime state and not a supported charm interface.- Charm code must not read or write
./.jjx/state.json; runtime behavior must not depend on charm access to this file.
These constraints are deliberate. They keep the system small, predictable, and fast to debug.
An earlier design used bubblewrap (a Linux sandboxing tool) to provide filesystem isolation and fidelity for charm hook execution. We considered it because it bind-mounts the staged charm directory to /charm, matching real Juju's JUJU_CHARM_DIR=/charm convention, and gives the charm process a restricted filesystem view.
We rejected it in favor of the charm runner container for three reasons:
- Network fidelity:
bubblewrapis a filesystem sandbox, not a network proxy. It required asitecustomize.pyshim to rewrite outbound TCP connects from loopback addresses to the workload container's bridge IP. The charm runner's--network=container:<workload>shares the workload's network namespace directly, so loopback just works — no shim needed. - Operational complexity:
bubblewraprequires AppArmor configuration on some systems (notably GitHub Actions runners) to avoid permission errors. A Docker container has no such host-level configuration burden. - Consistency: The charm runner is a Docker container, just like the workload and postgres containers. Using Docker for all containers simplifies the mental model and the teardown logic.
This is not a fake Juju platform. It is a focused test adapter.
Every feature must justify itself against one question: does this help single-unit local charm integration tests run with high fidelity and low complexity?
If not, it does not belong.