Skip to content

Commit ace42bb

Browse files
committed
swap to smaller jpgs for screenshots, add package diff concept, update reference URLs in report to be real URLs, add copy button with docker pull command
1 parent c1ec8e2 commit ace42bb

5 files changed

Lines changed: 143 additions & 23 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ chromium output/linuxserver/plex/latest/index.html
8080
| `WEB_AUTH` | Credentials for basic auth, format `user:password`. Leave empty for none. | `""` |
8181
| `WEB_SCREENSHOT_DELAY` | Seconds to wait after the page loads before taking the screenshot. | `20` |
8282

83+
### Generating a package diff
84+
85+
In development mode you can build an actual comparison image by tagging it as `testimage:latest`. This will simulate the pacakge difference diff by dumping the SBOM of your test image and comparing it to the last release of the repo.
8386

8487
## Advanced Usage (CI Environment)
8588

ci/ci.py

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import mimetypes
1212
import json
1313
import subprocess
14+
import io
15+
from PIL import Image
1416
from requests.adapters import HTTPAdapter
1517
from urllib3.util.retry import Retry
1618
from functools import wraps
@@ -125,6 +127,8 @@ def __init__(self) -> None:
125127
self.region: str = os.environ.get("S3_REGION", "us-east-1")
126128
self.bucket: str = os.environ.get("S3_BUCKET", "ci-tests.linuxserver.io")
127129
self.release_tag: str = os.environ.get("RELEASE_TAG", "latest")
130+
self.release_type: str = os.environ.get("RELEASE_TYPE", "stable")
131+
self.ls_branch: str = os.environ.get("LS_BRANCH", "master")
128132
self.syft_image_tag: str = os.environ.get("SYFT_IMAGE_TAG", "v1.26.1")
129133
self.commit_sha: str = os.environ.get("COMMIT_SHA", "")
130134
self.build_number: str = os.environ.get("BUILD_NUMBER", "")
@@ -159,6 +163,7 @@ def __init__(self) -> None:
159163
BASE: '{os.environ.get("BASE")}'
160164
META_TAG: '{os.environ.get("META_TAG")}'
161165
RELEASE_TAG: '{os.environ.get("RELEASE_TAG")}'
166+
RELEASE_TYPE: '{os.environ.get("RELEASE_TYPE")}'
162167
TAGS: '{os.environ.get("TAGS")}'
163168
S6_VERBOSITY: '{os.environ.get("S6_VERBOSITY")}'
164169
CI_S6_VERBOSITY '{os.environ.get("CI_S6_VERBOSITY")}'
@@ -386,18 +391,21 @@ def container_test(self, tag: str) -> None:
386391
self._endtest(container, tag, build_info, sbom, False, start_time)
387392
return
388393

394+
# Calculate package diff
395+
package_diff = self.get_package_diff(sbom)
396+
389397
# Screenshot the web interface and check connectivity
390398
screenshot_success, browser_logs = self.take_screenshot(container, tag)
391399
if not screenshot_success and self.get_platform(tag) == Platform.AMD64.value:
392400
self.logger.error("Test of %s FAILED after %.2f seconds", tag, time.time() - start_time)
393-
self._endtest(container, tag, build_info, sbom, False, start_time, browser_logs)
401+
self._endtest(container, tag, build_info, sbom, False, start_time, browser_logs, package_diff)
394402
return
395403

396-
self._endtest(container, tag, build_info, sbom, True, start_time, browser_logs)
404+
self._endtest(container, tag, build_info, sbom, True, start_time, browser_logs, package_diff)
397405
self.logger.success("Test of %s PASSED after %.2f seconds", tag, time.time() - start_time)
398406
return
399407

400-
def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packages:str|CITestResult, test_success: bool, start_time:float|int = 0.0, browser_logs: str = "") -> None:
408+
def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packages:str|CITestResult, test_success: bool, start_time:float|int = 0.0, browser_logs: str = "", package_diff: str = "") -> None:
401409
"""End the test with as much info as we have and append to the report.
402410
403411
Args:
@@ -408,6 +416,7 @@ def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packa
408416
`test_success` (bool): If the testing of the container failed or not
409417
`start_time` (float, optional): The start time of the test. Defaults to 0.0. Used to calculate the runtime of the test.
410418
`browser_logs` (str, optional): The browser console logs.
419+
`package_diff` (str, optional): The diff of packages between this build and the last release.
411420
"""
412421
if not start_time:
413422
runtime = "-"
@@ -429,6 +438,7 @@ def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packa
429438
self.report_containers[tag] = {
430439
"logs": logblob,
431440
"sysinfo": packages,
441+
"package_diff": package_diff,
432442
"browser_logs": browser_logs,
433443
"warnings": {
434444
"dotnet": warning_texts["dotnet"] if "icu-libs" in packages and "arm32" in tag else "",
@@ -596,6 +606,13 @@ def make_sbom(self, tag: str) -> "str|CITestResult":
596606
self.logger.warning("Falling back to Syft for SBOM generation on tag %s", tag)
597607

598608
# Fallback to syft if buildx failed
609+
if os.environ.get("CI_LOCAL_MODE", "false").lower() == "true":
610+
self.logger.info("Local mode detected, attempting to generate SBOM from local 'testimage:latest'")
611+
sbom = self.get_sbom_syft(tag, override_image="testimage:latest")
612+
if sbom != CITestResult.ERROR:
613+
self._add_test_result(tag, CITests.CREATE_SBOM, CITestResult.PASS, "Generated from testimage:latest", start_time)
614+
self.create_html_ansi_file(str(sbom),tag,"sbom")
615+
return sbom
599616
sbom = self.get_sbom_syft(tag)
600617
if sbom != CITestResult.ERROR:
601618
self._add_test_result(tag, CITests.CREATE_SBOM, CITestResult.PASS, "-", start_time)
@@ -605,23 +622,26 @@ def make_sbom(self, tag: str) -> "str|CITestResult":
605622
self.report_status = CIReportResult.FAIL
606623
self._add_test_result(tag, CITests.CREATE_SBOM, CITestResult.FAIL, "Failed to generate SBOM with both buildx and syft", start_time)
607624
return CITestResult.ERROR
608-
609-
def get_sbom_syft(self, tag: str) -> str | CITestResult:
625+
626+
def get_sbom_syft(self, tag: str, override_image: str = None) -> str | CITestResult:
610627
"""Get the SBOM for the image tag using Syft.
611628
612629
Args:
613630
tag (str): The tag we are testing
631+
override_image (str, optional): Use this image name instead of self.image:tag. Defaults to None.
614632
Returns:
615633
str: SBOM output if successful, otherwise "ERROR".
616634
"""
617635
start_time = time.time()
618636
platform: str = self.get_platform(tag)
619-
syft:Container = self.client.containers.run(image=f"ghcr.io/anchore/syft:{self.syft_image_tag}",command=f"{self.image}:{tag} --platform=linux/{platform}",
637+
target_image = override_image if override_image else f"{self.image}:{tag}"
638+
cmd = f"{target_image}" if override_image else f"{target_image} --platform=linux/{platform}"
639+
syft:Container = self.client.containers.run(image=f"ghcr.io/anchore/syft:{self.syft_image_tag}",command=cmd,
620640
detach=True, volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}})
621-
self.logger.info("Creating SBOM package list on %s with syft version %s",tag,self.syft_image_tag)
641+
self.logger.info("Creating SBOM package list on %s with syft version %s", target_image, self.syft_image_tag)
622642
logblob: str = ""
623643
t_end: float = time.time() + self.sbom_timeout
624-
self.logger.info("Tailing the Syft container logs for %s seconds looking the 'VERSION' message on tag: %s",self.sbom_timeout,tag)
644+
self.logger.info("Tailing the Syft container logs for %s seconds looking the 'VERSION' message on tag: %s",self.sbom_timeout,tag)
625645
while time.time() < t_end:
626646
time.sleep(5)
627647
try:
@@ -809,9 +829,9 @@ def get_build_url(self, tag: str) -> str:
809829
_, container_name = self.image.split("/")
810830
match self.image:
811831
case _ if "lspipepr" in self.image:
812-
return f"https://ghcr.io/linuxserver/lspipepr-{container_name}:{tag}"
832+
return f"https://hub.docker.com/r/lspipepr/{container_name}/tags?page=1&name={tag}"
813833
case _ if "lsiodev" in self.image:
814-
return f"https://ghcr.io/linuxserver/lsiodev-{container_name}:{tag}"
834+
return f"https://hub.docker.com/r/lsiodev/{container_name}/tags?page=1&name={tag}"
815835
case _ if "lsiobase" in self.image:
816836
return f"https://ghcr.io/linuxserver/baseimage-{container_name}:{tag}"
817837
case _:
@@ -1047,6 +1067,68 @@ def _add_test_result(self, tag:str, test:CITests, status:CITestResult, message:s
10471067
"message":message,
10481068
"runtime": runtime}.items())))
10491069

1070+
def get_package_diff(self, current_sbom: str | CITestResult) -> str:
1071+
"""Fetch the last release/branch SBOM and generate a diff against the current SBOM."""
1072+
if isinstance(current_sbom, CITestResult):
1073+
return ""
1074+
try:
1075+
# Determine repo name
1076+
container_name = self.image.split("/")[-1]
1077+
for prefix in ["lspipepr-", "lsiodev-"]:
1078+
if container_name.startswith(prefix):
1079+
container_name = container_name.replace(prefix, "")
1080+
if self.release_type == "stable":
1081+
repo_api = f"https://api.github.com/repos/linuxserver/docker-{container_name}/releases/latest"
1082+
resp = requests.get(repo_api, timeout=10)
1083+
if resp.status_code != 200:
1084+
self.logger.warning("Could not fetch latest release info from GitHub: %s", resp.status_code)
1085+
return ""
1086+
tag_name = resp.json().get("tag_name")
1087+
if not tag_name:
1088+
return ""
1089+
raw_sbom_url = f"https://raw.githubusercontent.com/linuxserver/docker-{container_name}/refs/tags/{tag_name}/package_versions.txt"
1090+
else:
1091+
raw_sbom_url = f"https://raw.githubusercontent.com/linuxserver/docker-{container_name}/refs/heads/{self.ls_branch}/package_versions.txt"
1092+
# Get remote SBOM
1093+
resp_sbom = requests.get(raw_sbom_url, timeout=10)
1094+
if resp_sbom.status_code != 200:
1095+
self.logger.warning("Could not fetch remote SBOM from %s: %s", raw_sbom_url, resp_sbom.status_code)
1096+
return ""
1097+
remote_pkgs = self._parse_sbom_string(resp_sbom.text)
1098+
current_pkgs = self._parse_sbom_string(current_sbom)
1099+
return self._generate_diff_text(remote_pkgs, current_pkgs)
1100+
except Exception:
1101+
self.logger.exception("Failed to generate package diff")
1102+
return ""
1103+
1104+
def _parse_sbom_string(self, sbom_text: str) -> dict[str, str]:
1105+
"""Parse the formatted SBOM table into a dictionary."""
1106+
pkgs = {}
1107+
lines = sbom_text.strip().splitlines()
1108+
# Skip header if present
1109+
if lines and "NAME" in lines[0] and "VERSION" in lines[0]:
1110+
lines = lines[1:]
1111+
for line in lines:
1112+
parts = line.split()
1113+
if len(parts) >= 2:
1114+
pkgs[parts[0]] = parts[1]
1115+
return pkgs
1116+
1117+
def _generate_diff_text(self, old_pkgs: dict[str, str], new_pkgs: dict[str, str]) -> str:
1118+
"""Generate a text diff between two package lists."""
1119+
diff_lines = []
1120+
all_keys = set(old_pkgs.keys()) | set(new_pkgs.keys())
1121+
for pkg in sorted(all_keys):
1122+
old_ver = old_pkgs.get(pkg)
1123+
new_ver = new_pkgs.get(pkg)
1124+
if old_ver is None:
1125+
diff_lines.append(f"[+] {pkg}: {new_ver} (Added)")
1126+
elif new_ver is None:
1127+
diff_lines.append(f"[-] {pkg}: {old_ver} (Removed)")
1128+
elif old_ver != new_ver:
1129+
diff_lines.append(f"[*] {pkg}: {old_ver} -> {new_ver} (Changed)")
1130+
return "\n".join(diff_lines) if diff_lines else "No package changes found."
1131+
10501132
def take_screenshot(self, container: Container, tag:str) -> tuple[bool, str]:
10511133
"""Take a screenshot and save it to self.outdir if self.screenshot is True
10521134
@@ -1080,9 +1162,14 @@ def take_screenshot(self, container: Container, tag:str) -> tuple[bool, str]:
10801162
driver.get(endpoint)
10811163
time.sleep(self.screenshot_delay) # A grace period for the page to load
10821164
self.logger.debug("Trying to take screenshot of %s at %s", tag, endpoint)
1083-
driver.get_screenshot_as_file(f"{self.outdir}/{tag}.png")
1084-
if not os.path.isfile(f"{self.outdir}/{tag}.png"):
1085-
raise FileNotFoundError(f"Screenshot '{self.outdir}/{tag}.png' not found")
1165+
1166+
png_data = driver.get_screenshot_as_png()
1167+
image = Image.open(io.BytesIO(png_data))
1168+
rgb_im = image.convert('RGB')
1169+
rgb_im.save(f"{self.outdir}/{tag}.jpg", quality=80)
1170+
1171+
if not os.path.isfile(f"{self.outdir}/{tag}.jpg"):
1172+
raise FileNotFoundError(f"Screenshot '{self.outdir}/{tag}.jpg' not found")
10861173
self._add_test_result(tag, CITests.CAPTURE_SCREENSHOT, CITestResult.PASS, "-", start_time)
10871174
self.logger.success("Screenshot %s: PASSED after %.2f seconds", tag, time.time() - start_time)
10881175
return True, self._get_browser_logs(driver, tag)

ci/template.html

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -564,8 +564,21 @@ <h1>Linux<span>Server</span>.io</h1>
564564
</header>
565565
<div id="results">
566566
<h1 style="margin-bottom: 0; text-align: center;">Test Results</h1>
567-
<h2 style="margin-bottom: 0; text-align: center;"><strong>{{ image }}</strong></span></h2>
568-
<h3 style="margin-top:0; margin-bottom: 0; text-align: center;"><strong>{{ meta_tag }}</strong></span></h2>
567+
<div style="display: flex; justify-content: center; margin: 10px 0 20px;">
568+
<div style="background: #1f1f1f; padding: 10px 15px; border-radius: 5px; border: 1px solid rgba(128,128,128,0.5); display: flex; align-items: center; gap: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.2);">
569+
<code id="pull-cmd" style="color: #dce2ec; font-family: monospace; font-size: 1.1em;">docker pull {{ image }}:{{ meta_tag }}</code>
570+
<i class="fas fa-copy" id="copy-btn" style="color: #da3b8a; cursor: pointer;" onclick="copyToClipboard()" title="Copy"></i>
571+
</div>
572+
</div>
573+
<script>
574+
function copyToClipboard() {
575+
const text = document.getElementById('pull-cmd').innerText;
576+
navigator.clipboard.writeText(text);
577+
const btn = document.getElementById('copy-btn');
578+
btn.className = 'fas fa-check';
579+
setTimeout(() => { btn.className = 'fas fa-copy'; }, 2000);
580+
}
581+
</script>
569582
<h2 style="margin-bottom: 0">Cumulative: <span class="report-status-{{ report_status.lower() }}">{{ report_status }}</span></h2>
570583
<span>Total Runtime: {{ total_runtime }}</span>
571584
<main>
@@ -586,8 +599,8 @@ <h2 class="section-header-h2">
586599
</h2> </div>
587600
<div class="runtime build-section">Runtime: {{ report_containers[tag]["runtime"] }}</div>
588601
{% if screenshot %}
589-
<a href="{{ tag }}.png">
590-
<img src="{{ tag }}.png" alt="{{ tag }}" width="600" height="auto" onerror="this.onerror=null; this.src='404.jpg'; this.parentElement.setAttribute('href','#')">
602+
<a href="{{ tag }}.jpg">
603+
<img src="{{ tag }}.jpg" alt="{{ tag }}" width="600" height="auto" onerror="this.onerror=null; this.src='404.jpg'; this.parentElement.setAttribute('href','#')">
591604
</a>
592605
{% else %}
593606
<div class="tag-image">
@@ -620,6 +633,17 @@ <h2 class="section-header-h2">
620633
<pre><code>{{ report_containers[tag]["sysinfo"] }}</code></pre>
621634
</div>
622635
</details>
636+
{% if report_containers[tag]["package_diff"] %}
637+
<summary class="summary">
638+
Package changes from last release
639+
</summary>
640+
<details>
641+
<summary>Expand</summary>
642+
<div class="summary-container">
643+
<pre><code>{{ report_containers[tag]["package_diff"] }}</code></pre>
644+
</div>
645+
</details>
646+
{% endif %}
623647
{% if report_containers[tag]["browser_logs"] %}
624648
<summary class="summary">
625649
<a href="{{ tag }}.browser.html" target="_blank">View Browser Console Logs</a>
@@ -687,13 +711,15 @@ <h2 class="section-header-h2">
687711
fetch("ci.log")
688712
.then(response => response.text())
689713
.then(logs => {
690-
pylogs = logs.replace(/\[38;20m/gi,"<span class='log-debug'>"
691-
).replace(/\[33;20m/gi,"<span class='log-warning'>"
692-
).replace(/\[31;20m/gi,"<span class='log-error'>"
693-
).replace(/\[36;20m/gi,"<span class='log-info'>"
694-
).replace(/\[32;20m/gi,"<span class='log-success'>"
695-
).replace(/\[0m/gi,"</span>")
714+
pylogs = logs.replace(/ \[38;20m/gi,"<span class='log-debug'>"
715+
).replace(/ \[33;20m/gi,"<span class='log-warning'>"
716+
).replace(/ \[31;20m/gi,"<span class='log-error'>"
717+
).replace(/ \[36;20m/gi,"<span class='log-info'>"
718+
).replace(/ \[32;20m/gi,"<span class='log-success'>"
719+
).replace(/ \[0m/gi,"</span>")
696720
document.getElementById("logs").innerHTML = pylogs
721+
}).catch(e => {
722+
document.getElementById("logs").innerText = "Log file unavailable."
697723
})
698724
</script>
699725
</body>

readme-vars.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ full_custom_readme: |
8282
| `WEB_AUTH` | Credentials for basic auth, format `user:password`. Leave empty for none. | `""` |
8383
| `WEB_SCREENSHOT_DELAY` | Seconds to wait after the page loads before taking the screenshot. | `20` |
8484
85+
### Generating a package diff
86+
87+
In development mode you can build an actual comparison image by tagging it as `testimage:latest`. This will simulate the pacakge difference diff by dumping the SBOM of your test image and comparing it to the last release of the repo.
8588
8689
## Advanced Usage (CI Environment)
8790

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ jinja2==3.1.2
66
requests==2.28.2
77
pyvirtualdisplay==3.0
88
ansi2html==1.8.0
9+
pillow==12.1.1

0 commit comments

Comments
 (0)