Add a Containers page, grouped by project and led by what is exposed
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
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ Rectangle {
|
|||||||
case "sharing": return sharingPage;
|
case "sharing": return sharingPage;
|
||||||
case "firewall": return firewallPage;
|
case "firewall": return firewallPage;
|
||||||
case "printers": return printersPage;
|
case "printers": return printersPage;
|
||||||
|
case "containers": return containersPage;
|
||||||
case "services": return healthPage;
|
case "services": return healthPage;
|
||||||
case "about": return aboutPage;
|
case "about": return aboutPage;
|
||||||
default: return homePage;
|
default: return homePage;
|
||||||
@@ -172,6 +173,7 @@ Rectangle {
|
|||||||
Component { id: usersPage; UsersPage {} }
|
Component { id: usersPage; UsersPage {} }
|
||||||
Component { id: sharingPage; SharingPage {} }
|
Component { id: sharingPage; SharingPage {} }
|
||||||
Component { id: firewallPage; FirewallPage {} }
|
Component { id: firewallPage; FirewallPage {} }
|
||||||
|
Component { id: containersPage; ContainersPage {} }
|
||||||
Component { id: printersPage; PrintersPage {} }
|
Component { id: printersPage; PrintersPage {} }
|
||||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||||
Component { id: powerPage; PowerPage {} }
|
Component { id: powerPage; PowerPage {} }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Rectangle {
|
|||||||
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
{ page: "sharing", label: "Sharing", icon: "\u{F04E6}" },
|
||||||
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||||
|
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
|
||||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ AppearancePage 1.0 AppearancePage.qml
|
|||||||
AvatarPicker 1.0 AvatarPicker.qml
|
AvatarPicker 1.0 AvatarPicker.qml
|
||||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||||
FirewallPage 1.0 FirewallPage.qml
|
FirewallPage 1.0 FirewallPage.qml
|
||||||
|
ContainersPage 1.0 ContainersPage.qml
|
||||||
GamingPage 1.0 GamingPage.qml
|
GamingPage 1.0 GamingPage.qml
|
||||||
HomePhonePage 1.0 HomePhonePage.qml
|
HomePhonePage 1.0 HomePhonePage.qml
|
||||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||||
|
|||||||
+566
@@ -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 "<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:]))
|
||||||
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ Singleton {
|
|||||||
{ label: "Open ports", detail: "Which ports the firewall permits", page: "firewall" },
|
{ 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: "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: "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 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: "Remote desktop", detail: "See and control this desktop from elsewhere", page: "sharing" },
|
||||||
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
{ label: "Network name", detail: "The name other machines see", page: "sharing" },
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
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";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+275
@@ -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" <<PSJSON
|
||||||
|
[
|
||||||
|
{"Id":"aaaaaaaaaaaa1","Names":["kcc-postgres"],"Image":"pgvector:pg17",
|
||||||
|
"State":"running","Status":"Up 32 hours (healthy)","ExitCode":0,"StartedAt":1,"Restarts":0,
|
||||||
|
"Ports":[{"host_ip":"","host_port":5432,"container_port":5432,"protocol":"tcp","range":1}],
|
||||||
|
"Labels":{"com.docker.compose.project":"docker","com.docker.compose.service":"kcc-postgres",
|
||||||
|
"com.docker.compose.project.config_files":"$compose",
|
||||||
|
"com.docker.compose.project.working_dir":"$compose_dir"}},
|
||||||
|
{"Id":"aaaaaaaaaaaa2","Names":["kcc-redis"],"Image":"redis:7-alpine",
|
||||||
|
"State":"running","Status":"Up 32 hours (healthy)","ExitCode":0,"StartedAt":1,"Restarts":0,
|
||||||
|
"Ports":[{"host_ip":"127.0.0.1","host_port":6379,"container_port":6379,"protocol":"tcp","range":1}],
|
||||||
|
"Labels":{"com.docker.compose.project":"docker","com.docker.compose.service":"kcc-redis",
|
||||||
|
"com.docker.compose.project.config_files":"$compose",
|
||||||
|
"com.docker.compose.project.working_dir":"$compose_dir"}},
|
||||||
|
{"Id":"bbbbbbbbbbbb1","Names":["planner_db"],"Image":"postgres:17",
|
||||||
|
"State":"exited","Status":"Exited (137) 3 weeks ago","ExitCode":137,"StartedAt":1,"Restarts":0,
|
||||||
|
"Ports":[{"host_ip":"","host_port":54322,"container_port":5432,"protocol":"tcp","range":1}],
|
||||||
|
"Labels":{"com.docker.compose.project":"planner","com.docker.compose.service":"db"}}
|
||||||
|
]
|
||||||
|
PSJSON
|
||||||
|
|
||||||
|
cat >"$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" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "\$*" >>"$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" <<STUB2
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "\$*" >>"$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'
|
||||||
Reference in New Issue
Block a user