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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: report renovate service versions by ineedjet · Pull Request #181 · rubykatzen/flightdeck · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/renovate/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,7 +30,7 @@ jobs:
parse-mode: MarkdownV2
```

Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares `docker compose images -q` output before and after the pull, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group updated apps by host in notifications; `updated-hosts` remains available as the original comma-separated compatibility output.
Not every target runs every requested app - `renovate.py` decides that itself, from the target manifest's own `apps` mapping, and simply does nothing (never opening an SSH connection) if none of the requested apps are present. To tell whether a host's image actually changed (rather than the pull being a no-op), it compares the immutable image IDs used by each Compose service before the pull and after `docker compose up`, and exposes that as this action's own `updated`/`updated-hosts`/`updated-items`/`target-name` outputs. `updated-items` is the structured JSON form used to group changed services by app and host and show their version transitions in notifications; it uses the image's OCI version label when available and falls back to a short image ID. `updated-hosts` remains available as the original comma-separated compatibility output.

The action prepares a host-grouped message as `telegram-message` only when something changed, but never sends it or receives Telegram credentials. Sending the message is the *caller's* job, as a separate step reading that output, same as shown above - a different caller is free to wire up a different channel, or none, without forking this action.

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/renovate/action.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ outputs:
description: Comma-separated app@host pairs that were actually updated.
value: ${{ steps.run.outputs.updated_hosts }}
updated-items:
description: JSON array of appand host objects that were actually updated.
description: JSON array of app, host, and per-service image-version changes that were actually updated.
value: ${{ steps.run.outputs.updated_items }}
target-name:
description: This target's name, derived from the manifest's own filename.
Expand Down
67 changes: 57 additions & 10 deletions .github/actions/renovate/renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,8 @@
calling workflow can notify only when a host's image actually changed,
rather than on every run. `updated_hosts` lists `app@host` pairs, since
more than one app may have been renovated in the same run. `updated_items`
contains the same data as structured JSON for downstream formatting.
contains the same data plus per-service image-version transitions as
structured JSON for downstream formatting.
"""
import json
import os
Expand All@@ -44,18 +45,63 @@ def expand_home(path, home):
return home + path[1:] if path.startswith("~") else path


def image_version(connection, image_id):
command = f"docker image inspect {shlex.quote(image_id)} --format '{{{{json .Config.Labels}}}}'"
labels = json.loads(connection.run(command, hide=True).stdout.strip() or "null") or {}
return labels.get("org.opencontainers.image.version") or labels.get("org.label-schema.version")


def container_snapshot(connection, compose_dir):
result = connection.run(f"cd {compose_dir} && docker compose ps --all -q", hide=True)
snapshot = {}
versions = {}
for container_id in result.stdout.splitlines():
inspect = connection.run(f"docker inspect {shlex.quote(container_id)}", hide=True)
container = json.loads(inspect.stdout)[0]
config = container["Config"]
service = config["Labels"]["com.docker.compose.service"]
image_id = container["Image"]
if image_id not in versions:
versions[image_id] = image_version(connection, image_id)
snapshot[service] = {
"id": image_id,
"image": config["Image"],
"version": versions[image_id],
}
return snapshot


def image_transitions(before, after):
transitions = []
for service in sorted(after):
current = after[service]
previous = before.get(service)
if previous and previous["id"] == current["id"]:
continue
transitions.append(
{
"service": service,
"image": current["image"],
"before": {
"id": previous["id"] if previous else "none",
"version": previous["version"] if previous else None,
},
"after": {"id": current["id"], "version": current["version"]},
}
)
return transitions


def renovate_host(host, base_path, app):
connection = Connection(host)
connection.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
home = connection.run("echo $HOME", hide=True).stdout.strip()
compose_dir = shlex.quote(f"{expand_home(base_path, home)}/current/apps/{app}")
command = (
f"cd {compose_dir} && before=$(docker compose images -q) && docker compose pull "
f'&& after=$(docker compose images -q) && docker compose up -d --remove-orphans '
f'&& if [ "$before" != "$after" ]; then echo RENOVATE_UPDATED; fi'
)
result = connection.run(command)
return "RENOVATE_UPDATED" in result.stdout
before = container_snapshot(connection, compose_dir)
connection.run(f"cd {compose_dir} && docker compose pull")
connection.run(f"cd {compose_dir} && docker compose up -d --remove-orphans")
after = container_snapshot(connection, compose_dir)
return image_transitions(before, after)


def write_github_output(name, value):
Expand DownExpand Up@@ -90,8 +136,9 @@ def main():
for app in matching_apps:
for host in target["hosts"]:
print(f"Renovating {app} on {host}")
if renovate_host(host, base_path, app):
updated.append({"app": app, "host": host})
changes = renovate_host(host, base_path, app)
if changes:
updated.append({"app": app, "host": host, "changes": changes})

write_github_output("updated", "true" if updated else "false")
write_github_output("updated_hosts", ",".join(f"{item['app']}@{item['host']}" for item in updated))
Expand Down
173 changes: 155 additions & 18 deletions .github/actions/renovate/tests/test_renovate.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,17 +22,41 @@ class FakeConnection:
"""Stand-in for fabric.Connection - records commands instead of opening
a real SSH session, mirroring test_deploy.py's FakeConnection."""

def __init__(self, host, pull_stdout=""):
def __init__(self, host, snapshots=None, labels=None):
self.host = host
self.client = SimpleNamespace(set_missing_host_key_policy=lambda policy: None)
self.commands = []
self.pull_stdout = pull_stdout
self.snapshots = list(snapshots or [{}, {}])
self.current_snapshot = {}
self.labels = labels or {}

def run(self, command, hide=False):
self.commands.append(command)
if command == "echo $HOME":
return SimpleNamespace(stdout="/home/deploy\n")
return SimpleNamespace(stdout=self.pull_stdout)
if command.endswith("docker compose ps --all -q"):
self.current_snapshot = self.snapshots.pop(0)
return SimpleNamespace(stdout="\n".join(self.current_snapshot))
if command.startswith("docker inspect "):
container_id = command.split()[2]
container = self.current_snapshot[container_id]
return SimpleNamespace(
stdout=json.dumps(
[
{
"Image": container["image_id"],
"Config": {
"Image": container["image"],
"Labels": {"com.docker.compose.service": container["service"]},
},
}
]
)
)
if command.startswith("docker image inspect "):
image_id = command.split()[3]
return SimpleNamespace(stdout=json.dumps(self.labels.get(image_id)))
return SimpleNamespace(stdout="")


class ExpandHomeTest(unittest.TestCase):
Expand All@@ -51,20 +75,118 @@ def test_pulls_and_recreates_without_touching_the_release(self):

joined = "\n".join(fake.commands)
self.assertIn("cd /home/deploy/flightdeck/current/apps/beszel", joined)
self.assertIn("before=$(docker compose images -q)", joined)
self.assertIn("docker compose pull", joined)
self.assertIn("after=$(docker compose images -q)", joined)
self.assertIn("docker compose up -d --remove-orphans", joined)
self.assertEqual(joined.count("docker compose ps --all -q"), 2)

def test_returns_version_transition_when_the_image_changed(self):
before = {
"container-old": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:old",
}
}
after = {
"container-new": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:new",
}
}
labels = {
"sha256:old": {"org.opencontainers.image.version": "0.12.8"},
"sha256:new": {"org.opencontainers.image.version": "0.12.9"},
}
fake = FakeConnection("deploy@host", [before, after], labels)
with patch.object(renovate, "Connection", return_value=fake):
self.assertEqual(
renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"),
[
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
],
)

def test_returns_true_when_the_image_changed(self):
fake = FakeConnection("deploy@host", pull_stdout="RENOVATE_UPDATED\n")
def test_returns_no_transitions_when_the_image_was_already_current(self):
containers = {
"container": {
"service": "beszel",
"image": "henrygd/beszel:latest",
"image_id": "sha256:same",
}
}
fake = FakeConnection("deploy@host", [containers, containers])
with patch.object(renovate, "Connection", return_value=fake):
self.assertTrue(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
self.assertEqual(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"), [])

def test_reports_the_specific_changed_service_in_a_multiservice_app(self):
before = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-old",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
after = {
"client-container": {
"service": "client",
"image": "ghcr.io/rybbit-io/rybbit-client:latest",
"image_id": "sha256:client-new",
},
"backend-container": {
"service": "backend",
"image": "ghcr.io/rybbit-io/rybbit-backend:latest",
"image_id": "sha256:backend-same",
},
}
labels = {
"sha256:client-old": {"org.opencontainers.image.version": "1.6.0"},
"sha256:client-new": {"org.opencontainers.image.version": "1.6.1"},
}
fake = FakeConnection("deploy@host", [before, after], labels)

def test_returns_false_when_the_image_was_already_current(self):
fake = FakeConnection("deploy@host", pull_stdout="")
with patch.object(renovate, "Connection", return_value=fake):
self.assertFalse(renovate.renovate_host("deploy@host", "~/flightdeck", "beszel"))
changes = renovate.renovate_host("deploy@host", "~/flightdeck", "rybbit")

self.assertEqual([change["service"] for change in changes], ["client"])

def test_preserves_missing_versions_for_downstream_image_id_fallback(self):
before = {
"app": {
"id": "sha256:1234567890abcdef",
"image": "app:latest",
"version": None,
}
}
after = {
"app": {
"id": "sha256:abcdef1234567890",
"image": "app:latest",
"version": None,
}
}

self.assertEqual(
renovate.image_transitions(before, after),
[
{
"service": "app",
"image": "app:latest",
"before": {"id": "sha256:1234567890abcdef", "version": None},
"after": {"id": "sha256:abcdef1234567890", "version": None},
}
],
)


class MainTest(unittest.TestCase):
Expand DownExpand Up@@ -95,7 +217,7 @@ def test_skips_a_target_that_runs_none_of_the_requested_apps(self):
def test_renovates_every_app_when_apps_is_empty(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, [], "apps:\n traefik: {}\n gatus: {}\nhosts: [deploy@host]\n")

Expand All@@ -116,7 +238,7 @@ def test_skips_a_target_with_no_apps_when_apps_is_empty(self):
def test_renovates_only_the_requested_apps_present_on_the_target(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(
directory,
Expand All@@ -128,7 +250,16 @@ def test_renovates_only_the_requested_apps_present_on_the_target(self):

def test_reports_updated_app_host_pairs_when_the_image_changed(self):
def fake_renovate_host(host, base_path, app):
return app == "beszel" and host == "deploy@app1.example.com"
if app == "beszel" and host == "deploy@app1.example.com":
return [
{
"service": "beszel",
"image": "henrygd/beszel:latest",
"before": {"id": "sha256:old", "version": "0.12.8"},
"after": {"id": "sha256:new", "version": "0.12.9"},
}
]
return []

manifest = (
"apps:\n beszel: {}\n traefik: {}\n"
Expand All@@ -142,12 +273,18 @@ def fake_renovate_host(host, base_path, app):

self.assertIn("updated=true\n", outputs)
self.assertIn("updated_hosts=beszel@deploy@app1.example.com\n", outputs)
self.assertIn('updated_items=[{"app":"beszel","host":"deploy@app1.example.com"}]\n', outputs)
self.assertIn(
'updated_items=[{"app":"beszel","host":"deploy@app1.example.com","changes":'
'[{"service":"beszel","image":"henrygd/beszel:latest","before":'
'{"id":"sha256:old","version":"0.12.8"},"after":'
'{"id":"sha256:new","version":"0.12.9"}}]}]\n',
outputs,
)

def test_reports_not_updated_when_every_host_was_already_current(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -158,7 +295,7 @@ def test_reports_not_updated_when_every_host_was_already_current(self):
def test_defaults_path_when_omitted(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False) as fake_renovate_host,
patch.object(renovate, "renovate_host", return_value=[]) as fake_renovate_host,
):
self._run_main(directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n")

Expand All@@ -167,7 +304,7 @@ def test_defaults_path_when_omitted(self):
def test_derives_target_name_from_manifest_filename(self):
with (
tempfile.TemporaryDirectory() as directory,
patch.object(renovate, "renovate_host", return_value=False),
patch.object(renovate, "renovate_host", return_value=[]),
):
outputs = self._run_main(
directory, ["beszel"], "apps:\n beszel: {}\nhosts: [deploy@host]\n", manifest_name="mainframe"
Expand Down
Loading