Skip to content

Commit 326114e

Browse files
committed
update docs and testing script to have a local mode, add browser console dumping
1 parent 8a48843 commit 326114e

4 files changed

Lines changed: 210 additions & 31 deletions

File tree

README.md

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,77 @@
1717

1818
# [linuxserver/ci][huburl]
1919

20-
**This container is not meant for public consumption as it is hard coded to LinuxServer endpoints for storage of resulting reports**
20+
## What is this?
2121

22-
The purpose of this container is to accept environment variables from our build system [linuxserver/pipeline-triggers][pipelineurl] to perform basic continuous integration on the software being built.
22+
This container is an automated testing tool for Docker images. It's designed to perform a series of checks to ensure a container is healthy and functional before it's released. Here's what it does:
2323

24-
## Usage
24+
1. **Spins up the container:** It runs the target Docker image with a specified tag.
25+
2. **Checks for successful startup:** It tails the container's logs, waiting for the `[services.d] done.` message, which confirms the init system has finished and the services are running.
26+
3. **Generates an SBOM:** It uses `syft` to create a Software Bill of Materials, providing a complete list of all packages inside the image.
27+
4. **Tests the Web UI (optional):** If the container runs a web service, it attempts to connect to the UI and take a screenshot to verify it's accessible and renders correctly.
28+
5. **Generates a report:** It gathers all the results—container logs, build info, SBOM, screenshots, and test statuses—into a comprehensive HTML report.
29+
6. **Uploads the report (CI only):** In a CI environment, it uploads the final report to an S3 bucket for review.
2530

26-
The container can be run locally, but it is meant to be integrated into the LinuxServer build process:
31+
## Developer Mode (Local Testing)
2732

28-
```
33+
For local development and debugging, you can use `CI_LOCAL_MODE`. This mode runs all the tests but skips the S3 upload, saving the report directly to a local folder. It's the easiest way to test a container without needing cloud credentials.
34+
35+
### Example Run Command
36+
37+
Run this command from your terminal. It will test the `linuxserver/plex:latest` image and place the report in an `output` directory in your current folder.
38+
39+
|||
40+
docker run --rm -i \
41+
--shm-size=1gb \
42+
-v /var/run/docker.sock:/var/run/docker.sock \
43+
-v "$(pwd)/output:/ci/output" \
44+
-e CI_LOCAL_MODE=true \
45+
-e IMAGE="linuxserver/plex" \
46+
-e TAGS="latest" \
47+
-e BASE="ubuntu" \
48+
-e WEB_SCREENSHOT=true \
49+
-e PORT=32400 \
50+
-e SSL=false \
51+
-e WEB_PATH="/web/index.html" \
52+
-e WEB_AUTH="" \
53+
-e WEB_SCREENSHOT_TIMEOUT=60 \
54+
-e WEB_SCREENSHOT_DELAY=20 \
55+
-t lsiodev/ci:latest \
56+
python3 test_build.py
57+
|||
58+
59+
### Viewing the Report
60+
61+
Once the script finishes, you can view the detailed HTML report with this command:
62+
63+
|||
64+
chromium output/linuxserver/plex/latest/index.html
65+
|||
66+
> **Note:** You can use any modern web browser (Firefox, Chrome, etc.).
67+
68+
### Key Local Variables
69+
70+
| Variable | Description | Example |
71+
| :--- | :--- | :--- |
72+
| `CI_LOCAL_MODE` | **Required.** Enables local mode, disables S3 uploads. | `true` |
73+
| `IMAGE` | **Required.** The full name of the image to test. | `linuxserver/plex` |
74+
| `TAGS` | **Required.** The tag(s) to test. Use `\|` to separate multiple tags. | `latest` |
75+
| `BASE` | **Required.** The base distribution of the image. | `ubuntu` or `alpine` |
76+
| `WEB_SCREENSHOT` | Set to `true` to enable screenshot testing for web UIs. | `true` |
77+
| `PORT` | The internal port the web UI listens on. | `32400` |
78+
| `SSL` | Set to `true` if the web UI uses `https://`. | `false` |
79+
| `WEB_PATH` | The specific path to the web UI landing page. | `/web/index.html` |
80+
| `WEB_AUTH` | Credentials for basic auth, format `user:password`. Leave empty for none. | `""` |
81+
| `WEB_SCREENSHOT_DELAY` | Seconds to wait after the page loads before taking the screenshot. | `20` |
82+
83+
84+
## Advanced Usage (CI Environment)
85+
86+
**This container is not meant for public consumption as it is hard coded to LinuxServer endpoints for storage of resulting reports.**
87+
88+
The following shows the full list of environment variables used when the container is run by our CI system, [linuxserver/pipeline-triggers][pipelineurl].
89+
90+
|||
2991
sudo docker run --rm -i \
3092
-v /var/run/docker.sock:/var/run/docker.sock \
3193
-v /host/path:/ci/output:rw `#Optional, will contain all the files the container creates.` \
@@ -55,7 +117,7 @@ sudo docker run --rm -i \
55117
-e SYFT_IMAGE_TAG=<optional, The image tag of the syft docker image. Used for generating SBOM. Defaults to '1.26.1'> \
56118
-t lsiodev/ci:latest \
57119
python3 test_build.py
58-
```
120+
|||
59121

60122
The following line is only in this repo for loop testing:
61123

ci/ci.py

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ def __init__(self) -> None:
9696
if os.environ.get("DOCKER_PRIVILEGED"):
9797
self.logger.warning("DOCKER_PRIVILEGED env is not in use")
9898

99+
if os.environ.get("CI_LOCAL_MODE", "false").lower() == "true":
100+
self.logger.warning("--- LOCAL MODE ACTIVE ---")
101+
self.logger.warning("S3 uploads will be skipped and dummy keys will be used.")
102+
os.environ["DRY_RUN"] = "true"
103+
# Set dummy ENVs to pass the check_env() validation
104+
os.environ.setdefault("ACCESS_KEY", "local")
105+
os.environ.setdefault("SECRET_KEY", "local")
106+
# Use the first tag as the meta tag for a sensible output folder name
107+
first_tag = os.environ.get("TAGS", "local").split("|")[0]
108+
os.environ.setdefault("META_TAG", first_tag)
109+
os.environ.setdefault("RELEASE_TAG", first_tag)
110+
99111
self.check_env()
100112
self.validate_attrs()
101113

@@ -331,17 +343,17 @@ def container_test(self, tag: str) -> None:
331343
return
332344

333345
# Screenshot the web interface and check connectivity
334-
screenshot: bool = self.take_screenshot(container, tag)
335-
if not screenshot and self.get_platform(tag) == "amd64": # Allow ARM tags to fail the screenshot test
346+
screenshot_success, browser_logs = self.take_screenshot(container, tag)
347+
if not screenshot_success and self.get_platform(tag) == "amd64":
336348
self.logger.error("Test of %s FAILED after %.2f seconds", tag, time.time() - start_time)
337-
self._endtest(container, tag, build_info, sbom, False, start_time)
349+
self._endtest(container, tag, build_info, sbom, False, start_time, browser_logs)
338350
return
339351

340-
self._endtest(container, tag, build_info, sbom, True, start_time)
352+
self._endtest(container, tag, build_info, sbom, True, start_time, browser_logs)
341353
self.logger.success("Test of %s PASSED after %.2f seconds", tag, time.time() - start_time)
342354
return
343355

344-
def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packages:str, test_success: bool, start_time:float|int = 0.0) -> None:
356+
def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packages:str, test_success: bool, start_time:float|int = 0.0, browser_logs: str = "") -> None:
345357
"""End the test with as much info as we have and append to the report.
346358
347359
Args:
@@ -351,6 +363,7 @@ def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packa
351363
`packages` (str): SBOM dump from the container
352364
`test_success` (bool): If the testing of the container failed or not
353365
`start_time` (float, optional): The start time of the test. Defaults to 0.0. Used to calculate the runtime of the test.
366+
`browser_logs` (str, optional): The browser console logs.
354367
"""
355368
if not start_time:
356369
runtime = "-"
@@ -370,6 +383,7 @@ def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packa
370383
self.report_containers[tag] = {
371384
"logs": logblob,
372385
"sysinfo": packages,
386+
"browser_logs": browser_logs,
373387
"warnings": {
374388
"dotnet": warning_texts["dotnet"] if "icu-libs" in packages and "arm32" in tag else "",
375389
"uwsgi": warning_texts["uwsgi"] if "uwsgi" in packages and "arm" in tag else ""
@@ -383,6 +397,26 @@ def _endtest(self, container:Container, tag:str, build_info:dict[str,str], packa
383397
}
384398
self.report_containers[tag]["has_warnings"] = any(warning[1] for warning in self.report_containers[tag]["warnings"].items())
385399

400+
def _get_browser_logs(self, driver: WebDriver, tag: str) -> str:
401+
"""Get browser console logs from the webdriver.
402+
403+
Args:
404+
driver (WebDriver): The selenium webdriver instance.
405+
tag (str): The container tag.
406+
407+
Returns:
408+
str: The browser logs as a JSON formatted string.
409+
"""
410+
try:
411+
self.logger.info("Getting browser console logs for tag %s", tag)
412+
browser_logs_list = driver.get_log('browser')
413+
browser_logs_str = json.dumps(browser_logs_list, indent=4)
414+
self.create_html_ansi_file(browser_logs_str, tag, "browser")
415+
return browser_logs_str
416+
except Exception:
417+
self.logger.exception("Failed to get browser console logs for tag %s", tag)
418+
return '{"error": "Failed to retrieve browser logs"}'
419+
386420
def get_platform(self, tag: str) -> str:
387421
"""Check the 5 first characters of the tag and return the platform.
388422
@@ -750,6 +784,7 @@ def log_upload(self) -> None:
750784
"""
751785
self.logger.info("Uploading logs")
752786
try:
787+
shutil.copyfile("ci.log", f"{self.outdir}/ci.log")
753788
self.upload_file(f"{self.outdir}/ci.log", "ci.log", {"ContentType": "text/plain", "ACL": "public-read"})
754789
with open(f"{self.outdir}/ci.log","r", encoding="utf-8") as logs:
755790
blob: str = logs.read()
@@ -781,7 +816,7 @@ def _add_test_result(self, tag:str, test:str, status:str, message:str, start_tim
781816
"message":message,
782817
"runtime": runtime}.items())))
783818

784-
def take_screenshot(self, container: Container, tag:str) -> bool:
819+
def take_screenshot(self, container: Container, tag:str) -> tuple[bool, str]:
785820
"""Take a screenshot and save it to self.outdir if self.screenshot is True
786821
787822
Takes a screenshot using a ChromiumDriver instance.
@@ -791,19 +826,21 @@ def take_screenshot(self, container: Container, tag:str) -> bool:
791826
tag (str): The container tag we are testing.
792827
793828
Returns:
794-
bool: Return True if the screenshot was successful, otherwise False.
829+
tuple[bool, str]: Return (True, browser_logs) if successful, otherwise (False, browser_logs).
795830
"""
796831
if not self.screenshot:
797-
return True
832+
return True, ""
798833
proto: Literal["https", "http"] = "https" if self.ssl.upper() == "TRUE" else "http"
799834
screenshot_timeout = time.time() + self.screenshot_timeout
800835
test = "Get screenshot"
801836
start_time = time.time()
837+
driver: WebDriver | None = None
838+
browser_logs: str = ""
802839
try:
803-
driver: WebDriver = self.setup_driver()
840+
driver = self.setup_driver()
804841
container.reload()
805842
ip_adr:str = container.attrs.get("NetworkSettings",{}).get("Networks",{}).get("bridge",{}).get("IPAddress","")
806-
webauth: str = f"{self.webauth}@" if self.webauth else ""
843+
webauth: str = f"{self.webauth}"
807844
endpoint: str = f"{proto}://{webauth}{ip_adr}:{self.port}{self.webpath}"
808845
self.logger.info("Trying for %s seconds to take a screenshot of %s ",self.screenshot_timeout, tag)
809846
while time.time() < screenshot_timeout:
@@ -818,7 +855,7 @@ def take_screenshot(self, container: Container, tag:str) -> bool:
818855
raise FileNotFoundError(f"Screenshot '{self.outdir}/{tag}.png' not found")
819856
self._add_test_result(tag, test, "PASS", "-", start_time)
820857
self.logger.success("Screenshot %s: PASSED after %.2f seconds", tag, time.time() - start_time)
821-
return True
858+
return True, self._get_browser_logs(driver, tag)
822859
except Exception as error:
823860
logger.debug("Failed to take screenshot of %s at %s, trying again in 3 seconds", tag, endpoint, exc_info=error)
824861
time.sleep(3)
@@ -830,22 +867,29 @@ def take_screenshot(self, container: Container, tag:str) -> bool:
830867
self._add_test_result(tag, test, "FAIL", f"CONNECTION ERROR: {str(error)}", start_time)
831868
self.logger.exception("Screenshot %s FAIL CONNECTION ERROR", tag)
832869
self.report_status = "FAIL"
833-
return False
870+
if driver:
871+
browser_logs = self._get_browser_logs(driver, tag)
872+
return False, browser_logs
834873
except TimeoutException as error:
835874
self._add_test_result(tag, test, "FAIL", f"TIMEOUT: {str(error)}", start_time)
836875
self.logger.exception("Screenshot %s FAIL TIMEOUT", tag)
837876
self.report_status = "FAIL"
838-
return False
877+
if driver:
878+
browser_logs = self._get_browser_logs(driver, tag)
879+
return False, browser_logs
839880
except (WebDriverException, Exception) as error:
840881
self._add_test_result(tag, test, "FAIL", f"UNKNOWN: {str(error)}", start_time)
841882
self.logger.exception("Screenshot %s FAIL UNKNOWN", tag)
842883
self.report_status = "FAIL"
843-
return False
884+
if driver:
885+
browser_logs = self._get_browser_logs(driver, tag)
886+
return False, browser_logs
844887
finally:
845-
try:
846-
driver.quit()
847-
except Exception:
848-
self.logger.exception("Failed to quit the driver")
888+
if driver:
889+
try:
890+
driver.quit()
891+
except Exception:
892+
self.logger.exception("Failed to quit the driver")
849893

850894
def _check_response(self, endpoint:str) -> bool:
851895
"""Check if we can get a good response from the endpoint
@@ -910,7 +954,7 @@ def setup_driver(self) -> WebDriver:
910954
chrome_options.add_argument("--disable-gpu")
911955
chrome_options.add_argument("--disable-extensions")
912956
chrome_options.add_argument("--ignore-certificate-errors")
913-
chrome_options.add_argument("--disable-dev-shm-usage") # https://developers.google.com/web/tools/puppeteer/troubleshooting#tips
957+
chrome_options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
914958
driver = webdriver.Chrome(options=chrome_options)
915959
driver.set_page_load_timeout(60)
916960
driver.set_window_size(1920,1080)

ci/template.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,17 @@ <h2 class="section-header-h2">
620620
<pre><code>{{ report_containers[tag]["sysinfo"] }}</code></pre>
621621
</div>
622622
</details>
623+
{% if report_containers[tag]["browser_logs"] %}
624+
<summary class="summary">
625+
<a href="{{ tag }}.browser.html" target="_blank">View Browser Console Logs</a>
626+
</summary>
627+
<details>
628+
<summary>Expand</summary>
629+
<div class="summary-container">
630+
<pre><code>{{ report_containers[tag]["browser_logs"] }}</code></pre>
631+
</div>
632+
</details>
633+
{% endif %}
623634
{% if report_containers[tag]["has_warnings"]%}
624635
<details open>
625636
<summary class="warning-summary">Warnings</summary>

readme-vars.yml

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,77 @@ full_custom_readme: |
2222
2323
# [linuxserver/ci][huburl]
2424
25-
**This container is not meant for public consumption as it is hard coded to LinuxServer endpoints for storage of resulting reports**
25+
## What is this?
2626
27-
The purpose of this container is to accept environment variables from our build system [linuxserver/pipeline-triggers][pipelineurl] to perform basic continuous integration on the software being built.
27+
This container is an automated testing tool for Docker images. It's designed to perform a series of checks to ensure a container is healthy and functional before it's released. Here's what it does:
2828
29-
## Usage
29+
1. **Spins up the container:** It runs the target Docker image with a specified tag.
30+
2. **Checks for successful startup:** It tails the container's logs, waiting for the `[services.d] done.` message, which confirms the init system has finished and the services are running.
31+
3. **Generates an SBOM:** It uses `syft` to create a Software Bill of Materials, providing a complete list of all packages inside the image.
32+
4. **Tests the Web UI (optional):** If the container runs a web service, it attempts to connect to the UI and take a screenshot to verify it's accessible and renders correctly.
33+
5. **Generates a report:** It gathers all the results—container logs, build info, SBOM, screenshots, and test statuses—into a comprehensive HTML report.
34+
6. **Uploads the report (CI only):** In a CI environment, it uploads the final report to an S3 bucket for review.
3035
31-
The container can be run locally, but it is meant to be integrated into the LinuxServer build process:
36+
## Developer Mode (Local Testing)
3237
33-
```
38+
For local development and debugging, you can use `CI_LOCAL_MODE`. This mode runs all the tests but skips the S3 upload, saving the report directly to a local folder. It's the easiest way to test a container without needing cloud credentials.
39+
40+
### Example Run Command
41+
42+
Run this command from your terminal. It will test the `linuxserver/plex:latest` image and place the report in an `output` directory in your current folder.
43+
44+
|||
45+
docker run --rm -i \
46+
--shm-size=1gb \
47+
-v /var/run/docker.sock:/var/run/docker.sock \
48+
-v "$(pwd)/output:/ci/output" \
49+
-e CI_LOCAL_MODE=true \
50+
-e IMAGE="linuxserver/plex" \
51+
-e TAGS="latest" \
52+
-e BASE="ubuntu" \
53+
-e WEB_SCREENSHOT=true \
54+
-e PORT=32400 \
55+
-e SSL=false \
56+
-e WEB_PATH="/web/index.html" \
57+
-e WEB_AUTH="" \
58+
-e WEB_SCREENSHOT_TIMEOUT=60 \
59+
-e WEB_SCREENSHOT_DELAY=20 \
60+
-t lsiodev/ci:latest \
61+
python3 test_build.py
62+
|||
63+
64+
### Viewing the Report
65+
66+
Once the script finishes, you can view the detailed HTML report with this command:
67+
68+
|||
69+
chromium output/linuxserver/plex/latest/index.html
70+
|||
71+
> **Note:** You can use any modern web browser (Firefox, Chrome, etc.).
72+
73+
### Key Local Variables
74+
75+
| Variable | Description | Example |
76+
| :--- | :--- | :--- |
77+
| `CI_LOCAL_MODE` | **Required.** Enables local mode, disables S3 uploads. | `true` |
78+
| `IMAGE` | **Required.** The full name of the image to test. | `linuxserver/plex` |
79+
| `TAGS` | **Required.** The tag(s) to test. Use `\|` to separate multiple tags. | `latest` |
80+
| `BASE` | **Required.** The base distribution of the image. | `ubuntu` or `alpine` |
81+
| `WEB_SCREENSHOT` | Set to `true` to enable screenshot testing for web UIs. | `true` |
82+
| `PORT` | The internal port the web UI listens on. | `32400` |
83+
| `SSL` | Set to `true` if the web UI uses `https://`. | `false` |
84+
| `WEB_PATH` | The specific path to the web UI landing page. | `/web/index.html` |
85+
| `WEB_AUTH` | Credentials for basic auth, format `user:password`. Leave empty for none. | `""` |
86+
| `WEB_SCREENSHOT_DELAY` | Seconds to wait after the page loads before taking the screenshot. | `20` |
87+
88+
89+
## Advanced Usage (CI Environment)
90+
91+
**This container is not meant for public consumption as it is hard coded to LinuxServer endpoints for storage of resulting reports.**
92+
93+
The following shows the full list of environment variables used when the container is run by our CI system, [linuxserver/pipeline-triggers][pipelineurl].
94+
95+
|||
3496
sudo docker run --rm -i \
3597
-v /var/run/docker.sock:/var/run/docker.sock \
3698
-v /host/path:/ci/output:rw `#Optional, will contain all the files the container creates.` \
@@ -60,7 +122,7 @@ full_custom_readme: |
60122
-e SYFT_IMAGE_TAG=<optional, The image tag of the syft docker image. Used for generating SBOM. Defaults to '1.26.1'> \
61123
-t lsiodev/ci:latest \
62124
python3 test_build.py
63-
```
125+
|||
64126
65127
The following line is only in this repo for loop testing:
66128

0 commit comments

Comments
 (0)