1111import mimetypes
1212import json
1313import subprocess
14+ import io
15+ from PIL import Image
1416from requests .adapters import HTTPAdapter
1517from urllib3 .util .retry import Retry
1618from 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 )
0 commit comments