Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ jobs:
path: operator
- name: Run httpbin-demo
working-directory: operator/examples/httpbin-demo
run: uvx --with git+https://github.com/dwilding/jjx@main jjx -d -p 8000:80
run: uvx --with git+https://github.com/dwilding/jjx@main jjx -d --stable-port 80
- name: Check workload
run: curl --fail http://localhost:8000/
run: curl --fail http://127.0.0.220/
- name: Tear down
working-directory: operator/examples/httpbin-demo
run: uvx --with git+https://github.com/dwilding/jjx@main jjx down
- name: Check no workload
run: |
if curl --fail --max-time 2 http://localhost:8000/; then
if curl --fail --max-time 2 http://127.0.0.220/; then
echo "ERROR: workload is still running after jjx down"
exit 1
fi
Expand Down Expand Up @@ -84,15 +84,15 @@ jobs:
uv add --group integration "jjx @ git+https://github.com/dwilding/jjx@main"
- name: Run httpbin-demo
working-directory: operator/examples/httpbin-demo
run: uv run jjx -d -p 8000:80
run: uv run jjx -d --stable-port 80
- name: Check workload
run: curl --fail http://localhost:8000/
run: curl --fail http://127.0.0.220/
- name: Tear down
working-directory: operator/examples/httpbin-demo
run: uv run jjx down
- name: Check no workload
run: |
if curl --fail --max-time 2 http://localhost:8000/; then
if curl --fail --max-time 2 http://127.0.0.220/; then
echo "ERROR: workload is still running after jjx down"
exit 1
fi
Expand Down
6 changes: 3 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jjx down # tear down all models and remove runtime state

Optional flags:

- `-p HOST:CONTAINER` — publish a container port to `127.0.0.1:HOST`
- `--stable-port <port>` — publish the workload's `<port>` on `127.0.0.220:<port>`, so tests get a predictable address. The flag bypasses port detection.

When a run leaves the workload running, `jjx` prints its address as `Workload running at <ip>:<port>`. The port comes from the last passing test that dialed a port literal, cross-referencing two inputs:

Expand All @@ -25,7 +25,7 @@ Each port the winning test dialed prints one line. The report is a file rather t

The deliberate misses, each a silent degrade to the IP-only line (`Workload running at <ip>`): a port computed by a helper, a port that only exists at runtime (`test_loki_data` builds its URL from the output of `show-proxied-endpoints`), and a run where no passing test dialed any literal.

`-p` means the user chose the port themselves, so `jjx` skips the scan, the JUnit argument, and the report read. A detected port printed alongside would name a container-IP endpoint that `-p` never published. Filtering it against the publish mapping would be a cross-check, which jjx does not do.
`--stable-port` bypasses the scan, the JUnit argument, and the report read, and prints `Workload running at 127.0.0.220:<port>`. Without the flag, `jjx` prints the address port detection produced.

jjx reads only what the tests did. It performs no check of its own against the workload. A passing test whose dial would fail is a test bug. So is a stale verdict: when a test reconfigures the port and no later passing test dials the new one, the line reports the old port — a bug in the tests, exposed for the charm author to fix. Reporting the tests' verdict, including a stale one, is the feature.

Expand Down Expand Up @@ -240,7 +240,7 @@ Deploy flow:
4. 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.yaml` so baked-in layers (e.g. Rockcraft layers with `startup: 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_PUBLISH` is set to `HOST_PORT:CONTAINER_PORT`, add port publish `127.0.0.1:HOST_PORT:CONTAINER_PORT`
- if `JJX_STABLE_PORT` is set (the CLI set it from `--stable-port`), add port publish `127.0.0.220:<port>:<port>`, identity-mapped
- the workload container's IP is stored in `state.json` as `container_ip` so hook tools (e.g. `network-get`) can access it without calling Docker directly
5. start charm runner container (`--network=container:<workload>`, running as `170:170`, bind-mounts for Python, venv, jjx source, charm dir, state dir) — its image is pre-pulled the same way
6. wait for Pebble socket (`/jjx/socket`) to be connectable inside charm runner
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,6 @@ The workload stays running until you press Ctrl-C.

The output shows the workload's IP address. You can play with the workload by connecting to this address. If the integration tests interacted with the workload, the output also shows which port to use when connecting to the workload.

Alternatively, to play with the workload on localhost, specify a port mapping:

```text
uvx jjx -p <localhost-port>:<workload-port>
```

### Run the workload in the background

In the charm dir:
Expand Down
66 changes: 35 additions & 31 deletions src/jjx/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import os
import re
import shutil
import signal
import subprocess
Expand Down Expand Up @@ -220,8 +219,9 @@ def jjx_pytest_env_args(charm_root: Path) -> list[str]:
def _split_cli_args(argv: list[str]) -> tuple[list[str], list[str]]:
"""Split argv into jjx options and extra pytest args at the ``--`` separator.

Everything before ``--`` is jjx's own flags (``-d``, ``-p``, ``down``).
Everything after ``--`` is passed through as extra pytest arguments.
Everything before ``--`` is jjx's own flags (``-d``, ``--stable-port``,
``down``). Everything after ``--`` is passed through as extra pytest
arguments.
"""
if "--" in argv:
idx = argv.index("--")
Expand Down Expand Up @@ -500,17 +500,19 @@ def jjx_cli() -> int:

detach = "-d" in jjx_args

# Extract -p flag for port publishing.
publish = None
if "-p" in jjx_args:
idx = jjx_args.index("-p")
if idx + 1 < len(jjx_args):
publish = jjx_args[idx + 1]
if not re.match(r"^\d+:\d+$", publish):
sys.stderr.write(
f"Error: Invalid port format '{publish}': expected <number>:<number>.\n"
)
# Extract --stable-port flag: publish the workload's port on a fixed
# loopback address, so tests get a predictable address.
stable_port = None
if "--stable-port" in jjx_args:
idx = jjx_args.index("--stable-port")
if idx + 1 < len(jjx_args) and jjx_args[idx + 1].isdigit():
stable_port = int(jjx_args[idx + 1])
if not 1 <= stable_port <= 65535:
sys.stderr.write(f"Error: Invalid port '{jjx_args[idx + 1]}': expected 1-65535.\n")
return 2
else:
sys.stderr.write("Error: --stable-port expects a port number.\n")
return 2

try:
pytest_args = jjx_pytest_args(charm_root, cli_pytest_args)
Expand All @@ -528,22 +530,22 @@ def jjx_cli() -> int:

# Port detection reads the integration tests for dialed port literals and
# cross-references them against the JUnit report pytest writes during the
# run. -p publishes a port of the user's choosing, so detection is skipped
# entirely. A report path already in the args wins and is never removed —
# including a dangling flag naming no path, which pytest itself rejects as
# it would without jjx. Otherwise jjx appends its own report path under
# .jjx/ and removes it in the finally below.
# run. --stable-port bypasses detection. A report path already in the
# args wins and is never removed — including a dangling flag naming no
# path, which pytest itself rejects as it would without jjx. Otherwise
# jjx appends its own report path under .jjx/ and removes it in the
# finally below.
junit_path: Path | None = None
junit_owned = False
tests_dir: Path | None = None
if not publish:
if not stable_port:
tests_dir = _test_dir_from_args(pytest_args, charm_root)
junit_path = _junit_path_from_args(pytest_args, charm_root)
if junit_path is None and not _has_junit_arg(pytest_args):
junit_path = charm_root / _engine.STATE_DIR_NAME / "junit.xml"
junit_path.parent.mkdir(parents=True, exist_ok=True)
pytest_args.append(f"--junit-xml={junit_path}")
junit_owned = True
if not stable_port and junit_path is None and not _has_junit_arg(pytest_args):
junit_path = charm_root / _engine.STATE_DIR_NAME / "junit.xml"
junit_path.parent.mkdir(parents=True, exist_ok=True)
pytest_args.append(f"--junit-xml={junit_path}")
junit_owned = True

placeholder_charm = charm_root / "placeholder.charm"
placeholder_charm.touch()
Expand All @@ -555,9 +557,8 @@ def jjx_cli() -> int:
# at the launcher's venv; if inherited, uv may reuse it instead of creating
# one in the charm dir, causing pytest to run from the wrong project root.
env.pop("VIRTUAL_ENV", None)
if publish:
env["JJX_PUBLISH"] = publish
external_port, _ = publish.split(":", 1)
if stable_port:
env["JJX_STABLE_PORT"] = str(stable_port)
cmd = [
"uv",
"run",
Expand All @@ -582,12 +583,15 @@ def jjx_cli() -> int:
print() # separate the teardown output from the test report
teardown_all_models()
return returncode
if publish:
print(f"\nWorkload running at 127.0.0.1:{external_port}", flush=True)
if stable_port:
print(
f"\nWorkload running at {_engine.STABLE_PORT_ADDRESS}:{stable_port}",
flush=True,
)
else:
# The ports dialed by the last passing test that dialed any
# literal, read from the tests' ASTs and the JUnit report. Nothing
# detectable: today's IP-only line, byte for byte.
# literal, read from the tests' ASTs and the JUnit report.
# Nothing detectable: today's IP-only line, byte for byte.
ports = _port_scan.detected_ports(tests_dir, junit_path)
if ports:
print(flush=True)
Expand Down
35 changes: 15 additions & 20 deletions src/jjx/_cmd_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from __future__ import annotations

import os
import re
import subprocess
import uuid
from pathlib import Path
Expand All @@ -29,27 +28,23 @@
}


_PUBLISH_RE = re.compile(r"^(?P<host_port>\d{1,5}):(?P<container_port>\d{1,5})$")
def _stable_port_publish_from_env() -> str | None:
"""Return the publish spec for a requested --stable-port, or None.


def _parse_publish(raw: str) -> str:
match = _PUBLISH_RE.fullmatch(raw)
if not match:
raise _engine.CliError("JJX_PUBLISH must be in HOST_PORT:CONTAINER_PORT format")

host_port = int(match.group("host_port"))
container_port = int(match.group("container_port"))
if not (1 <= host_port <= 65535 and 1 <= container_port <= 65535):
raise _engine.CliError("JJX_PUBLISH ports must be between 1 and 65535")

return f"127.0.0.1:{host_port}:{container_port}"


def _publish_from_env() -> str | None:
raw = os.environ.get("JJX_PUBLISH", "").strip()
The CLI sets ``JJX_STABLE_PORT`` when invoked with --stable-port. The
publish is identity-mapped on the fixed loopback address, so the port
the tests dial is exactly the workload's port.
"""
raw = os.environ.get("JJX_STABLE_PORT", "").strip()
if not raw:
return None
return _parse_publish(raw)
try:
port = int(raw)
except ValueError:
raise _engine.CliError(f"JJX_STABLE_PORT is not a port number: {raw!r}") from None
if not 1 <= port <= 65535:
raise _engine.CliError(f"JJX_STABLE_PORT port out of range: {port}")
return f"{_engine.STABLE_PORT_ADDRESS}:{port}:{port}"


def _copy_image_pebble_layers(image: str, dest_layers_dir: Path) -> None:
Expand Down Expand Up @@ -333,7 +328,7 @@ def deploy(args: list[str], model: str | None) -> int:
(str(pebble_binary), "/charm/bin/pebble", True),
(str(jjx_dir), "/jjx", False),
]
publish = _publish_from_env()
publish = _stable_port_publish_from_env()
container_id = _engine._docker_run(
image,
container_name,
Expand Down
4 changes: 4 additions & 0 deletions src/jjx/_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
# PR will make this configurable (env var or detection).
_CONTAINER_BINARY = "docker"

# Fixed address --stable-port publishes on, identity-mapped:
# 127.0.0.220:<port>.
STABLE_PORT_ADDRESS = "127.0.0.220"


@dataclass
class CliError(Exception):
Expand Down
10 changes: 5 additions & 5 deletions tests/functional/test_jjx_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,14 @@ def test_uvx_jjx(k8s_2_configurable):
proc.kill()


def test_uvx_jjx_publish(k8s_2_configurable):
def test_uvx_jjx_stable_port(k8s_2_configurable):
command = [
"uvx",
"--with-editable",
PACKAGE_DIR,
"jjx",
"-p",
"8135:8000",
"--stable-port",
"8000",
]
proc = subprocess.Popen(
command,
Expand All @@ -117,8 +117,8 @@ def test_uvx_jjx_publish(k8s_2_configurable):
text=True,
)
try:
wait_for_output_line(proc, "Workload running at 127.0.0.1:8135")
assert_connection("http://127.0.0.1:8135")
wait_for_output_line(proc, "Workload running at 127.0.0.220:8000")
assert_connection("http://127.0.0.220:8000")
# TEARDOWN
proc.send_signal(signal.SIGINT)
assert proc.wait(timeout=30) == 130
Expand Down
Loading