diff --git a/config/dot/quickshell/modules/settings/ContainersPage.qml b/config/dot/quickshell/modules/settings/ContainersPage.qml new file mode 100644 index 0000000..c22cc70 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ContainersPage.qml @@ -0,0 +1,558 @@ +// Rootless podman-compose stacks, led by what needs attention. +// +// The grouping is the compose project, because that is the unit a person thinks +// in: not "thirteen containers" but "the Command Center database" and "the +// capacity planner's Supabase". State decides prominence within that grouping -- +// what is running gets rows, what is stopped collapses to a line -- so neither +// axis has to be chosen over the other. +// +// The findings at the top are the same 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. + +import Quickshell +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + objectName: "containers" + + // "images", "volumes", or empty. Removal never happens on a first press. + property string confirmingPrune: "" + + // Projects whose stopped containers have been expanded, by project name. + property var expanded: [] + + function isExpanded(name: string): bool { return root.expanded.indexOf(name) >= 0; } + + function toggleExpanded(name: string): void { + root.expanded = root.isExpanded(name) + ? root.expanded.filter(entry => entry !== name) + : root.expanded.concat([name]); + } + + function uptimeOf(container: var): string { + const status = String(container.status ?? ""); + // podman already phrases this well ("Up 32 hours (healthy)"); the health + // is shown separately, so only the duration is wanted here. + const match = /^Up ([^(]+)/.exec(status); + return match ? match[1].trim() : ""; + } + + function portSummary(container: var): string { + const ports = container.ports ?? []; + if (ports.length === 0) + return "no published ports"; + return ports.map(port => { + const host = String(port.hostIp ?? ""); + const where = host === "" ? "every interface" : host; + return where + ":" + port.hostPort + " → " + port.containerPort; + }).join(", "); + } + + Component.onCompleted: Containers.refresh() + + // ── the list ──────────────────────────────────────────────────────────── + + SettingsPage { + anchors.fill: parent + visible: Containers.logTarget === "" + + title: "Containers" + lede: "Local services you run for development — what they expose, and what they cost." + + TextRow { + visible: Containers.lastError !== "" + label: "That did not work" + detail: Containers.lastError + value: "" + divider: false + } + + TextRow { + visible: Containers.scanned && !Containers.available + label: "podman is not available" + detail: "Nothing here can be shown until podman is installed." + value: "" + divider: false + } + + // ── finding: published to the network ─────────────────────────────── + + SettingsCard { + visible: Containers.reachable.length > 0 + title: Containers.reachable.length === 1 + ? "A container is published to your whole network" + : "Containers are published to your whole network" + subtitle: "These bind every interface, so any machine on your network can connect. " + + "For a development database, loopback is almost always what you want. " + + "Binding adds an address to the compose file and changes nothing else." + + Repeater { + model: Containers.reachable + + delegate: SettingRow { + required property var modelData + width: parent.width + label: String(modelData.container ?? "") + detail: "Port " + modelData.hostPort + "/" + String(modelData.protocol ?? "tcp") + + " · " + String(modelData.image ?? "") + + (modelData.configFile + ? "\n" + Containers.shorten(String(modelData.configFile)) + : "\nNo compose file is recorded for this container, so it cannot be bound from here.") + controlWidth: 250 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Open compose file" + visible: String(modelData.configFile ?? "") !== "" + enabled: !Containers.busy + onClicked: Containers.openFile(String(modelData.configFile)) + } + + SettingsButton { + text: "Bind to localhost" + tone: "accent" + // Needs both halves of the label to find the entry in + // the compose file; the Supabase CLI records neither. + visible: String(modelData.configFile ?? "") !== "" + && String(modelData.service ?? "") !== "" + enabled: !Containers.busy + onClicked: Containers.bindLocal( + String(modelData.project), String(modelData.service)) + } + } + } + } + + TextRow { + visible: Containers.reachable.length > 0 + label: "After binding" + detail: "The change takes effect the next time the stack comes up, because a " + + "published port is fixed when the container is created." + value: "" + divider: false + } + } + + // ── finding: disk nothing references ──────────────────────────────── + + SettingsCard { + visible: Containers.unusedImages.length > 0 || Containers.unusedVolumes.length > 0 + title: Containers.formatBytes(Containers.reclaimable) + " nothing is using" + subtitle: "Images and volumes no container references. Removing them frees the space; " + + "anything still needed downloads again on next use." + + Repeater { + model: Containers.unusedImages.slice(0, 5) + + delegate: TextRow { + required property var modelData + width: parent.width + label: String(modelData.name ?? "") + detail: "" + value: Containers.formatBytes(Number(modelData.size ?? 0)) + } + } + + TextRow { + visible: Containers.unusedImages.length > 5 + label: "and " + (Containers.unusedImages.length - 5) + " more" + detail: "" + value: "" + } + + SettingRow { + width: parent.width + label: root.confirmingPrune === "images" + ? "Remove " + Containers.unusedImages.length + " images?" + : "Unused images" + detail: root.confirmingPrune === "images" + ? "Each is removed by name. Nothing that a container references is touched." + : Containers.unusedImages.length + " images, " + + Containers.formatBytes(Number(Containers.disk.imagesReclaimable ?? 0)) + visible: Containers.unusedImages.length > 0 + controlWidth: 230 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: root.confirmingPrune === "images" ? "Keep them" : "Remove…" + enabled: !Containers.busy + onClicked: root.confirmingPrune = + root.confirmingPrune === "images" ? "" : "images" + } + + SettingsButton { + visible: root.confirmingPrune === "images" + text: "Remove them" + tone: "danger" + enabled: !Containers.busy + onClicked: { + root.confirmingPrune = ""; + Containers.pruneImages(); + } + } + } + } + + SettingRow { + width: parent.width + visible: Containers.unusedVolumes.length > 0 + label: root.confirmingPrune === "volumes" + ? "Remove " + Containers.unusedVolumes.length + " volumes?" + : "Unused volumes" + detail: root.confirmingPrune === "volumes" + ? "A volume holds data. These are the ones podman reports as referenced by " + + "nothing, but removing them cannot be undone." + : Containers.unusedVolumes.length + " volumes, " + + Containers.formatBytes(Number(Containers.disk.volumesReclaimable ?? 0)) + controlWidth: 230 + divider: false + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: root.confirmingPrune === "volumes" ? "Keep them" : "Remove…" + enabled: !Containers.busy + onClicked: root.confirmingPrune = + root.confirmingPrune === "volumes" ? "" : "volumes" + } + + SettingsButton { + visible: root.confirmingPrune === "volumes" + text: "Remove them" + tone: "danger" + enabled: !Containers.busy + onClicked: { + root.confirmingPrune = ""; + Containers.pruneVolumes(); + } + } + } + } + } + + // ── the stacks ────────────────────────────────────────────────────── + + Repeater { + model: Containers.projects + + delegate: SettingsCard { + id: projectCard + + required property var modelData + + readonly property var runningSet: + (projectCard.modelData.containers ?? []).filter(c => c.state === "running") + readonly property var stoppedSet: + (projectCard.modelData.containers ?? []).filter(c => c.state !== "running") + readonly property string projectName: String(projectCard.modelData.name ?? "") + + title: String(projectCard.modelData.title ?? "") + subtitle: { + const total = Number(projectCard.modelData.total ?? 0); + const up = Number(projectCard.modelData.running ?? 0); + const where = String(projectCard.modelData.configFile ?? ""); + const counts = up === 0 + ? total + (total === 1 ? " container, stopped" : " containers, all stopped") + : up + " of " + total + " running"; + return where === "" ? counts : counts + " · " + Containers.shorten(where); + } + + SettingRow { + width: parent.width + label: "The whole stack" + detail: projectCard.runningSet.length === 0 + ? "Starts every container this project defines." + : "Stopping leaves the containers in place; nothing is removed." + controlWidth: 250 + divider: projectCard.runningSet.length > 0 + || projectCard.stoppedSet.length > 0 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Start all" + visible: projectCard.stoppedSet.length > 0 + enabled: !Containers.busy + onClicked: Containers.startProject(projectCard.projectName) + } + + SettingsButton { + text: "Restart all" + visible: projectCard.runningSet.length > 0 + enabled: !Containers.busy + onClicked: Containers.restartProject(projectCard.projectName) + } + + SettingsButton { + text: "Stop all" + visible: projectCard.runningSet.length > 0 + enabled: !Containers.busy + onClicked: Containers.stopProject(projectCard.projectName) + } + } + } + + Repeater { + model: projectCard.runningSet + + delegate: SettingRow { + required property var modelData + width: parent.width + label: String(modelData.name ?? "") + detail: String(modelData.image ?? "") + " · " + root.portSummary(modelData) + value: { + const health = String(modelData.health ?? ""); + const up = root.uptimeOf(modelData); + if (health !== "" && up !== "") + return health + " · " + up; + return health !== "" ? health : up; + } + controlWidth: 250 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Logs" + onClicked: Containers.openLogs(String(modelData.name)) + } + + SettingsButton { + text: "Restart" + enabled: !Containers.busy + onClicked: Containers.restart(String(modelData.name)) + } + + SettingsButton { + text: "Stop" + enabled: !Containers.busy + onClicked: Containers.stop(String(modelData.name)) + } + } + } + } + + SettingRow { + width: parent.width + visible: projectCard.stoppedSet.length > 0 + activatable: true + divider: root.isExpanded(projectCard.projectName) + label: (root.isExpanded(projectCard.projectName) ? "▾ " : "▸ ") + + projectCard.stoppedSet.length + + (projectCard.stoppedSet.length === 1 + ? " stopped container" : " stopped containers") + detail: { + const bad = projectCard.stoppedSet.filter(c => Number(c.exitCode ?? 0) !== 0); + return bad.length === 0 + ? "" + : bad.length + (bad.length === 1 ? " exited" : " exited") + " badly."; + } + onActivated: root.toggleExpanded(projectCard.projectName) + } + + Repeater { + model: root.isExpanded(projectCard.projectName) ? projectCard.stoppedSet : [] + + delegate: SettingRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.name ?? "") + detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "") + controlWidth: 180 + divider: index < projectCard.stoppedSet.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Logs" + onClicked: Containers.openLogs(String(modelData.name)) + } + + SettingsButton { + text: "Start" + enabled: !Containers.busy + onClicked: Containers.start(String(modelData.name)) + } + } + } + } + } + } + + // ── containers no compose project claims ──────────────────────────── + + SettingsCard { + visible: Containers.loose.length > 0 + title: "Not part of a project" + subtitle: "Started directly rather than by a compose file." + + Repeater { + model: Containers.loose + + delegate: SettingRow { + required property var modelData + required property int index + width: parent.width + label: String(modelData.name ?? "") + detail: String(modelData.image ?? "") + " · " + String(modelData.status ?? "") + controlWidth: 180 + divider: index < Containers.loose.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Logs" + onClicked: Containers.openLogs(String(modelData.name)) + } + + SettingsButton { + text: modelData.state === "running" ? "Stop" : "Start" + enabled: !Containers.busy + onClicked: modelData.state === "running" + ? Containers.stop(String(modelData.name)) + : Containers.start(String(modelData.name)) + } + } + } + } + } + + TextRow { + visible: Containers.scanned && Containers.available && Containers.total === 0 + label: "No containers" + detail: "Nothing has been created on this machine yet." + value: "" + divider: false + } + } + + // ── logs ──────────────────────────────────────────────────────────────── + // + // A drill-in rather than a panel inside the scrolling page: logs need their + // own scrollback, and nesting one scrolling view inside another makes the + // wheel ambiguous over the region where you most want to use it. + + Item { + anchors.fill: parent + visible: Containers.logTarget !== "" + + Column { + id: logHeader + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 34 + anchors.rightMargin: 34 + anchors.topMargin: 30 + spacing: 6 + + Row { + width: parent.width + spacing: 12 + + SettingsButton { + text: "‹ Back" + anchors.verticalCenter: parent.verticalCenter + onClicked: Containers.closeLogs() + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Containers.logTarget + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 22 + font.weight: Font.DemiBold + } + } + + Text { + width: parent.width + text: Containers.logError !== "" + ? Containers.logError + : (Containers.logFollowing + ? "Following. The last " + Containers.logLines.count + " lines are shown." + : "The stream has ended.") + color: Containers.logError !== "" ? Theme.danger : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: logHeader.bottom + anchors.bottom: parent.bottom + anchors.leftMargin: 34 + anchors.rightMargin: 34 + anchors.topMargin: 14 + anchors.bottomMargin: 30 + radius: Theme.cardRadius + 2 + color: Theme.alpha(Theme.bgDark, 0.75) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.07) + + ListView { + id: logList + + anchors.fill: parent + anchors.margins: 12 + clip: true + model: Containers.logLines + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + cacheBuffer: 400 + + // Stay pinned to the newest line while the reader is already at + // the bottom, and leave the view alone the moment they scroll up + // to read something. + property bool pinned: true + onContentYChanged: logList.pinned = + logList.contentY >= logList.contentHeight - logList.height - 24 + onCountChanged: if (logList.pinned) logList.positionViewAtEnd() + + delegate: Text { + required property string line + width: logList.width - 24 + text: line + color: Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WrapAnywhere + textFormat: Text.PlainText + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 6662ffc..1d9099a 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -128,6 +128,7 @@ Rectangle { case "sharing": return sharingPage; case "firewall": return firewallPage; case "printers": return printersPage; + case "containers": return containersPage; case "services": return healthPage; case "about": return aboutPage; default: return homePage; @@ -172,6 +173,7 @@ Rectangle { Component { id: usersPage; UsersPage {} } Component { id: sharingPage; SharingPage {} } Component { id: firewallPage; FirewallPage {} } + Component { id: containersPage; ContainersPage {} } Component { id: printersPage; PrintersPage {} } Component { id: accessibilityPage; AccessibilityPage {} } Component { id: powerPage; PowerPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 550b2bb..f7c30d0 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -29,6 +29,7 @@ Rectangle { { page: "sharing", label: "Sharing", icon: "\u{F04E6}" }, { page: "firewall", label: "Firewall", icon: "\u{F0483}" }, { page: "printers", label: "Printers", icon: "\u{F042A}" }, + { page: "containers", label: "Containers", icon: "\u{F0868}" }, { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index abb7854..1b12c0b 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -4,6 +4,7 @@ AppearancePage 1.0 AppearancePage.qml AvatarPicker 1.0 AvatarPicker.qml ConnectivityPage 1.0 ConnectivityPage.qml FirewallPage 1.0 FirewallPage.qml +ContainersPage 1.0 ContainersPage.qml GamingPage 1.0 GamingPage.qml HomePhonePage 1.0 HomePhonePage.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml diff --git a/config/dot/quickshell/scripts/panama-containers b/config/dot/quickshell/scripts/panama-containers new file mode 100755 index 0000000..9b43ae9 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-containers @@ -0,0 +1,566 @@ +#!/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 "", + "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:])) diff --git a/config/dot/quickshell/services/Containers.qml b/config/dot/quickshell/services/Containers.qml new file mode 100644 index 0000000..fb81d25 --- /dev/null +++ b/config/dot/quickshell/services/Containers.qml @@ -0,0 +1,202 @@ +pragma Singleton + +// Rootless podman-compose stacks: what they are, what they expose, what they cost. +// +// Grouping is read from the compose labels rather than invented, and acting on a +// group is done with 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 it +// belongs to the repository. +// +// Rootless throughout, so nothing here prompts for a password. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-containers" + + // How many log lines are kept in memory. A busy container can emit + // thousands a minute, and the panel is for reading the recent past, not for + // archiving it. + readonly property int logLimit: 2000 + + property bool available: false + property var projects: [] + property var loose: [] + property int running: 0 + property int total: 0 + property var exposed: [] + property var disk: ({}) + property bool scanned: false + property string lastError: "" + + // Guards read these Processes directly. A derived `busy` binding returns its + // cached value inside the handler that changes its dependency, which is how + // a write can be dropped without any error at all. + readonly property bool busy: query.running || mutation.running + + // Published on every interface AND currently running: reachable now, as + // opposed to a stopped container that merely would be. + readonly property var reachable: root.exposed.filter(entry => entry.running === true) + readonly property var wouldExpose: root.exposed.filter(entry => entry.running !== true) + + readonly property var unusedImages: root.disk.unusedImages ?? [] + readonly property var unusedVolumes: root.disk.unusedVolumes ?? [] + readonly property int reclaimable: + Number(root.disk.imagesReclaimable ?? 0) + Number(root.disk.volumesReclaimable ?? 0) + + // The container whose logs are on screen, and whether podman is still + // feeding them. Empty when the log view is closed. + property string logTarget: "" + readonly property bool logFollowing: logs.running + property string logError: "" + + readonly property alias logLines: logModel + + // Decimal units, to match what podman itself prints. + function formatBytes(bytes: real): string { + if (!(bytes > 0)) + return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let index = 0; + while (value >= 1000 && index < units.length - 1) { + value /= 1000; + index += 1; + } + return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index]; + } + + // Paths are shown relative to home: the interesting part of a compose path + // is where it sits in the repository, not the eight characters before it. + function shorten(path: string): string { + const home = Quickshell.env("HOME") ?? ""; + return home !== "" && path.startsWith(home + "/") ? "~" + path.slice(home.length) : path; + } + + // Hand a compose file to whatever opens text files -- which on this machine + // is Neovim in kitty. + function openFile(path: string): void { + if (path === "") + return; + opener.command = ["xdg-open", path]; + opener.running = true; + } + + function refresh(): void { + if (query.running) + return; + query.command = [root.helperPath, "snapshot"]; + query.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.available = parsed.available === true; + root.projects = Array.isArray(parsed.projects) ? parsed.projects : []; + root.loose = Array.isArray(parsed.loose) ? parsed.loose : []; + root.running = Number(parsed.running ?? 0); + root.total = Number(parsed.total ?? 0); + root.exposed = Array.isArray(parsed.exposed) ? parsed.exposed : []; + root.disk = parsed.disk ?? ({}); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.lastError = "Could not read the state of the containers."; + console.warn("Containers: could not parse helper output:", error); + } + root.scanned = true; + } + + function run(arguments: var): void { + if (mutation.running) + return; + root.lastError = ""; + mutation.command = [root.helperPath].concat(arguments); + mutation.running = true; + } + + function start(name: string): void { root.run(["start", name]); } + function stop(name: string): void { root.run(["stop", name]); } + function restart(name: string): void { root.run(["restart", name]); } + + function startProject(name: string): void { root.run(["project-start", name]); } + function stopProject(name: string): void { root.run(["project-stop", name]); } + function restartProject(name: string): void { root.run(["project-restart", name]); } + + function pruneImages(): void { root.run(["prune-images"]); } + function pruneVolumes(): void { root.run(["prune-volumes"]); } + + // Bind a service's published ports to loopback by editing its compose file + // in place. The helper adds an address and leaves everything else alone, or + // refuses; it never rewrites a port it cannot read unambiguously. + function bindLocal(project: string, service: string): void { + root.run(["bind-local", project, service]); + } + + // ── logs ──────────────────────────────────────────────────────────────── + + function openLogs(name: string): void { + root.closeLogs(); + root.logTarget = name; + root.logError = ""; + // --tail bounds the initial burst: a container running for weeks would + // otherwise deliver its entire history before the first line appears. + logs.command = ["podman", "logs", "--tail", "400", "--timestamps", "--follow", name]; + logs.running = true; + } + + function closeLogs(): void { + if (logs.running) + logs.running = false; + logModel.clear(); + root.logTarget = ""; + root.logError = ""; + } + + function appendLog(line: string): void { + if (root.logTarget === "") + return; + logModel.append({ line }); + if (logModel.count > root.logLimit) + logModel.remove(0, logModel.count - root.logLimit); + } + + Component.onCompleted: root.refresh() + + ListModel { id: logModel } + + Process { + id: query + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { + id: mutation + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + Process { id: opener } + + Process { + id: logs + // podman writes container output to both streams; a log view that showed + // only one would silently drop half of what the container said. + stdout: SplitParser { onRead: line => root.appendLog(line) } + stderr: SplitParser { onRead: line => root.appendLog(line) } + onExited: (code, status) => { + if (root.logTarget !== "" && code !== 0) + root.logError = "The log stream ended unexpectedly."; + } + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 7d16d1a..c0fedc7 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -79,6 +79,11 @@ Singleton { { label: "Open ports", detail: "Which ports the firewall permits", page: "firewall" }, { label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" }, { label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" }, + { label: "Containers", detail: "Rootless podman stacks you run for development", page: "containers" }, + { label: "Podman", detail: "Running containers, images and volumes", page: "containers" }, + { label: "Container logs", detail: "Follow what a container is printing", page: "containers" }, + { label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" }, + { label: "Published ports", detail: "Which containers are reachable from the network", page: "containers" }, { label: "Remote login", detail: "Sign in to this machine over SSH", page: "sharing" }, { label: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" }, { label: "Network name", detail: "The name other machines see", page: "sharing" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 61e8db3..8d9da7b 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/local/share/vicinae/scripts/settings-containers.sh b/config/local/share/vicinae/scripts/settings-containers.sh new file mode 100755 index 0000000..2ca28b4 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-containers.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Containers +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Containers in Settings. +# @vicinae.keywords ["settings", "containers", "podman", "container logs", "reclaim container space", "published ports"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page containers diff --git a/tests/quickshell/containers-contract.sh b/tests/quickshell/containers-contract.sh new file mode 100755 index 0000000..c98d517 --- /dev/null +++ b/tests/quickshell/containers-contract.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +# The containers page shows rootless podman-compose stacks: what they are, what +# they expose, and what they cost. +# +# The rules: +# +# 1. A volume in use is never offered for deletion. MountCount is a runtime +# lock counter, not a usage signal -- it reads zero for a volume a running +# container has mounted this second. Trusting it offered to delete the live +# Command Center database. This is the rule the whole file exists for. +# 2. Grouping is read from the compose labels, never invented. Where the +# project name says nothing ("docker" is a directory), the repository does. +# 3. Acting on a stack uses plain `podman`, never `podman-compose down`, which +# would REMOVE containers this tool did not create. +# 4. Exposure means published on every interface. A stopped container exposes +# nothing yet, and is reported as such rather than as a live finding. +# 5. Pruning names what it removes, by id, from the state that was shown. +# 6. The compose edit adds an address and changes nothing else -- variables, +# quoting, comments and layout all survive -- and is undone if the file +# does not read back exactly as intended. +# 7. A refusal states its reason. "Something failed" has let a mutation +# through three times on this project. +# +# Every mutation runs against a stubbed podman. Nothing here starts, stops or +# removes a real container, image or volume: the containers on this machine are +# a working development database. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-containers" + +fail() { + printf 'containers contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -r "$helper" ]] || fail "missing $helper" +[[ -x "$helper" ]] || fail 'panama-containers is not executable' + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +calls="$work/calls.log" +: >"$calls" + +# A repository laid out like the real one: the compose file sits well below the +# root, in a directory called "docker", which is exactly how podman-compose ends +# up naming the project after a directory that means nothing. +repo="$work/command-center" +compose_dir="$repo/packages/db/docker" +mkdir -p "$compose_dir" +git -C "$repo" init --quiet 2>/dev/null || fail 'could not create the fixture repository' +compose="$compose_dir/compose.yml" + +# ── a podman that answers, and records what it was asked to do ─────────────── +# +# State lives in files so a test can change it between calls. Mutating verbs are +# recorded and otherwise do nothing, which is the entire point. + +cat >"$work/ps.json" <"$work/images.json" <<'IMGJSON' +[ + {"Id":"1111111111112222","Names":["docker.io/library/postgres:16"],"Size":458000000,"Containers":0}, + {"Id":"3333333333334444","Names":["pgvector:pg17"],"Size":450000000,"Containers":1} +] +IMGJSON + +# Two volumes exist. docker_kcc-pg-data is mounted by the RUNNING postgres, so +# podman does not consider it dangling; the anonymous one it does. +cat >"$work/volumes-dangling.json" <<'VOLJSON' +[{"Name":"7a830b1a58f2anonymous"}] +VOLJSON + +cat >"$work/df.json" <<'DFJSON' +[ + {"Type":"Images","Total":2,"Active":1,"RawSize":908000000,"RawReclaimable":458000000}, + {"Type":"Containers","Total":3,"Active":2,"RawSize":11886090,"RawReclaimable":10795752}, + {"Type":"Local Volumes","Total":2,"Active":1,"RawSize":274720148,"RawReclaimable":84280000} +] +DFJSON + +cat >"$work/podman" <>"$calls" +case "\$1 \$2" in + "ps -a") cat "$work/ps.json" ;; + "images --format") cat "$work/images.json" ;; + "system df") cat "$work/df.json" ;; + "volume ls") cat "$work/volumes-dangling.json" ;; + *) exit 0 ;; +esac +STUB +chmod +x "$work/podman" +export PANAMA_CONTAINERS_PODMAN="$work/podman" + +# His actual compose style: flow sequence, an interpolation containing a colon, +# and comments that must survive. +cat >"$compose" <<'COMPOSE' +services: + kcc-postgres: + # pgvector is the official Postgres image with the extension preinstalled. + image: docker.io/pgvector/pgvector:pg17 + ports: ["${POSTGRES_PORT:-5432}:5432"] + restart: unless-stopped + kcc-redis: + image: redis:7-alpine + ports: ["127.0.0.1:${REDIS_PORT:-6379}:6379"] +COMPOSE +cp "$compose" "$work/compose.original" + +snap() { "$helper" snapshot; } +field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } + +state="$(snap)" || fail 'snapshot failed' + +# ── 1. A volume in use is never offered for deletion ───────────────────────── + +unused_volumes="$(printf '%s' "$state" | field "['disk']['unusedVolumes']")" +case "$unused_volumes" in + *pg-data*) fail 'a volume mounted by a running container was offered for deletion' ;; +esac +[[ "$unused_volumes" == *anonymous* ]] || fail 'the genuinely unused volume was not found' + +grep -q 'dangling=true' "$helper" \ + || fail 'unused volumes are not read from podman own dangling filter' +# Matched as code, not as prose: the comment explaining why MountCount is the +# wrong signal is the reason this rule is here, and must not trip it. +grep -q 'get("MountCount")' "$helper" \ + && fail 'MountCount is being used to decide whether a volume is in use' + +# ── 2. Grouping comes from the labels, and says something ──────────────────── + +titles="$(printf '%s' "$state" | field "['projects']")" +[[ "$titles" == *"'name': 'docker'"* ]] || fail 'the compose project label was not read' +case "$titles" in + *"'title': 'docker'"*) fail 'a project named after a directory was shown as "docker"' ;; +esac + +running_first="$(printf '%s' "$state" | field "['projects'][0]['running']")" +[[ "$running_first" == "2" ]] || fail "the running project is not first (got $running_first)" + +# ── 3. Stacks are acted on with podman, never compose down ─────────────────── + +: >"$calls" +"$helper" project-stop docker >/dev/null || fail 'project-stop failed' +grep -q '^stop kcc-postgres$' "$calls" || fail 'project-stop did not stop the containers' +grep -qi 'compose' "$calls" && fail 'project-stop invoked podman-compose' +grep -qE '^(rm|rmi) ' "$calls" && fail 'project-stop removed something' + +# A verb with nothing to do refuses, and says why. +: >"$calls" +reason="$(printf '%s' "$("$helper" start kcc-postgres)" | field "['error']")" +[[ "$reason" == *"already running"* ]] \ + || fail "starting a running container did not say why it refused (got: $reason)" +[[ -s "$calls" ]] && grep -qE '^start ' "$calls" && fail 'it started an already-running container' + +# ── 4. Exposure is published-on-every-interface, and honest about state ────── + +exposed="$(printf '%s' "$state" | field "['exposed']")" +[[ "$exposed" == *kcc-postgres* ]] || fail 'a container published on every interface was not reported' +case "$exposed" in + *kcc-redis*) fail 'a container bound to loopback was reported as exposed' ;; +esac +[[ "$exposed" == *"'container': 'planner_db'"* ]] || fail 'a stopped publisher was dropped entirely' +printf '%s' "$state" | python3 -c " +import json,sys +for e in json.load(sys.stdin)['exposed']: + if e['container'] == 'planner_db' and e['running']: + raise SystemExit('a stopped container was reported as currently reachable') +" || fail 'a stopped container was reported as currently reachable' + +# ── 5. Pruning names what it removes ───────────────────────────────────────── + +: >"$calls" +"$helper" prune-images >/dev/null || fail 'prune-images failed' +grep -q '^rmi 111111111111$' "$calls" || fail 'prune-images did not remove the unused image by id' +grep -q '^rmi 333333333333$' "$calls" && fail 'prune-images removed an image still in use' +grep -q 'image prune' "$calls" && fail 'prune-images used a blanket prune instead of named ids' + +# Nothing to do refuses with a reason. +cat >"$work/images.json" <<'ALLUSED' +[{"Id":"3333333333334444","Names":["pgvector:pg17"],"Size":450000000,"Containers":1}] +ALLUSED +: >"$calls" +reason="$(printf '%s' "$("$helper" prune-images)" | field "['error']")" +[[ "$reason" == *"in use"* ]] || fail "prune-images did not say why it refused (got: $reason)" +grep -qE '^rmi ' "$calls" && fail 'prune-images removed something while refusing' + +# ── 6. The compose edit adds an address and nothing else ───────────────────── + +result="$("$helper" bind-local docker kcc-postgres)" || fail 'bind-local failed' +reason="$(printf '%s' "$result" | field "['error']")" +[[ -z "$reason" ]] || fail "bind-local refused a valid edit: $reason" + +grep -q 'ports: \["127.0.0.1:\${POSTGRES_PORT:-5432}:5432"\]' "$compose" \ + || fail 'the edit did not preserve the interpolation, the quoting, or the flow style' +grep -q '# pgvector is the official Postgres image' "$compose" \ + || fail 'the edit destroyed a comment' +grep -q 'restart: unless-stopped' "$compose" \ + || fail 'the edit disturbed the rest of the service' +[[ "$(grep -c '' "$compose")" == "$(grep -c '' "$work/compose.original")" ]] \ + || fail 'the edit changed the shape of the file' + +# Twice is a refusal, not a second address. +reason="$(printf '%s' "$("$helper" bind-local docker kcc-postgres)" | field "['error']")" +[[ -n "$reason" ]] || fail 'binding an already-bound service was allowed' +grep -q '127.0.0.1:127.0.0.1' "$compose" && fail 'the address was prepended twice' + +# A service whose ports it cannot read is refused, not guessed at. +cat >"$compose" <<'RANGE' +services: + kcc-postgres: + ports: + - "5432:5432" + - "5432:5432" +RANGE +reason="$(printf '%s' "$("$helper" bind-local docker kcc-postgres)" | field "['error']")" +[[ "$reason" == *"times"* || "$reason" == *"not clear"* ]] \ + || fail "an ambiguous mapping was not refused with a reason (got: $reason)" +grep -q '127.0.0.1' "$compose" && fail 'an ambiguous compose file was edited anyway' + +# ── 7. The tests above must be capable of failing ──────────────────────────── +# +# Rule 1 is the one that matters, so it is proven to fail: told to use +# MountCount, the helper must offer the live database for deletion and be +# caught. A guard nobody has seen fail is not a guard. + +probe="$work/probe-helper" +sed 's/"volume", "ls", "--filter", "dangling=true", "--format", "json"/"volume", "ls", "--format", "json"/' \ + "$helper" >"$probe" +chmod +x "$probe" +cat >"$work/volumes-all.json" <<'ALLVOL' +[{"Name":"docker_kcc-pg-data","MountCount":0},{"Name":"7a830b1a58f2anonymous","MountCount":0}] +ALLVOL +sed -i "s|\"volume ls\") cat \"$work/volumes-dangling.json\"|\"volume ls\") cat \"$work/volumes-all.json\"|" "$work/podman" +cat >"$work/podman" <>"$calls" +case "\$1 \$2" in + "ps -a") cat "$work/ps.json" ;; + "images --format") cat "$work/images.json" ;; + "system df") cat "$work/df.json" ;; + "volume ls") cat "$work/volumes-all.json" ;; + *) exit 0 ;; +esac +STUB2 +chmod +x "$work/podman" + +leaked="$("$probe" snapshot | field "['disk']['unusedVolumes']")" +[[ "$leaked" == *pg-data* ]] \ + || fail 'the volume guard cannot be made to fail, so it proves nothing' + +printf 'containers contract: ok\n'