diff --git a/ansible/combined-mirror/cloudlinux-complete-mirror.service.j2 b/ansible/combined-mirror/cloudlinux-complete-mirror.service.j2 index a49635a..691fda0 100644 --- a/ansible/combined-mirror/cloudlinux-complete-mirror.service.j2 +++ b/ansible/combined-mirror/cloudlinux-complete-mirror.service.j2 @@ -5,5 +5,6 @@ After=network.target [Service] Type=oneshot ExecStart=/bin/bash -c '/usr/bin/rsync -av --delete {{ cloudlinux_rsync_source }} {{ cloudlinux_mirror_path }}/ && /usr/bin/rsync -av --delete {{ swng_rsync_source }} {{ swng_mirror_path }}/' +ExecStartPost=/usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status OK StandardOutput=append:{{ combined_sync_log }} StandardError=append:{{ combined_sync_log }} diff --git a/ansible/combined-mirror/defaults/main.yml b/ansible/combined-mirror/defaults/main.yml index 75295be..a8f6272 100644 --- a/ansible/combined-mirror/defaults/main.yml +++ b/ansible/combined-mirror/defaults/main.yml @@ -30,3 +30,8 @@ certbot_cron_enabled: true certbot_cron_schedule: minute: 0 hour: 3 + +# /healthcheck endpoint (required by cl-mirrors mirrorservice) +healthcheck_file: /var/www/healthcheck.html +healthcheck_json_file: /var/www/healthcheck.json +healthcheck_source_name: swng.cloudlinux.com diff --git a/ansible/combined-mirror/nginx-https.conf.j2 b/ansible/combined-mirror/nginx-https.conf.j2 index 118e8e4..f7c5b40 100644 --- a/ansible/combined-mirror/nginx-https.conf.j2 +++ b/ansible/combined-mirror/nginx-https.conf.j2 @@ -31,6 +31,19 @@ server { autoindex on; } + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location /swng/ { alias {{ swng_mirror_path }}/; diff --git a/ansible/combined-mirror/nginx.conf.j2 b/ansible/combined-mirror/nginx.conf.j2 index 1d9b4fc..72c1857 100644 --- a/ansible/combined-mirror/nginx.conf.j2 +++ b/ansible/combined-mirror/nginx.conf.j2 @@ -38,6 +38,19 @@ server { autoindex on; } + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location /swng/ { alias {{ swng_mirror_path }}/; diff --git a/ansible/combined-mirror/playbook.yml b/ansible/combined-mirror/playbook.yml index 185c402..b53a50a 100644 --- a/ansible/combined-mirror/playbook.yml +++ b/ansible/combined-mirror/playbook.yml @@ -61,6 +61,7 @@ ignore_errors: yes - name: Create combined systemd service (when sync_mode is combined) + tags: healthcheck template: src: cloudlinux-complete-mirror.service.j2 dest: "/etc/systemd/system/{{ service_name }}.service" @@ -93,6 +94,7 @@ notify: reload systemd - name: Create separate SWNG service (when sync_mode is separate) + tags: healthcheck template: src: swng-mirror.service.j2 dest: /etc/systemd/system/swng-mirror.service @@ -141,12 +143,63 @@ debug: msg: "{{ timer_status.stdout_lines }}" + - name: Install epel-release (required for python3-dotenv on RedHat-family) + tags: healthcheck + ansible.builtin.package: + name: epel-release + state: present + when: ansible_facts['os_family'] == 'RedHat' + ignore_errors: true + + - name: Install python3-dotenv (required by healthcheck_update.py) + tags: healthcheck + ansible.builtin.package: + name: python3-dotenv + state: present + + - name: Ensure /opt/healthcheck directory exists + tags: healthcheck + ansible.builtin.file: + path: /opt/healthcheck + state: directory + mode: '0755' + + - name: Install healthcheck_update.py (shared from ansible/healthcheck/) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/healthcheck_update.py" + dest: /opt/healthcheck/healthcheck_update.py + mode: '0755' + + - name: Deploy /opt/healthcheck/.env (idempotent, preserves customer edits) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/.env" + dest: /opt/healthcheck/.env + mode: '0644' + force: false + + - name: Ensure /var/www directory exists (serves /healthcheck HTML) + tags: healthcheck + ansible.builtin.file: + path: /var/www + state: directory + mode: '0755' + + - name: Generate initial /healthcheck so it is available before first sync completes + tags: healthcheck + ansible.builtin.command: + cmd: /usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status PENDING + args: + creates: /var/www/healthcheck.html + - name: Install Nginx package: name: nginx state: present - name: Create Nginx configuration for combined mirror + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/combined-mirror.conf @@ -238,6 +291,7 @@ when: certbot_enabled | bool and certbot_authenticator == 'standalone' - name: Create Nginx HTTPS configuration for combined mirror + tags: healthcheck template: src: nginx-https.conf.j2 dest: /etc/nginx/conf.d/combined-mirror-https.conf @@ -246,6 +300,7 @@ when: certbot_enabled | bool - name: Update HTTP Nginx configuration to redirect to HTTPS + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/combined-mirror.conf diff --git a/ansible/combined-mirror/swng-mirror.service.j2 b/ansible/combined-mirror/swng-mirror.service.j2 index da50f5b..22cb729 100644 --- a/ansible/combined-mirror/swng-mirror.service.j2 +++ b/ansible/combined-mirror/swng-mirror.service.j2 @@ -5,5 +5,6 @@ After=network.target [Service] Type=oneshot ExecStart=/usr/bin/rsync -av --delete {{ swng_rsync_source }} {{ swng_mirror_path }}/ +ExecStartPost=/usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status OK StandardOutput=append:{{ swng_sync_log }} StandardError=append:{{ swng_sync_log }} diff --git a/ansible/complete-swng-rsync/defaults/main.yml b/ansible/complete-swng-rsync/defaults/main.yml index a51b85d..8d84149 100644 --- a/ansible/complete-swng-rsync/defaults/main.yml +++ b/ansible/complete-swng-rsync/defaults/main.yml @@ -24,3 +24,8 @@ certbot_cron_schedule: minute: 0 hour: 3 + +# /healthcheck endpoint (required by cl-mirrors mirrorservice) +healthcheck_file: /var/www/healthcheck.html +healthcheck_json_file: /var/www/healthcheck.json +healthcheck_source_name: swng.cloudlinux.com diff --git a/ansible/complete-swng-rsync/nginx-https.conf.j2 b/ansible/complete-swng-rsync/nginx-https.conf.j2 index e1fa07b..7754d1a 100644 --- a/ansible/complete-swng-rsync/nginx-https.conf.j2 +++ b/ansible/complete-swng-rsync/nginx-https.conf.j2 @@ -25,6 +25,19 @@ server { access_log /var/log/nginx/swng-mirror-https-access.log; error_log /var/log/nginx/swng-mirror-https-error.log; + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location / { try_files $uri $uri/ =404; diff --git a/ansible/complete-swng-rsync/nginx.conf.j2 b/ansible/complete-swng-rsync/nginx.conf.j2 index aaae6db..837c6ab 100644 --- a/ansible/complete-swng-rsync/nginx.conf.j2 +++ b/ansible/complete-swng-rsync/nginx.conf.j2 @@ -10,6 +10,19 @@ server { root {{ certbot_webroot | default('/var/www/mirrors/acme') }}; } + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # Normalize SWNG path (no trailing slash) location = /swng { return 301 https://$server_name/swng/; @@ -37,6 +50,19 @@ server { access_log /var/log/nginx/swng-mirror-access.log; error_log /var/log/nginx/swng-mirror-error.log; + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location / { try_files $uri $uri/ =404; diff --git a/ansible/complete-swng-rsync/playbook.yml b/ansible/complete-swng-rsync/playbook.yml index e45da5c..26dc67b 100644 --- a/ansible/complete-swng-rsync/playbook.yml +++ b/ansible/complete-swng-rsync/playbook.yml @@ -44,6 +44,7 @@ command: chmod -R o+rx {{ swng_mirror_path }} - name: Create systemd service for SWNG mirror sync + tags: healthcheck template: src: swng-mirror.service.j2 dest: /etc/systemd/system/swng-mirror.service @@ -73,12 +74,63 @@ debug: msg: "{{ timer_status.stdout_lines }}" + - name: Install epel-release (required for python3-dotenv on RedHat-family) + tags: healthcheck + ansible.builtin.package: + name: epel-release + state: present + when: ansible_facts['os_family'] == 'RedHat' + ignore_errors: true + + - name: Install python3-dotenv (required by healthcheck_update.py) + tags: healthcheck + ansible.builtin.package: + name: python3-dotenv + state: present + + - name: Ensure /opt/healthcheck directory exists + tags: healthcheck + ansible.builtin.file: + path: /opt/healthcheck + state: directory + mode: '0755' + + - name: Install healthcheck_update.py (shared from ansible/healthcheck/) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/healthcheck_update.py" + dest: /opt/healthcheck/healthcheck_update.py + mode: '0755' + + - name: Deploy /opt/healthcheck/.env (idempotent, preserves customer edits) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/.env" + dest: /opt/healthcheck/.env + mode: '0644' + force: false + + - name: Ensure /var/www directory exists (serves /healthcheck HTML) + tags: healthcheck + ansible.builtin.file: + path: /var/www + state: directory + mode: '0755' + + - name: Generate initial /healthcheck so it is available before first sync completes + tags: healthcheck + ansible.builtin.command: + cmd: /usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status PENDING + args: + creates: /var/www/healthcheck.html + - name: Install Nginx package: name: nginx state: present - name: Create Nginx configuration for SWNG mirror + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/swng-mirror.conf @@ -170,6 +222,7 @@ when: certbot_enabled | bool and certbot_authenticator == 'standalone' - name: Create Nginx HTTPS configuration for SWNG mirror + tags: healthcheck template: src: nginx-https.conf.j2 dest: /etc/nginx/conf.d/swng-mirror-https.conf @@ -178,6 +231,7 @@ when: certbot_enabled | bool - name: Update HTTP Nginx configuration to redirect to HTTPS + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/swng-mirror.conf diff --git a/ansible/complete-swng-rsync/swng-mirror.service.j2 b/ansible/complete-swng-rsync/swng-mirror.service.j2 index 4ce7768..0c0eab5 100644 --- a/ansible/complete-swng-rsync/swng-mirror.service.j2 +++ b/ansible/complete-swng-rsync/swng-mirror.service.j2 @@ -5,5 +5,6 @@ After=network.target [Service] Type=oneshot ExecStart=/usr/bin/rsync -av --delete {{ rsync_source }} {{ swng_mirror_path }}/ +ExecStartPost=/usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status OK StandardOutput=append:{{ sync_log_file }} StandardError=append:{{ sync_log_file }} diff --git a/ansible/healthcheck/.env b/ansible/healthcheck/.env new file mode 100644 index 0000000..5e50bf6 --- /dev/null +++ b/ansible/healthcheck/.env @@ -0,0 +1,10 @@ +# Default values for customer-deployed SWNG mirrors. +# Nginx in this repo aliases /healthcheck to /var/www/healthcheck.html, so +# point the renderer at /var/www by default. +HEALTHCHECK_JSON=/var/www/healthcheck.json +HEALTHCHECK_HTML=/var/www/healthcheck.html + +# To use storage-rooted paths (matches internal CloudLinux mirror convention), +# uncomment these and comment the two lines above: +# HEALTHCHECK_JSON=/storage/$HOSTNAME/healthcheck.json +# HEALTHCHECK_HTML=/storage/$HOSTNAME/healthcheck.html diff --git a/ansible/healthcheck/LICENSE b/ansible/healthcheck/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/ansible/healthcheck/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ansible/healthcheck/healthcheck_update.py b/ansible/healthcheck/healthcheck_update.py new file mode 100755 index 0000000..e2acfab --- /dev/null +++ b/ansible/healthcheck/healthcheck_update.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# Source-of-truth: reait.gitlab.atm.svcs.io/repositories/healthcheck (internal mirror, commit ddd199fbb) +# This is the same tool used on internal CloudLinux mirrors. Migrated under PF-600. + +import argparse +import json +from datetime import datetime +from pathlib import Path +import os +import sys +from dotenv import load_dotenv + +load_dotenv(dotenv_path="/opt/healthcheck/.env") + +def format_now(): + return datetime.now().strftime("%Y/%m/%d %H:%M:%S") + +def update_json(json_path, service=None, field=None, value=None, status=None, config_update=False): + time = format_now() + data = {} + if json_path.exists() and json_path.stat().st_size > 0: + try: + with open(json_path) as f: + data = json.load(f) + except json.JSONDecodeError: + data = {} + + data["healthcheck_update"] = time + + if config_update: + data["config_update"] = time + + if service and status: + block_name = f"{service}_status" + block = data.setdefault(block_name, []) + + if field and value: + block = [entry for entry in block if entry.get(field) != value] + block.append({field: value, "status": status, "time": time}) + else: + block = [entry for entry in block if list(entry.keys()) != ["status", "time"]] + block.append({"status": status, "time": time}) + + data[block_name] = block + + with open(json_path, "w") as f: + json.dump(data, f, indent=2) + +def render_html(json_path, html_path): + if not json_path.exists() or json_path.stat().st_size == 0: + return + + try: + with open(json_path) as f: + data = json.load(f) + except json.JSONDecodeError: + return + + lines = ["\n"] + lines.append(f"Last config update: {data.get('config_update', '')}
\n") + lines.append(f"Last healthcheck update: {data.get('healthcheck_update', '')}

\n") + + for key, entries in data.items(): + if key in ("config_update", "healthcheck_update"): + continue + + title = key.replace("_", " ").capitalize() + lines.append(f"

{title}

\n") + for entry in entries: + value_field = next((k for k in entry if k not in ("status", "time")), None) + value = entry.get(value_field, "Last launch") if value_field else "Last launch" + status = entry.get("status", "") + time = entry.get("time", "") + lines.append(f"{value} | Status: {status} | {time}
\n") + lines.append("
\n") + + lines.append("\n") + + temp_path = html_path.with_suffix('.html.tmp') + with open(temp_path, "w") as f: + f.writelines(lines) + + os.replace(temp_path, html_path) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--service", help="Name of the service (e.g. rsync, snapshot)") + parser.add_argument("--field", help="Field name (e.g. repo, domain)") + parser.add_argument("--value", help="Value of the field") + parser.add_argument("--status", help="Status string (e.g. OK, FAIL)") + parser.add_argument("--json", help="Path to JSON file") + parser.add_argument("--html", help="Path to HTML file") + parser.add_argument("--config-update", action="store_true", help="Flag to update config_update timestamp") + + args = parser.parse_args() + + if not any([args.service, args.status, args.config_update]): + print("Error: Must provide at least --service and --status or --config-update") + sys.exit(1) + + json_env = os.getenv("HEALTHCHECK_JSON") + html_env = os.getenv("HEALTHCHECK_HTML") + + if not (args.json or json_env): + print("Error: JSON path is not defined (via --json or HEALTHCHECK_JSON)") + sys.exit(1) + + if not (args.html or html_env): + print("Error: HTML path is not defined (via --html or HEALTHCHECK_HTML)") + sys.exit(1) + + json_path = Path(args.json or json_env) + html_path = Path(args.html or html_env) + + update_json(json_path, args.service, args.field, args.value, args.status, args.config_update) + render_html(json_path, html_path) + +if __name__ == "__main__": + main() diff --git a/ansible/specific-version-rsync(Recomended)/defaults/main.yml b/ansible/specific-version-rsync(Recomended)/defaults/main.yml index 21ed5bb..0d1deed 100644 --- a/ansible/specific-version-rsync(Recomended)/defaults/main.yml +++ b/ansible/specific-version-rsync(Recomended)/defaults/main.yml @@ -25,3 +25,8 @@ certbot_cron_enabled: true certbot_cron_schedule: minute: 0 hour: 3 + +# /healthcheck endpoint (required by cl-mirrors mirrorservice) +healthcheck_file: /var/www/healthcheck.html +healthcheck_json_file: /var/www/healthcheck.json +healthcheck_source_name: swng.cloudlinux.com diff --git a/ansible/specific-version-rsync(Recomended)/nginx-https.conf.j2 b/ansible/specific-version-rsync(Recomended)/nginx-https.conf.j2 index ad4e26f..7c20f4a 100644 --- a/ansible/specific-version-rsync(Recomended)/nginx-https.conf.j2 +++ b/ansible/specific-version-rsync(Recomended)/nginx-https.conf.j2 @@ -26,6 +26,19 @@ server { error_log /var/log/nginx/swng-{{ cloudlinux_version }}-mirror-https-error.log; # SWNG CloudLinux repositories + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + location / { try_files $uri $uri/ =404; } diff --git a/ansible/specific-version-rsync(Recomended)/nginx.conf.j2 b/ansible/specific-version-rsync(Recomended)/nginx.conf.j2 index 229bd76..21fba4d 100644 --- a/ansible/specific-version-rsync(Recomended)/nginx.conf.j2 +++ b/ansible/specific-version-rsync(Recomended)/nginx.conf.j2 @@ -10,6 +10,19 @@ server { root {{ certbot_webroot | default('/var/www/mirrors/acme') }}; } + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # Normalize SWNG path (no trailing slash) location = /swng { return 301 https://$server_name/swng/; diff --git a/ansible/specific-version-rsync(Recomended)/playbook.yml b/ansible/specific-version-rsync(Recomended)/playbook.yml index 3987d93..5f00084 100644 --- a/ansible/specific-version-rsync(Recomended)/playbook.yml +++ b/ansible/specific-version-rsync(Recomended)/playbook.yml @@ -48,6 +48,7 @@ ignore_errors: yes - name: Create systemd service for SWNG {{ cloudlinux_version }} mirror sync + tags: healthcheck template: src: swng-version-mirror.service.j2 dest: "/etc/systemd/system/{{ service_name }}.service" @@ -77,12 +78,63 @@ debug: msg: "{{ timer_status.stdout_lines }}" + - name: Install epel-release (required for python3-dotenv on RedHat-family) + tags: healthcheck + ansible.builtin.package: + name: epel-release + state: present + when: ansible_facts['os_family'] == 'RedHat' + ignore_errors: true + + - name: Install python3-dotenv (required by healthcheck_update.py) + tags: healthcheck + ansible.builtin.package: + name: python3-dotenv + state: present + + - name: Ensure /opt/healthcheck directory exists + tags: healthcheck + ansible.builtin.file: + path: /opt/healthcheck + state: directory + mode: '0755' + + - name: Install healthcheck_update.py (shared from ansible/healthcheck/) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/healthcheck_update.py" + dest: /opt/healthcheck/healthcheck_update.py + mode: '0755' + + - name: Deploy /opt/healthcheck/.env (idempotent, preserves customer edits) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/.env" + dest: /opt/healthcheck/.env + mode: '0644' + force: false + + - name: Ensure /var/www directory exists (serves /healthcheck HTML) + tags: healthcheck + ansible.builtin.file: + path: /var/www + state: directory + mode: '0755' + + - name: Generate initial /healthcheck so it is available before first sync completes + tags: healthcheck + ansible.builtin.command: + cmd: /usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status PENDING + args: + creates: /var/www/healthcheck.html + - name: Install Nginx package: name: nginx state: present - name: Create Nginx configuration for SWNG mirror + tags: healthcheck template: src: nginx.conf.j2 dest: "/etc/nginx/conf.d/swng-mirror.conf" @@ -174,6 +226,7 @@ when: certbot_enabled | bool and certbot_authenticator == 'standalone' - name: Create Nginx HTTPS configuration for SWNG mirror + tags: healthcheck template: src: nginx-https.conf.j2 dest: "/etc/nginx/conf.d/swng-mirror-https.conf" @@ -182,6 +235,7 @@ when: certbot_enabled | bool - name: Update HTTP Nginx configuration to redirect to HTTPS + tags: healthcheck template: src: nginx.conf.j2 dest: "/etc/nginx/conf.d/swng-mirror.conf" diff --git a/ansible/specific-version-rsync(Recomended)/swng-version-mirror.service.j2 b/ansible/specific-version-rsync(Recomended)/swng-version-mirror.service.j2 index fb10dde..3538382 100644 --- a/ansible/specific-version-rsync(Recomended)/swng-version-mirror.service.j2 +++ b/ansible/specific-version-rsync(Recomended)/swng-version-mirror.service.j2 @@ -5,5 +5,6 @@ After=network.target [Service] Type=oneshot ExecStart=/usr/bin/rsync -av --delete {{ rsync_source }} {{ swng_mirror_path }}/ +ExecStartPost=/usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status OK StandardOutput=append:{{ sync_log_file }} StandardError=append:{{ sync_log_file }} diff --git a/ansible/yum-reposync/defaults/main.yml b/ansible/yum-reposync/defaults/main.yml index 56f9e4d..29364d0 100644 --- a/ansible/yum-reposync/defaults/main.yml +++ b/ansible/yum-reposync/defaults/main.yml @@ -41,3 +41,8 @@ swng_repos: # baseurl: https://upstream.cloudlinux.com/swng/8/x86_64/ # module_platform_id: platform:el8 # enabled: true + +# /healthcheck endpoint (required by cl-mirrors mirrorservice) +healthcheck_file: /var/www/healthcheck.html +healthcheck_json_file: /var/www/healthcheck.json +healthcheck_source_name: swng.cloudlinux.com diff --git a/ansible/yum-reposync/nginx-https.conf.j2 b/ansible/yum-reposync/nginx-https.conf.j2 index 737cf44..fdfb206 100644 --- a/ansible/yum-reposync/nginx-https.conf.j2 +++ b/ansible/yum-reposync/nginx-https.conf.j2 @@ -25,6 +25,19 @@ server { access_log /var/log/nginx/swng-reposync-mirror-https-access.log; error_log /var/log/nginx/swng-reposync-mirror-https-error.log; + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location / { try_files $uri $uri/ =404; diff --git a/ansible/yum-reposync/nginx.conf.j2 b/ansible/yum-reposync/nginx.conf.j2 index 3be75e1..38581e2 100644 --- a/ansible/yum-reposync/nginx.conf.j2 +++ b/ansible/yum-reposync/nginx.conf.j2 @@ -14,6 +14,19 @@ server { access_log /var/log/nginx/swng-reposync-mirror-access.log; error_log /var/log/nginx/swng-reposync-mirror-error.log; + # Required by cl-mirrors mirrorservice (health-check) + location = /healthcheck { + alias {{ healthcheck_file }}; + default_type text/html; + } + + # Required by cl-mirrors mirrorservice (machine-readable JSON, post-2026-06-12 contract) + location = /healthcheck.json { + alias {{ healthcheck_json_file }}; + default_type application/json; + add_header Cache-Control "no-store"; + } + # SWNG repositories location / { try_files $uri $uri/ =404; diff --git a/ansible/yum-reposync/playbook.yml b/ansible/yum-reposync/playbook.yml index f6aff91..7427a04 100644 --- a/ansible/yum-reposync/playbook.yml +++ b/ansible/yum-reposync/playbook.yml @@ -75,6 +75,7 @@ ignore_errors: yes - name: Create systemd service for SWNG reposync + tags: healthcheck template: src: swng-reposync.service.j2 dest: "/etc/systemd/system/{{ service_name }}.service" @@ -104,12 +105,63 @@ debug: msg: "{{ timer_status.stdout_lines }}" + - name: Install epel-release (required for python3-dotenv on RedHat-family) + tags: healthcheck + ansible.builtin.package: + name: epel-release + state: present + when: ansible_facts['os_family'] == 'RedHat' + ignore_errors: true + + - name: Install python3-dotenv (required by healthcheck_update.py) + tags: healthcheck + ansible.builtin.package: + name: python3-dotenv + state: present + + - name: Ensure /opt/healthcheck directory exists + tags: healthcheck + ansible.builtin.file: + path: /opt/healthcheck + state: directory + mode: '0755' + + - name: Install healthcheck_update.py (shared from ansible/healthcheck/) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/healthcheck_update.py" + dest: /opt/healthcheck/healthcheck_update.py + mode: '0755' + + - name: Deploy /opt/healthcheck/.env (idempotent, preserves customer edits) + tags: healthcheck + ansible.builtin.copy: + src: "{{ playbook_dir }}/../healthcheck/.env" + dest: /opt/healthcheck/.env + mode: '0644' + force: false + + - name: Ensure /var/www directory exists (serves /healthcheck HTML) + tags: healthcheck + ansible.builtin.file: + path: /var/www + state: directory + mode: '0755' + + - name: Generate initial /healthcheck so it is available before first sync completes + tags: healthcheck + ansible.builtin.command: + cmd: /usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status PENDING + args: + creates: /var/www/healthcheck.html + - name: Install Nginx package: name: nginx state: present - name: Create Nginx configuration for SWNG reposync mirror + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/swng-reposync-mirror.conf @@ -201,6 +253,7 @@ when: certbot_enabled | bool and certbot_authenticator == 'standalone' - name: Create Nginx HTTPS configuration for SWNG reposync mirror + tags: healthcheck template: src: nginx-https.conf.j2 dest: /etc/nginx/conf.d/swng-reposync-mirror-https.conf @@ -209,6 +262,7 @@ when: certbot_enabled | bool - name: Update HTTP Nginx configuration to redirect to HTTPS + tags: healthcheck template: src: nginx.conf.j2 dest: /etc/nginx/conf.d/swng-reposync-mirror.conf diff --git a/ansible/yum-reposync/swng-reposync.service.j2 b/ansible/yum-reposync/swng-reposync.service.j2 index 2b7eb94..0ba02f6 100644 --- a/ansible/yum-reposync/swng-reposync.service.j2 +++ b/ansible/yum-reposync/swng-reposync.service.j2 @@ -14,5 +14,6 @@ ExecStart=/usr/bin/reposync -p {{ swng_mirror_path }}/ --repo {{ repo.name }} ExecStartPost=/usr/bin/createrepo {{ swng_mirror_path }}/{{ repo.name }}/ {% endif %} {% endfor %} +ExecStartPost=/usr/bin/python3 /opt/healthcheck/healthcheck_update.py --service sync --field repo --value {{ healthcheck_source_name }} --status OK StandardOutput=append:{{ sync_log_file }} StandardError=append:{{ sync_log_file }}