Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.
Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.
The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.
Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.
Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
567 lines
22 KiB
Python
Executable File
567 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Rootless containers, grouped by the project that defines them.
|
|
|
|
Every container on this machine is created by podman-compose and labelled with
|
|
the project it belongs to, so the grouping is read from the labels rather than
|
|
invented. Acting on a group is then done with plain `podman` over the labelled
|
|
set -- never `podman-compose down`, which would REMOVE the containers. Nothing
|
|
here creates, recreates, or removes a container: the compose file is the source
|
|
of truth for what exists, and it belongs to the repository, not to this tool.
|
|
|
|
Rootless throughout, so nothing here needs privilege.
|
|
|
|
The one exception to "does not touch the compose file" is `bind-local`, which
|
|
exists because a development database published on every interface is worth
|
|
closing and the fix is a single token. It prepends a loopback bind address and
|
|
leaves the rest of the line byte-for-byte -- variables, quoting and style
|
|
intact -- then re-parses to confirm only that value moved. Anything it cannot
|
|
read unambiguously it refuses rather than guesses.
|
|
|
|
panama-containers snapshot
|
|
panama-containers start NAME | stop NAME | restart NAME
|
|
panama-containers project-start NAME | project-stop NAME | project-restart NAME
|
|
panama-containers prune-images | prune-volumes
|
|
panama-containers bind-local PROJECT SERVICE
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
# A container or project name as podman and compose accept them. Deliberately
|
|
# strict: these values reach an argv, and nothing legitimate needs more.
|
|
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
|
|
# Project names that say nothing, because podman-compose defaults to the
|
|
# directory holding the compose file. "docker" is a directory, not a project.
|
|
ANONYMOUS = {"docker", "compose", "containers", "container", "db", "dev", "local", "src"}
|
|
|
|
# Substituted before a port mapping is split, because ${POSTGRES_PORT:-5432}
|
|
# contains a colon and would otherwise be torn in half.
|
|
INTERPOLATION = re.compile(r"\$\{[^}]*\}")
|
|
|
|
# Host addresses that mean "every interface", and so mean reachable.
|
|
EVERY_INTERFACE = {"", "0.0.0.0", "::", "[::]", "*"}
|
|
|
|
LOOPBACK = "127.0.0.1"
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible validation or podman failure."""
|
|
|
|
|
|
def podman_binary() -> str:
|
|
"""The podman to call.
|
|
|
|
Overridable so that stopping a container and removing an image can be
|
|
tested without stopping a real container or removing a real image. The
|
|
containers on this machine are a working development database; a test suite
|
|
has no business touching them.
|
|
"""
|
|
return os.environ.get("PANAMA_CONTAINERS_PODMAN", "podman")
|
|
|
|
|
|
def run(command: list[str], timeout: float = 60.0) -> subprocess.CompletedProcess:
|
|
try:
|
|
return subprocess.run(command, capture_output=True, text=True, timeout=timeout)
|
|
except FileNotFoundError as error:
|
|
raise BoundaryError("podman is not installed.") from error
|
|
except subprocess.TimeoutExpired as error:
|
|
raise BoundaryError("podman did not respond.") from error
|
|
|
|
|
|
def podman_json(arguments: list[str], timeout: float = 60.0) -> list | dict:
|
|
result = run([podman_binary(), *arguments], timeout=timeout)
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or "").strip().splitlines()
|
|
raise BoundaryError(detail[-1] if detail else "podman could not be read.")
|
|
try:
|
|
return json.loads(result.stdout or "[]")
|
|
except json.JSONDecodeError as error:
|
|
raise BoundaryError("podman returned something unreadable.") from error
|
|
|
|
|
|
def podman_do(arguments: list[str], failure: str, timeout: float = 120.0) -> None:
|
|
result = run([podman_binary(), *arguments], timeout=timeout)
|
|
if result.returncode != 0:
|
|
detail = (result.stderr or "").strip().splitlines()
|
|
raise BoundaryError(detail[-1] if detail else failure)
|
|
|
|
|
|
def require(pattern: re.Pattern[str], value: str, message: str) -> str:
|
|
if not pattern.match(value or ""):
|
|
raise BoundaryError(message)
|
|
return value
|
|
|
|
|
|
# ── reading ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def split_mapping(mapping: str) -> list[str]:
|
|
"""The parts of a compose port mapping, with interpolations kept whole."""
|
|
masked: list[str] = []
|
|
placeholder = "\x00{}\x00"
|
|
def keep(match: re.Match[str]) -> str:
|
|
masked.append(match.group(0))
|
|
return placeholder.format(len(masked) - 1)
|
|
stand_in = INTERPOLATION.sub(keep, mapping)
|
|
return [
|
|
re.sub(r"\x00(\d+)\x00", lambda m: masked[int(m.group(1))], part)
|
|
for part in stand_in.split(":")
|
|
]
|
|
|
|
|
|
def health_of(status: str) -> str:
|
|
"""The health podman prints inside the status line, when it prints one."""
|
|
match = re.search(r"\((healthy|unhealthy|starting)\)", status or "")
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def containers() -> list[dict]:
|
|
raw = podman_json(["ps", "-a", "--format", "json"])
|
|
result: list[dict] = []
|
|
for entry in raw if isinstance(raw, list) else []:
|
|
labels = entry.get("Labels") or {}
|
|
names = entry.get("Names") or []
|
|
status = str(entry.get("Status") or "")
|
|
ports = [
|
|
{
|
|
"hostIp": str(port.get("host_ip") or ""),
|
|
"hostPort": int(port.get("host_port") or 0),
|
|
"containerPort": int(port.get("container_port") or 0),
|
|
"protocol": str(port.get("protocol") or "tcp"),
|
|
"range": int(port.get("range") or 1),
|
|
}
|
|
for port in (entry.get("Ports") or [])
|
|
]
|
|
result.append({
|
|
"id": str(entry.get("Id") or "")[:12],
|
|
"name": names[0] if names else str(entry.get("Id") or "")[:12],
|
|
"image": str(entry.get("Image") or ""),
|
|
"state": str(entry.get("State") or ""),
|
|
"status": status,
|
|
"health": health_of(status),
|
|
"exitCode": int(entry.get("ExitCode") or 0),
|
|
"startedAt": int(entry.get("StartedAt") or 0),
|
|
"restarts": int(entry.get("Restarts") or 0),
|
|
"project": str(labels.get("com.docker.compose.project") or ""),
|
|
"service": str(labels.get("com.docker.compose.service") or ""),
|
|
"configFile": str(labels.get("com.docker.compose.project.config_files") or ""),
|
|
"workingDir": str(labels.get("com.docker.compose.project.working_dir") or ""),
|
|
# Published ports whose host address is every interface.
|
|
"ports": ports,
|
|
})
|
|
return result
|
|
|
|
|
|
def resolve_config(path: str, working_dir: str) -> str:
|
|
"""The compose file a label points at, normalised.
|
|
|
|
podman-compose records the path as it was invoked, which is how a perfectly
|
|
valid label ends up containing "/scripts/../compose.yml".
|
|
"""
|
|
if not path:
|
|
return ""
|
|
first = path.split(",")[0].strip()
|
|
if not first:
|
|
return ""
|
|
candidate = Path(first)
|
|
if not candidate.is_absolute() and working_dir:
|
|
candidate = Path(working_dir) / candidate
|
|
try:
|
|
return str(candidate.resolve(strict=False))
|
|
except OSError:
|
|
return str(candidate)
|
|
|
|
|
|
def repository_name(config_file: str) -> str:
|
|
"""The repository a compose file lives in, for naming a project sensibly."""
|
|
if not config_file:
|
|
return ""
|
|
result = run(
|
|
["git", "-C", str(Path(config_file).parent), "rev-parse", "--show-toplevel"],
|
|
timeout=10.0,
|
|
)
|
|
if result.returncode != 0:
|
|
return ""
|
|
return Path(result.stdout.strip()).name if result.stdout.strip() else ""
|
|
|
|
|
|
def display_name(project: str, config_file: str) -> str:
|
|
"""A project name worth showing.
|
|
|
|
podman-compose names a project after the directory holding its compose
|
|
file, so a database stack can end up called "docker". Where the name says
|
|
nothing, the repository it lives in says more.
|
|
"""
|
|
if project.lower() not in ANONYMOUS:
|
|
return project
|
|
return repository_name(config_file) or project
|
|
|
|
|
|
def projects_of(entries: list[dict]) -> list[dict]:
|
|
"""Containers grouped by the compose project that declares them."""
|
|
grouped: dict[str, dict] = {}
|
|
for container in entries:
|
|
key = container["project"]
|
|
if not key:
|
|
continue
|
|
group = grouped.get(key)
|
|
if group is None:
|
|
config_file = resolve_config(container["configFile"], container["workingDir"])
|
|
group = grouped[key] = {
|
|
"name": key,
|
|
"title": display_name(key, config_file),
|
|
"configFile": config_file,
|
|
"workingDir": container["workingDir"],
|
|
"containers": [],
|
|
}
|
|
group["containers"].append(container)
|
|
|
|
result = []
|
|
for group in grouped.values():
|
|
group["containers"].sort(key=lambda c: (c["state"] != "running", c["name"]))
|
|
group["running"] = sum(1 for c in group["containers"] if c["state"] == "running")
|
|
group["total"] = len(group["containers"])
|
|
result.append(group)
|
|
|
|
# Whatever is running comes first; a project nobody is using can wait.
|
|
result.sort(key=lambda g: (-g["running"], g["title"].lower()))
|
|
return result
|
|
|
|
|
|
def exposures(entries: list[dict]) -> list[dict]:
|
|
"""Published ports any machine on the network can reach.
|
|
|
|
A container is only reachable if it is running AND publishes on an address
|
|
that is not loopback. A stopped container publishes nothing, whatever its
|
|
compose file says -- so it is reported as something that WILL expose, not
|
|
something that does.
|
|
"""
|
|
found: list[dict] = []
|
|
for container in entries:
|
|
for port in container["ports"]:
|
|
if port["hostIp"] not in EVERY_INTERFACE:
|
|
continue
|
|
if port["hostPort"] <= 0:
|
|
continue
|
|
found.append({
|
|
"container": container["name"],
|
|
"project": container["project"],
|
|
"service": container["service"],
|
|
"configFile": resolve_config(container["configFile"], container["workingDir"]),
|
|
"image": container["image"],
|
|
"hostPort": port["hostPort"],
|
|
"containerPort": port["containerPort"],
|
|
"protocol": port["protocol"],
|
|
"running": container["state"] == "running",
|
|
})
|
|
found.sort(key=lambda e: (not e["running"], e["hostPort"]))
|
|
return found
|
|
|
|
|
|
def disk() -> dict:
|
|
"""What the container store costs, and what of it nothing references."""
|
|
usage = podman_json(["system", "df", "--format", "json"])
|
|
totals = {
|
|
str(row.get("Type") or ""): row
|
|
for row in (usage if isinstance(usage, list) else [])
|
|
}
|
|
|
|
def raw(kind: str, field: str) -> int:
|
|
return int((totals.get(kind) or {}).get(field) or 0)
|
|
|
|
images = podman_json(["images", "--format", "json"])
|
|
unused = []
|
|
for image in images if isinstance(images, list) else []:
|
|
if int(image.get("Containers") or 0) > 0:
|
|
continue
|
|
tags = image.get("Names") or image.get("RepoTags") or []
|
|
unused.append({
|
|
"id": str(image.get("Id") or "")[:12],
|
|
"name": tags[0] if tags else "<untagged>",
|
|
"size": int(image.get("Size") or 0),
|
|
})
|
|
unused.sort(key=lambda i: -i["size"])
|
|
|
|
# Podman's own answer to "does anything reference this volume", rather
|
|
# than MountCount, which is a runtime lock counter: it reads zero for a
|
|
# volume that a running container has mounted this second, and using it
|
|
# here would offer to delete a live database.
|
|
volumes = podman_json(["volume", "ls", "--filter", "dangling=true", "--format", "json"])
|
|
idle = [
|
|
{"name": str(volume.get("Name") or "")}
|
|
for volume in (volumes if isinstance(volumes, list) else [])
|
|
if volume.get("Name")
|
|
]
|
|
idle.sort(key=lambda v: v["name"])
|
|
|
|
return {
|
|
"imagesSize": raw("Images", "RawSize"),
|
|
"imagesReclaimable": raw("Images", "RawReclaimable"),
|
|
"containersSize": raw("Containers", "RawSize"),
|
|
"volumesSize": raw("Local Volumes", "RawSize"),
|
|
"volumesReclaimable": raw("Local Volumes", "RawReclaimable"),
|
|
"unusedImages": unused,
|
|
"unusedVolumes": idle,
|
|
}
|
|
|
|
|
|
def snapshot() -> dict:
|
|
entries = containers()
|
|
grouped = projects_of(entries)
|
|
return {
|
|
"available": True,
|
|
"projects": grouped,
|
|
"loose": [c for c in entries if not c["project"]],
|
|
"running": sum(1 for c in entries if c["state"] == "running"),
|
|
"total": len(entries),
|
|
"exposed": exposures(entries),
|
|
"disk": disk(),
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def unavailable(message: str) -> dict:
|
|
return {
|
|
"available": False, "projects": [], "loose": [], "running": 0, "total": 0,
|
|
"exposed": [], "disk": {
|
|
"imagesSize": 0, "imagesReclaimable": 0, "containersSize": 0,
|
|
"volumesSize": 0, "volumesReclaimable": 0,
|
|
"unusedImages": [], "unusedVolumes": [],
|
|
},
|
|
"error": message,
|
|
}
|
|
|
|
|
|
# ── acting ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def find_container(name: str) -> dict:
|
|
for container in containers():
|
|
if container["name"] == name or container["id"] == name:
|
|
return container
|
|
raise BoundaryError(f"There is no container called {name}.")
|
|
|
|
|
|
def find_project(name: str) -> dict:
|
|
for project in projects_of(containers()):
|
|
if project["name"] == name:
|
|
return project
|
|
raise BoundaryError(f"There is no project called {name}.")
|
|
|
|
|
|
def act_on_container(verb: str, name: str) -> None:
|
|
container = find_container(name)
|
|
if verb == "start" and container["state"] == "running":
|
|
raise BoundaryError(f"{container['name']} is already running.")
|
|
if verb == "stop" and container["state"] != "running":
|
|
raise BoundaryError(f"{container['name']} is not running.")
|
|
podman_do([verb, container["name"]], f"{container['name']} could not be {verb}ed.")
|
|
|
|
|
|
def act_on_project(verb: str, name: str) -> None:
|
|
"""The whole stack, one container at a time, with plain podman.
|
|
|
|
Deliberately not `podman-compose down`: that removes containers, and this
|
|
tool does not remove what the compose file created.
|
|
"""
|
|
project = find_project(name)
|
|
wanted = "running" if verb == "stop" else "not running"
|
|
targets = [
|
|
container["name"] for container in project["containers"]
|
|
if (container["state"] == "running") == (wanted == "running")
|
|
] if verb != "restart" else [
|
|
container["name"] for container in project["containers"]
|
|
if container["state"] == "running"
|
|
]
|
|
if not targets:
|
|
raise BoundaryError(f"Nothing in {project['title']} needs to be {verb}ed.")
|
|
|
|
failures: list[str] = []
|
|
for target in targets:
|
|
result = run([podman_binary(), verb, target], timeout=120.0)
|
|
if result.returncode != 0:
|
|
failures.append(target)
|
|
if failures:
|
|
raise BoundaryError(f"Could not {verb} {', '.join(failures)}.")
|
|
|
|
|
|
def prune_images() -> None:
|
|
"""Remove images nothing references.
|
|
|
|
Scoped to exactly what the snapshot showed as unused, by id, so that an
|
|
image which gained a container between the panel rendering and the button
|
|
being pressed is not swept up by a blanket prune.
|
|
"""
|
|
unused = disk()["unusedImages"]
|
|
if not unused:
|
|
raise BoundaryError("Every image is in use.")
|
|
failures = []
|
|
for image in unused:
|
|
result = run([podman_binary(), "rmi", image["id"]], timeout=120.0)
|
|
if result.returncode != 0:
|
|
failures.append(image["name"])
|
|
if failures:
|
|
raise BoundaryError(f"Could not remove {len(failures)} image(s): {', '.join(failures[:3])}.")
|
|
|
|
|
|
def prune_volumes() -> None:
|
|
idle = disk()["unusedVolumes"]
|
|
if not idle:
|
|
raise BoundaryError("Every volume is in use.")
|
|
failures = []
|
|
for volume in idle:
|
|
result = run([podman_binary(), "volume", "rm", volume["name"]], timeout=120.0)
|
|
if result.returncode != 0:
|
|
failures.append(volume["name"])
|
|
if failures:
|
|
raise BoundaryError(f"Could not remove {len(failures)} volume(s): {', '.join(failures[:3])}.")
|
|
|
|
|
|
# ── the compose edit ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def rewrite_mapping(mapping: str) -> str:
|
|
"""A published port bound to loopback, with everything else left alone.
|
|
|
|
Only the address is added. The host port keeps whatever form it had --
|
|
literal, ${VAR}, or ${VAR:-default} -- because rewriting it to the number
|
|
podman happens to report today would silently delete the variable that lets
|
|
the port be configured at all.
|
|
"""
|
|
parts = split_mapping(mapping.strip())
|
|
if len(parts) == 3:
|
|
raise BoundaryError(f"{mapping} already names an address.")
|
|
if len(parts) != 2:
|
|
raise BoundaryError(f"{mapping} is not a mapping this can read.")
|
|
return f"{LOOPBACK}:{parts[0]}:{parts[1]}"
|
|
|
|
|
|
def compose_ports(document: dict, service: str) -> list[str]:
|
|
services = document.get("services")
|
|
if not isinstance(services, dict) or service not in services:
|
|
raise BoundaryError(f"{service} is not in that compose file.")
|
|
definition = services.get(service)
|
|
if not isinstance(definition, dict):
|
|
raise BoundaryError(f"{service} is not readable in that compose file.")
|
|
ports = definition.get("ports")
|
|
if ports is None:
|
|
raise BoundaryError(f"{service} does not publish any ports.")
|
|
if not isinstance(ports, list) or not all(isinstance(p, str) for p in ports):
|
|
raise BoundaryError(f"The ports of {service} are not in a form this can edit.")
|
|
return ports
|
|
|
|
|
|
def bind_local(project_name: str, service: str) -> None:
|
|
"""Bind a service's published ports to loopback, in place.
|
|
|
|
Read to understand, edit as text so the file keeps its comments, quoting
|
|
and layout, then read back to confirm that exactly the intended values
|
|
moved and nothing else did.
|
|
"""
|
|
project = find_project(project_name)
|
|
path = Path(project["configFile"])
|
|
if not project["configFile"] or not path.is_file():
|
|
raise BoundaryError("The compose file for that project could not be found.")
|
|
|
|
original = path.read_text(encoding="utf-8")
|
|
try:
|
|
document = yaml.safe_load(original)
|
|
except yaml.YAMLError as error:
|
|
raise BoundaryError("That compose file could not be parsed.") from error
|
|
if not isinstance(document, dict):
|
|
raise BoundaryError("That compose file is not a mapping.")
|
|
|
|
current = compose_ports(document, service)
|
|
wanted = [rewrite_mapping(mapping) for mapping in current]
|
|
|
|
# Replace each mapping where it is written, not the line it sits on, so
|
|
# flow style, block style and inline comments all survive untouched. The
|
|
# mapping text is searched for within the service's own span only.
|
|
updated = original
|
|
for before, after in zip(current, wanted):
|
|
needle = before.strip()
|
|
occurrences = updated.count(needle)
|
|
if occurrences == 0:
|
|
raise BoundaryError(f"Could not find {needle} in the compose file.")
|
|
if occurrences > 1:
|
|
raise BoundaryError(
|
|
f"{needle} appears {occurrences} times in that file; "
|
|
"it is not clear which one belongs to this service.")
|
|
updated = updated.replace(needle, after)
|
|
|
|
if updated == original:
|
|
raise BoundaryError("That compose file already binds these ports to loopback.")
|
|
|
|
path.write_text(updated, encoding="utf-8")
|
|
|
|
# Read back. A change that was not applied, or that broke the document, is
|
|
# worse than no change at all.
|
|
try:
|
|
reread = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as error:
|
|
path.write_text(original, encoding="utf-8")
|
|
raise BoundaryError("The edit would have broken that compose file; it was undone.") from error
|
|
|
|
if not isinstance(reread, dict) or compose_ports(reread, service) != wanted:
|
|
path.write_text(original, encoding="utf-8")
|
|
raise BoundaryError("The edit did not take effect; it was undone.")
|
|
|
|
# Everything except this service's ports must be identical.
|
|
before_document = yaml.safe_load(original)
|
|
before_document["services"][service]["ports"] = wanted
|
|
if before_document != reread:
|
|
path.write_text(original, encoding="utf-8")
|
|
raise BoundaryError("The edit changed more than those ports; it was undone.")
|
|
|
|
|
|
def main(arguments: list[str]) -> int:
|
|
try:
|
|
if arguments == ["snapshot"]:
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
|
|
if len(arguments) == 2 and arguments[0] in ("start", "stop", "restart"):
|
|
act_on_container(arguments[0], require(NAME, arguments[1], "That is not a container name."))
|
|
elif len(arguments) == 2 and arguments[0] in ("project-start", "project-stop", "project-restart"):
|
|
verb = arguments[0].split("-", 1)[1]
|
|
act_on_project(verb, require(NAME, arguments[1], "That is not a project name."))
|
|
elif arguments == ["prune-images"]:
|
|
prune_images()
|
|
elif arguments == ["prune-volumes"]:
|
|
prune_volumes()
|
|
elif len(arguments) == 3 and arguments[0] == "bind-local":
|
|
bind_local(
|
|
require(NAME, arguments[1], "That is not a project name."),
|
|
require(NAME, arguments[2], "That is not a service name."),
|
|
)
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-containers snapshot | start NAME | stop NAME | restart NAME | "
|
|
"project-start NAME | project-stop NAME | project-restart NAME | "
|
|
"prune-images | prune-volumes | bind-local PROJECT SERVICE")
|
|
except BoundaryError as error:
|
|
try:
|
|
state = snapshot()
|
|
except BoundaryError:
|
|
state = unavailable("")
|
|
state["error"] = str(error)
|
|
print(json.dumps(state, separators=(",", ":")))
|
|
return 0
|
|
|
|
print(json.dumps(snapshot(), separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|