diff --git a/.env.runtime.example b/.env.runtime.example new file mode 100644 index 0000000..e34e30d --- /dev/null +++ b/.env.runtime.example @@ -0,0 +1,20 @@ +# Local runtime configuration for the deployment +# Copy this file to .env.runtime and set the environment you want to deploy. +# Supported values: sbx, dev, dev2, prod +DOME_ENVIRONMENT=sbx + +# Uncomment to also start Dozzle (web UI for container logs, see README). +# This is a standard Docker Compose profile: it applies to every start, +# both manual (wizard.py) and automatic (update.sh via the systemd timer). +#COMPOSE_PROFILES=debug + +# Git branch to pull versions-.env from (used by update.sh / +# wizard.py step 3). Leave commented to use whichever branch is currently +# checked out on this machine; update.sh warns if that isn't "main". +#VERSIONS_BRANCH=main + +# Uncomment to keep a timestamped one-line record in LOG_FILE of every +# wizard.py step/command run and every update.sh run that actually applied +# a new version (not the full command output, and not the no-op ticks). +#LOGGING=true +#LOG_FILE=./access-node-compose.log diff --git a/.github/workflows/ci-compose-up-test.yml b/.github/workflows/ci-compose-up-test.yml index ffc294d..5f13533 100644 --- a/.github/workflows/ci-compose-up-test.yml +++ b/.github/workflows/ci-compose-up-test.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Generate runtime compose file + run: ./wizard.py generate - name: Start services run: docker compose -f compose.yaml up -d - name: Wait for containers to be healthy diff --git a/.github/workflows/ci-compose-validation.yml b/.github/workflows/ci-compose-validation.yml index 78e09fb..a9ee2dd 100644 --- a/.github/workflows/ci-compose-validation.yml +++ b/.github/workflows/ci-compose-validation.yml @@ -14,11 +14,10 @@ jobs: uses: actions/checkout@v4 - name: Create .env files from examples - run: | - for f in $(find . -name "*.example" -not -path "./.git/*"); do - cp "$f" "${f%.example}" - done - + run: ./wizard.py init + + - name: Generate runtime compose file + run: ./wizard.py generate - name: Validate Docker Compose file run: docker compose -f compose.yaml config - name: Check required metadata labels diff --git a/.gitignore b/.gitignore index a84f2f4..d63f890 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ Thumbs.db *.log *.tmp +# Python bytecode cache (generated by running wizard.py) +__pycache__/ + # Asset and build output folders assets/* !assets/.gitkeep @@ -19,6 +22,9 @@ assets/* # Misc *.bak +# Config backups created by wizard.py before overwriting (contain secrets) +*.tgz + # Ignore everything in postgres-init except SQL files postgres-init/* !postgres-init/*.sql @@ -27,5 +33,8 @@ postgres-init/* #Ignore customized .env files .env.desmos .env.dlt-adapter-alastria +.env.runtime .secrets.desmos -Caddyfile.example +compose.yaml +Caddyfile +.compose-update.state \ No newline at end of file diff --git a/README.md b/README.md index 09ffd60..917c0d3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,57 @@ All architectural metadata must be prefixed with `dome.`, acting as our namespac 4. **CI Validation:** Our (WIP) CI pipeline runs a checker. If a service is missing the `dome.owner` or `dome.type` labels, the build will fail. +## Prerequisites + +Before running the installation wizard, make sure the target machine has: + +- **Docker Engine** and the **Docker Compose plugin** (`docker compose`, not the standalone `docker-compose`) — [install instructions](https://docs.docker.com/engine/install/). +- **Python 3.8+** — used to run `wizard.py` (standard library only, no extra packages to install). +- **git** — needed by `update.sh` to pull the latest `versions-.env` for periodic auto-updates; not required for a one-off manual install. +- **systemd** (optional) — only needed if you want the wizard to install the auto-update timer for you (step 4); without it you can still set up your own scheduling, or update manually. + +`wizard.py` checks Docker and the Compose plugin automatically at startup (and on demand via step `1` of the menu) and stops with instructions if either is missing. + +## Installation + +The recommended way to install and configure the Access Node is the guided wizard: + +
 ./wizard.py
+ +It walks you through the following numbered steps, re-entrant at every run (safe to stop and relaunch at any point). Each step has both a number and a symbolic name — use whichever you prefer for direct/manual execution (e.g. `./wizard.py 5` and `./wizard.py start` are equivalent): + +| Step | Name | Description | +| --- | --- | --- | +| `1` | `preflight` | Checks Docker and the Compose plugin are installed; runs automatically at every startup, but can also be re-run on demand (e.g. after fixing something). | +| `2` | `init` | Creates your working config files from the `.example` templates and tells you which ones to customize. | +| `3` | `generate-compose` | Pulls the latest known-good image versions for your `DOME_ENVIRONMENT` (set in `.env.runtime`: `sbx`, `dev`, `dev2`, or `prod`) and generates `compose.yaml` from `compose-template.yaml`. | +| `4` | `config-auto-update` | Offers to install and enable a systemd timer that periodically checks for new versions and restarts the stack (optional); if it can't install it automatically (no systemd, no sudo), it prints the manual steps. | +| `5` | `start` | Runs `docker compose pull` and `docker compose up -d`. | + +The menu also lists a couple of lettered, non-sequential commands you can run at any point (they don't gate anything and are never auto-suggested as "the next step"): + +| Command | Name | Description | +| --- | --- | --- | +| `a` | `status` | A friendlier, condensed alternative to `docker compose ps`: one line per service with its up/down state and health. | +| `b` | `backup` | Creates a timestamped `.tgz` backup of your current configuration files, on demand. | + +Run `./wizard.py` again at any time to resume where you left off, or jump directly to a step/command with `./wizard.py ` (e.g. `./wizard.py 3` or `./wizard.py generate-compose`). Run `./wizard.py -h` for the full list. + +Step `3` (and `update.sh`'s automatic restarts) pull `versions-.env` from git. By default they use whichever branch is currently checked out on the machine; set `VERSIONS_BRANCH` in `.env.runtime` to pin a specific one (normally `main`). A yellow warning is printed whenever the branch in use isn't `main`, since that's not the intended production source. + +`wizard.py` is the only script meant to be run by hand. The only other one you'll see in the repo, `update.sh`, isn't for manual use either — it's what the systemd timer set up in step `4` calls periodically to check for new versions and restart the stack. + +### Logging + +Set `LOGGING=true` and (optionally) `LOG_FILE` in `.env.runtime` (default: `./access-node-compose.log`) to keep a timestamped record of every step or command actually run — one line each, not the full command output: + +``` +[2026.07.07 12.42.30] step 3) Pull updated versions and generate compose.yaml +[2026.07.07 12.42.30] update.sh) regenerated compose.yaml for 'sbx' (dry run) +``` + +This applies both to manual wizard use and to `update.sh`'s automatic restarts via the systemd timer, so you get a single history of what ran and when. `update.sh` only adds a line when it actually finds and applies a new version — the periodic ticks where nothing changed stay silent, so the log doesn't fill up with no-ops. + ## Configuration To correctly configure the stack, you need to edit the following configuration files. @@ -90,22 +141,37 @@ Please make a copy of the provided .example files before to customize them Once you have the working files, you can customize them. -You can also execure the script "setup.sh" to copy the working files from examples. +Run `./wizard.py` to copy the working files from the examples for you (its first step) — see Installation above. + +`compose.yaml` itself is generated, not edited by hand: it never contains hardcoded image versions, only variables filled in from `versions-.env` (`./wizard.py generate`, called by `wizard.py` step 3 and by `update.sh` for the periodic auto-update). ## Execution -You can execute the Access Node using the following command -
 docker compose up -d
+The preferred way to start, restart, and check the Access Node is through the wizard (see Installation above): + +
 ./wizard.py 5
-To stop the stack, use this command +This runs `docker compose pull` and `docker compose up -d` for you, automatically honoring any `COMPOSE_PROFILES` set in `.env.runtime` (see Dozzle below). To check the status of the running services afterwards: + +
 ./wizard.py a
+ +To stop the stack, use this command:
 docker compose down
+### Manual execution + +If you prefer not to use the wizard, once `compose.yaml` has been generated (see Installation above) you can start the stack directly with: + +
 docker compose up -d
+ +Keep in mind that running `docker compose` directly does not pick up `COMPOSE_PROFILES` from `.env.runtime` — export it yourself first if needed (e.g. `export $(grep -v '^#' .env.runtime | xargs)`), or pass `--profile ` explicitly. This also means manual runs stay out of sync with what `update.sh`'s automatic restarts will do, since those always read `.env.runtime`. + ### Dozzle During setup/debug it is possible to start an instance of **Dozzle**, a service that provide a simple web UI to view container logs for all services, without having to view them from console. -Dozzle will NOT be started automatically with docker compose unless you run the stack using this command +Dozzle will NOT be started automatically unless enabled. The recommended way is to uncomment `COMPOSE_PROFILES=debug` in your `.env.runtime` (see `.env.runtime.example`): this applies consistently both to `./wizard.py` (steps 5/a) and to `update.sh`'s automatic restarts via the systemd timer, so Dozzle doesn't silently disappear on the next auto-update. For a one-off manual start without touching `.env.runtime`, you can still run
 docker compose up -d --profile debug
diff --git a/compose.yaml b/compose-template.yaml similarity index 92% rename from compose.yaml rename to compose-template.yaml index 0c07564..6971047 100644 --- a/compose.yaml +++ b/compose-template.yaml @@ -7,7 +7,7 @@ services: # Service: TMF666 - Account Management # Purpose: Manages user accounts and profiles. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-account:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF666_account restart: always labels: @@ -30,7 +30,7 @@ services: # Service: TMF651 - Agreement Management # Purpose: Manages commercial and technical agreements. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-agreement:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF651_agreement restart: always labels: @@ -53,7 +53,7 @@ services: # Service: TMF678 - Customer Bill Management # Purpose: Handles invoicing and billing information. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-customer-bill-management:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF678_customer-bill-management restart: always labels: @@ -76,7 +76,7 @@ services: # Service: TMF629 - Customer Management # Purpose: Manages customer information and lifecycle. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-customer-management:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ # Service: TMF629 - Customer Management # Service: TMF629 - Customer Management container_name: TMF629_customer-management @@ -101,7 +101,7 @@ services: # Service: TMF632 - Party Management # Purpose: Repository for parties (organizations/individuals). # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-party-catalog:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF632_party-catalog restart: always labels: @@ -124,7 +124,7 @@ services: # Service: TMF669 - Party Role Management # Purpose: Manages the roles played by parties. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-party-role:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF669_party-role restart: always labels: @@ -147,7 +147,7 @@ services: # Service: TMF620 - Product Catalog Management # Purpose: Repository for products and services. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-product-catalog:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF620_product-catalog restart: always labels: @@ -170,7 +170,7 @@ services: # Service: TMF637 - Product Inventory Management # Purpose: Manages the inventory of products. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-product-inventory:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF637_product-inventory restart: always labels: @@ -193,7 +193,7 @@ services: # Service: TMF622 - Product Ordering Management # Purpose: Manages product orders. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-product-ordering-management:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF622_product-ordering-management restart: always labels: @@ -216,7 +216,7 @@ services: # Service: TMF648 - Quote Management # Purpose: Manages product quotes. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-quote:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF648_quote restart: always labels: @@ -239,7 +239,7 @@ services: # Service: TMF634 - Resource Catalog Management # Purpose: Repository for resources. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-resource-catalog:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF634_resource-catalog restart: always labels: @@ -262,7 +262,7 @@ services: # Service: TMF664 - Resource Function Activation Management # Purpose: Manages resource function lifecycle. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-resource-function-activation:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF664_resource-function-activation restart: always labels: @@ -285,7 +285,7 @@ services: # Service: TMF639 - Resource Inventory Management # Purpose: Manages resource inventory. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-resource-inventory:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF639_resource-inventory restart: always labels: @@ -308,7 +308,7 @@ services: # Service: TMF633 - Service Catalog Management # Purpose: Repository for service definitions. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-service-catalog:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF633_service-catalog restart: always labels: @@ -331,7 +331,7 @@ services: # Service: TMF638 - Service Inventory Management # Purpose: Manages service inventory. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-service-inventory:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF638_service-inventory restart: always labels: @@ -354,7 +354,7 @@ services: # Service: TMF635 - Usage Management # Purpose: Manages service usage records. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-usage-management:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF635_usage-management restart: always labels: @@ -377,7 +377,7 @@ services: # Service: TMF667 - Usage Management # Purpose: Manages service usage records. # Internal Ports: 8080 (API), 9090 (Admin/Metrics) - image: docker.io/drllbl18/tmforum-document-management:sbx_1.5.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: TMF667_document-management restart: always labels: @@ -404,7 +404,8 @@ services: minio: # Service: MINIO S3 STORAGE # Purpose: This service provides S3-compatible object storage for the Access Node. - image: minio/minio:latest + image: __CONTAINER_IMAGE_PLACEHOLDER__ + container_name: minio command: server /data --console-address :9001 ports: - "9000:9000" # S3 API @@ -441,7 +442,7 @@ services: # Service: DESMOS # Purpose: Manages replication of data across nodes in the DOME ecosystem. # Internal Ports: 8080 (API) - image: in2workspace/in2-desmos-api:v2.0.11 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: desmos restart: always depends_on: @@ -504,7 +505,7 @@ services: # Service: DLT Adapter Alastria # Purpose: Interface for Alastria DLT. # Internal Ports: 8080 (API) - image: quay.io/digitelts/dlt-adapter:1.5.1 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: dlt-adapter-alastria restart: always labels: @@ -527,7 +528,7 @@ services: # Service: Scorpio Broker # Purpose: Context Broker for NGSI-LD data. # Internal Ports: 9090 - image: scorpiobroker/all-in-one-runner:java-4.1.10 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: scorpio restart: always depends_on: @@ -549,7 +550,7 @@ services: postgres: # Service: PostgreSQL Database - image: postgis/postgis:15-3.4 + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: postgres restart: always labels: @@ -575,7 +576,7 @@ services: # Service: Caddy API Gateway # Purpose: Single entry point for all external traffic entering Access Node. # Ports: 80 (HTTP exposed), 443 (HTTPS) - image: caddy:2-alpine + image: __CONTAINER_IMAGE_PLACEHOLDER__ container_name: caddy restart: always ports: @@ -604,7 +605,7 @@ services: # Note: Dozzle is disabled by default and can be enabled via Docker Compose profiles, or by running the stack with `docker compose --profile debug up -d`. # If you want to enable Dozzle by default, remove or comment the `profiles` directive below, or run the stack with `docker compose --profile debug up -d`. container_name: dozzle - image: amir20/dozzle:latest + image: __CONTAINER_IMAGE_PLACEHOLDER__ environment: - DOZZLE_BASE=/dozzle ports: diff --git a/setup.sh b/setup.sh deleted file mode 100644 index 06ea834..0000000 --- a/setup.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# setup.sh —init configuration files from examples - -for template in $(find . -name "*.example" -not -path "./.git/*"); do - target="${template%.example}" - if [ ! -f "$target" ]; then - cp "$template" "$target" - echo "Created: $target" - else - echo "· Already exists: $target (skipped)" - fi -done - -echo "" -echo "Modify the .env files with your values before starting the services." \ No newline at end of file diff --git a/systemd/access-node-update.service.tmpl b/systemd/access-node-update.service.tmpl new file mode 100644 index 0000000..773eea3 --- /dev/null +++ b/systemd/access-node-update.service.tmpl @@ -0,0 +1,9 @@ +[Unit] +Description=Dome Access Node Compose - check for updates and restart stack if needed +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__WORKDIR__ +ExecStart=__WORKDIR__/update.sh diff --git a/systemd/access-node-update.timer.tmpl b/systemd/access-node-update.timer.tmpl new file mode 100644 index 0000000..e10fe70 --- /dev/null +++ b/systemd/access-node-update.timer.tmpl @@ -0,0 +1,10 @@ +[Unit] +Description=Periodic check for Dome Access Node stack updates + +[Timer] +OnBootSec=5min +OnUnitActiveSec=15min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..a243e20 --- /dev/null +++ b/update.sh @@ -0,0 +1,102 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +if [ -t 1 ]; then + YELLOW='\033[93m' + RESET='\033[0m' +else + YELLOW='' + RESET='' +fi + +if [ ! -f .env.runtime ]; then + if [ -f .env.runtime.example ]; then + cp .env.runtime.example .env.runtime + echo "Created: .env.runtime" + else + echo "Missing .env.runtime. Copy .env.runtime.example to .env.runtime and set DOME_ENVIRONMENT." >&2 + exit 1 + fi +fi + +set -a +source .env.runtime +set +a + +log_action() { + if [ "${LOGGING:-false}" = "true" ]; then + echo "[$(date '+%Y.%m.%d %H.%M.%S')] $1" >> "${LOG_FILE:-access-node-compose.log}" + fi +} + +case "${DOME_ENVIRONMENT:-}" in + sbx|dev|dev2|prod) + ;; + *) + echo "Unsupported DOME_ENVIRONMENT='${DOME_ENVIRONMENT:-}'. Use one of: sbx, dev, dev2, prod." >&2 + exit 1 + ;; +esac + +VERSIONS_FILE="versions-${DOME_ENVIRONMENT}.env" +VERSIONS_REMOTE="${VERSIONS_REMOTE:-origin}" +STATE_FILE=".compose-update.state" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if [ -z "${VERSIONS_BRANCH:-}" ]; then + VERSIONS_BRANCH="$(git branch --show-current)" + VERSIONS_BRANCH="${VERSIONS_BRANCH:-main}" + fi + + if [ "$VERSIONS_BRANCH" != "main" ]; then + echo -e "${YELLOW}Warning: fetching versions from '${VERSIONS_REMOTE}/${VERSIONS_BRANCH}', not 'main'.${RESET}" + else + echo "Fetching remote versions from ${VERSIONS_REMOTE}/${VERSIONS_BRANCH}..." + fi + if git fetch "$VERSIONS_REMOTE" "$VERSIONS_BRANCH" >/dev/null 2>&1; then + REMOTE_FILE="refs/remotes/${VERSIONS_REMOTE}/${VERSIONS_BRANCH}:$VERSIONS_FILE" + if git cat-file -e "$REMOTE_FILE" 2>/dev/null; then + git show "$REMOTE_FILE" > "$VERSIONS_FILE" + echo "Updated $VERSIONS_FILE from ${VERSIONS_REMOTE}/${VERSIONS_BRANCH}." + else + echo "Remote file $VERSIONS_FILE not found on ${VERSIONS_REMOTE}/${VERSIONS_BRANCH}; using local copy." + fi + else + echo "Unable to fetch ${VERSIONS_REMOTE}/${VERSIONS_BRANCH}; using local copy." + fi +else + echo "Not a git repository; using local versions file." +fi + +if [ ! -f "$VERSIONS_FILE" ]; then + echo "Missing $VERSIONS_FILE for environment '$DOME_ENVIRONMENT'." >&2 + exit 1 +fi + +CURRENT_HASH="$(sha256sum "$VERSIONS_FILE" | awk '{print $1}')" +PREVIOUS_HASH="" +if [ -f "$STATE_FILE" ]; then + PREVIOUS_HASH="$(cat "$STATE_FILE")" +fi + +if [ "$CURRENT_HASH" = "$PREVIOUS_HASH" ]; then + echo "No image updates detected for environment '$DOME_ENVIRONMENT'; nothing to do." + exit 0 +fi + +echo "Image definitions changed; regenerating compose and updating stack..." +./wizard.py generate + +if [ "${DRY_RUN:-0}" = "1" ]; then + echo "Dry run enabled; skipping docker compose pull/up." + log_action "update.sh) regenerated compose.yaml for '$DOME_ENVIRONMENT' (dry run)" +else + docker compose -f compose.yaml pull + docker compose -f compose.yaml up -d --remove-orphans + log_action "update.sh) updated versions and restarted the stack (environment $DOME_ENVIRONMENT)" +fi + +echo "$CURRENT_HASH" > "$STATE_FILE" diff --git a/versions-dev.env b/versions-dev.env new file mode 100644 index 0000000..e41501e --- /dev/null +++ b/versions-dev.env @@ -0,0 +1,27 @@ +# Initial image mapping extracted from compose.yaml +# This file can be generated automatically later by the GitOps workflow. + +ACCOUNT_IMAGE=docker.io/drllbl18/tmforum-account:sbx_1.5.4 +AGREEMENT_IMAGE=docker.io/drllbl18/tmforum-agreement:sbx_1.5.4 +CADDY_IMAGE=caddy:2-alpine +CUSTOMER_BILL_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-bill-management:sbx_1.5.4 +CUSTOMER_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-management:sbx_1.5.4 +DESMOS_IMAGE=in2workspace/in2-desmos-api:v2.0.11 +DLT_ADAPTER_ALASTRIA_IMAGE=quay.io/digitelts/dlt-adapter:1.5.1 +DOCUMENT_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-document-management:sbx_1.5.4 +DOZZLE_IMAGE=amir20/dozzle:latest +MINIO_IMAGE=minio/minio:latest +PARTY_CATALOG_IMAGE=docker.io/drllbl18/tmforum-party-catalog:sbx_1.5.4 +PARTY_ROLE_IMAGE=docker.io/drllbl18/tmforum-party-role:sbx_1.5.4 +POSTGRES_IMAGE=postgis/postgis:15-3.4 +PRODUCT_CATALOG_IMAGE=docker.io/drllbl18/tmforum-product-catalog:sbx_1.5.4 +PRODUCT_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-product-inventory:sbx_1.5.4 +PRODUCT_ORDERING_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-product-ordering-management:sbx_1.5.4 +QUOTE_IMAGE=docker.io/drllbl18/tmforum-quote:sbx_1.5.4 +RESOURCE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-resource-catalog:sbx_1.5.4 +RESOURCE_FUNCTION_ACTIVATION_IMAGE=docker.io/drllbl18/tmforum-resource-function-activation:sbx_1.5.4 +RESOURCE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-resource-inventory:sbx_1.5.4 +SCORPIO_IMAGE=scorpiobroker/all-in-one-runner:java-4.1.10 +SERVICE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-service-catalog:sbx_1.5.4 +SERVICE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-service-inventory:sbx_1.5.4 +USAGE_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-usage-management:sbx_1.5.4 diff --git a/versions-dev2.env b/versions-dev2.env new file mode 100644 index 0000000..e41501e --- /dev/null +++ b/versions-dev2.env @@ -0,0 +1,27 @@ +# Initial image mapping extracted from compose.yaml +# This file can be generated automatically later by the GitOps workflow. + +ACCOUNT_IMAGE=docker.io/drllbl18/tmforum-account:sbx_1.5.4 +AGREEMENT_IMAGE=docker.io/drllbl18/tmforum-agreement:sbx_1.5.4 +CADDY_IMAGE=caddy:2-alpine +CUSTOMER_BILL_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-bill-management:sbx_1.5.4 +CUSTOMER_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-management:sbx_1.5.4 +DESMOS_IMAGE=in2workspace/in2-desmos-api:v2.0.11 +DLT_ADAPTER_ALASTRIA_IMAGE=quay.io/digitelts/dlt-adapter:1.5.1 +DOCUMENT_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-document-management:sbx_1.5.4 +DOZZLE_IMAGE=amir20/dozzle:latest +MINIO_IMAGE=minio/minio:latest +PARTY_CATALOG_IMAGE=docker.io/drllbl18/tmforum-party-catalog:sbx_1.5.4 +PARTY_ROLE_IMAGE=docker.io/drllbl18/tmforum-party-role:sbx_1.5.4 +POSTGRES_IMAGE=postgis/postgis:15-3.4 +PRODUCT_CATALOG_IMAGE=docker.io/drllbl18/tmforum-product-catalog:sbx_1.5.4 +PRODUCT_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-product-inventory:sbx_1.5.4 +PRODUCT_ORDERING_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-product-ordering-management:sbx_1.5.4 +QUOTE_IMAGE=docker.io/drllbl18/tmforum-quote:sbx_1.5.4 +RESOURCE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-resource-catalog:sbx_1.5.4 +RESOURCE_FUNCTION_ACTIVATION_IMAGE=docker.io/drllbl18/tmforum-resource-function-activation:sbx_1.5.4 +RESOURCE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-resource-inventory:sbx_1.5.4 +SCORPIO_IMAGE=scorpiobroker/all-in-one-runner:java-4.1.10 +SERVICE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-service-catalog:sbx_1.5.4 +SERVICE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-service-inventory:sbx_1.5.4 +USAGE_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-usage-management:sbx_1.5.4 diff --git a/versions-prod.env b/versions-prod.env new file mode 100644 index 0000000..e41501e --- /dev/null +++ b/versions-prod.env @@ -0,0 +1,27 @@ +# Initial image mapping extracted from compose.yaml +# This file can be generated automatically later by the GitOps workflow. + +ACCOUNT_IMAGE=docker.io/drllbl18/tmforum-account:sbx_1.5.4 +AGREEMENT_IMAGE=docker.io/drllbl18/tmforum-agreement:sbx_1.5.4 +CADDY_IMAGE=caddy:2-alpine +CUSTOMER_BILL_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-bill-management:sbx_1.5.4 +CUSTOMER_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-management:sbx_1.5.4 +DESMOS_IMAGE=in2workspace/in2-desmos-api:v2.0.11 +DLT_ADAPTER_ALASTRIA_IMAGE=quay.io/digitelts/dlt-adapter:1.5.1 +DOCUMENT_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-document-management:sbx_1.5.4 +DOZZLE_IMAGE=amir20/dozzle:latest +MINIO_IMAGE=minio/minio:latest +PARTY_CATALOG_IMAGE=docker.io/drllbl18/tmforum-party-catalog:sbx_1.5.4 +PARTY_ROLE_IMAGE=docker.io/drllbl18/tmforum-party-role:sbx_1.5.4 +POSTGRES_IMAGE=postgis/postgis:15-3.4 +PRODUCT_CATALOG_IMAGE=docker.io/drllbl18/tmforum-product-catalog:sbx_1.5.4 +PRODUCT_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-product-inventory:sbx_1.5.4 +PRODUCT_ORDERING_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-product-ordering-management:sbx_1.5.4 +QUOTE_IMAGE=docker.io/drllbl18/tmforum-quote:sbx_1.5.4 +RESOURCE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-resource-catalog:sbx_1.5.4 +RESOURCE_FUNCTION_ACTIVATION_IMAGE=docker.io/drllbl18/tmforum-resource-function-activation:sbx_1.5.4 +RESOURCE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-resource-inventory:sbx_1.5.4 +SCORPIO_IMAGE=scorpiobroker/all-in-one-runner:java-4.1.10 +SERVICE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-service-catalog:sbx_1.5.4 +SERVICE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-service-inventory:sbx_1.5.4 +USAGE_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-usage-management:sbx_1.5.4 diff --git a/versions-sbx.env b/versions-sbx.env new file mode 100644 index 0000000..e41501e --- /dev/null +++ b/versions-sbx.env @@ -0,0 +1,27 @@ +# Initial image mapping extracted from compose.yaml +# This file can be generated automatically later by the GitOps workflow. + +ACCOUNT_IMAGE=docker.io/drllbl18/tmforum-account:sbx_1.5.4 +AGREEMENT_IMAGE=docker.io/drllbl18/tmforum-agreement:sbx_1.5.4 +CADDY_IMAGE=caddy:2-alpine +CUSTOMER_BILL_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-bill-management:sbx_1.5.4 +CUSTOMER_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-customer-management:sbx_1.5.4 +DESMOS_IMAGE=in2workspace/in2-desmos-api:v2.0.11 +DLT_ADAPTER_ALASTRIA_IMAGE=quay.io/digitelts/dlt-adapter:1.5.1 +DOCUMENT_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-document-management:sbx_1.5.4 +DOZZLE_IMAGE=amir20/dozzle:latest +MINIO_IMAGE=minio/minio:latest +PARTY_CATALOG_IMAGE=docker.io/drllbl18/tmforum-party-catalog:sbx_1.5.4 +PARTY_ROLE_IMAGE=docker.io/drllbl18/tmforum-party-role:sbx_1.5.4 +POSTGRES_IMAGE=postgis/postgis:15-3.4 +PRODUCT_CATALOG_IMAGE=docker.io/drllbl18/tmforum-product-catalog:sbx_1.5.4 +PRODUCT_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-product-inventory:sbx_1.5.4 +PRODUCT_ORDERING_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-product-ordering-management:sbx_1.5.4 +QUOTE_IMAGE=docker.io/drllbl18/tmforum-quote:sbx_1.5.4 +RESOURCE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-resource-catalog:sbx_1.5.4 +RESOURCE_FUNCTION_ACTIVATION_IMAGE=docker.io/drllbl18/tmforum-resource-function-activation:sbx_1.5.4 +RESOURCE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-resource-inventory:sbx_1.5.4 +SCORPIO_IMAGE=scorpiobroker/all-in-one-runner:java-4.1.10 +SERVICE_CATALOG_IMAGE=docker.io/drllbl18/tmforum-service-catalog:sbx_1.5.4 +SERVICE_INVENTORY_IMAGE=docker.io/drllbl18/tmforum-service-inventory:sbx_1.5.4 +USAGE_MANAGEMENT_IMAGE=docker.io/drllbl18/tmforum-usage-management:sbx_1.5.4 diff --git a/wizard.py b/wizard.py new file mode 100755 index 0000000..3714f62 --- /dev/null +++ b/wizard.py @@ -0,0 +1,708 @@ +#!/usr/bin/env python3 +"""Guided installer for the access-node compose stack. + +Single entry point for installing and configuring the stack (config file +init, compose.yaml generation, auto-update timer, start). Every step +derives its own done/available/locked state from the filesystem on each +run, so the wizard can be safely re-launched at any point (e.g. after the +user closes the terminal to edit config files). `update.sh` (invoked by +the systemd timer for periodic checks) calls back into this script via +`wizard.py generate`. +""" + +import argparse +import datetime +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +os.chdir(ROOT) + +EXAMPLE_SUFFIX = ".example" + +COLOR = sys.stdout.isatty() +GREEN = "\033[92m" if COLOR else "" +RED = "\033[91m" if COLOR else "" +YELLOW = "\033[93m" if COLOR else "" +GRAY = "\033[90m" if COLOR else "" +RESET = "\033[0m" if COLOR else "" + + +def docker_cli_present(): + try: + subprocess.run(["docker", "--version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def docker_compose_plugin_present(): + try: + subprocess.run(["docker", "compose", "version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def docker_daemon_reachable(): + return subprocess.run(["docker", "info"], capture_output=True).returncode == 0 + + +def dependencies_ok(): + """Silent check used to render the step's status in the menu.""" + return docker_cli_present() and docker_compose_plugin_present() + + +def status_tag(label, color): + return f"{color}[{label}]{RESET}" + + +def run_dependency_check(): + """Verbose dependency check. Never exits — safe to run interactively from the menu.""" + print("Preflight checks:") + docker_ok = docker_cli_present() + print(status_tag("ok", GREEN) if docker_ok else status_tag("FAIL", RED), "Docker installed and on PATH", end="") + print("" if docker_ok else " — install from https://docs.docker.com/engine/install/") + + compose_ok = docker_compose_plugin_present() + print(status_tag("ok", GREEN) if compose_ok else status_tag("FAIL", RED), "'docker compose' plugin available", end="") + print("" if compose_ok else " — install from https://docs.docker.com/compose/install/") + + if docker_ok and compose_ok: + daemon_ok = docker_daemon_reachable() + print(status_tag("ok", GREEN) if daemon_ok else status_tag("warn", YELLOW), "Docker daemon reachable", end="") + print("" if daemon_ok else " (started? user permissions?) — needed for step (5)") + + return docker_ok and compose_ok + + +def preflight_checks(): + """Fail fast at startup if Docker isn't usable, before the user invests time in config.""" + if not run_dependency_check(): + sys.exit(1) + print() + + +def step_preflight_run(): + if not run_dependency_check(): + print("\nDependency check failed: fix the issues above before continuing.") + + +def customizable_targets(): + """Return (example_path, target_path) pairs for every *.example file.""" + pairs = [] + for example in sorted(ROOT.rglob(f"*{EXAMPLE_SUFFIX}")): + if ".git" in example.parts: + continue + target = example.with_name(example.name[: -len(EXAMPLE_SUFFIX)]) + pairs.append((example, target)) + return pairs + + +def missing_targets(): + return [target for _, target in customizable_targets() if not target.exists()] + + +def init_config_files(force=False): + """Copy every *.example file to its working target. + + If not force, existing targets are left untouched. If force, existing + targets are overwritten with a fresh copy of the template. + """ + for example, target in customizable_targets(): + existed = target.exists() + if existed and not force: + print(f"· Already exists: {target.relative_to(ROOT)} (skipped)") + continue + shutil.copy(example, target) + print(f"{'Overwritten' if existed else 'Created'}: {target.relative_to(ROOT)}") + + +def backup_config_files(targets): + timestamp = datetime.datetime.now().strftime("%Y.%m.%d_%H.%M.%S") + backup_path = ROOT / f"access-node-compose_config_{timestamp}.tgz" + with tarfile.open(backup_path, "w:gz") as tar: + for target in targets: + tar.add(target, arcname=target.relative_to(ROOT)) + print(f"Backup created: {backup_path.relative_to(ROOT)}") + return backup_path + + +ENV_RUNTIME_FILE = ROOT / ".env.runtime" +SUPPORTED_ENVIRONMENTS = ("sbx", "dev", "dev2", "prod") + + +def ensure_env_runtime(): + if ENV_RUNTIME_FILE.exists(): + return + example = ROOT / ".env.runtime.example" + if not example.exists(): + print("Missing .env.runtime. Copy .env.runtime.example to .env.runtime and set DOME_ENVIRONMENT.", file=sys.stderr) + sys.exit(1) + shutil.copy(example, ENV_RUNTIME_FILE) + print(f"Created: {ENV_RUNTIME_FILE.name}") + + +def parse_env_file(path): + values = {} + for raw_line in path.read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def read_environment(): + ensure_env_runtime() + return parse_env_file(ENV_RUNTIME_FILE).get("DOME_ENVIRONMENT") + + +def compose_env(): + """Environment for docker compose subprocess calls: inherits the shell env, + overlaid with .env.runtime (e.g. COMPOSE_PROFILES) so profile choices apply + consistently to manual wizard runs and to update.sh's automatic restarts.""" + env = os.environ.copy() + if ENV_RUNTIME_FILE.exists(): + env.update(parse_env_file(ENV_RUNTIME_FILE)) + return env + + +def log_action(message): + """Append one timestamped line to LOG_FILE if LOGGING=true in .env.runtime. + Just a record of what was run, not the command output itself.""" + if not ENV_RUNTIME_FILE.exists(): + return + env_vars = parse_env_file(ENV_RUNTIME_FILE) + if env_vars.get("LOGGING", "").strip().lower() != "true": + return + + log_path = Path(env_vars.get("LOG_FILE") or "access-node-compose.log") + if not log_path.is_absolute(): + log_path = ROOT / log_path + + timestamp = datetime.datetime.now().strftime("%Y.%m.%d %H.%M.%S") + with log_path.open("a") as f: + f.write(f"[{timestamp}] {message}\n") + + +def resolve_versions_branch(env_vars): + """Preview of the branch update.sh / step (3) would pull from: a local git + read only, no fetch, so it can't guarantee the fetch itself will succeed.""" + explicit = env_vars.get("VERSIONS_BRANCH") + if explicit: + return explicit + result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True) + if result.returncode != 0: + return None + return result.stdout.strip() or "main" + + +def print_runtime_summary(): + if not ENV_RUNTIME_FILE.exists(): + return + env_vars = parse_env_file(ENV_RUNTIME_FILE) + branch = resolve_versions_branch(env_vars) + branch_display = branch or "(not a git repo)" + branch_color = GREEN if branch == "main" else YELLOW if branch else GRAY + + rows = [ + ("DOME_ENVIRONMENT", env_vars.get("DOME_ENVIRONMENT", "(not set)"), ""), + ("COMPOSE_PROFILES", env_vars.get("COMPOSE_PROFILES", "(none)"), ""), + ("VERSIONS_BRANCH", branch_display, branch_color), + ] + width = max(len(label) for label, _, _ in rows) + + print("Current runtime configuration (.env.runtime):") + for label, value, color in rows: + print(f" {label:<{width}} {color}{value}{RESET if color else ''}") + print() + + +SERVICE_IMAGE_RE = re.compile(r"^ ([a-zA-Z0-9_.-]+):\s*$") + + +def generate_compose(): + """Render compose.yaml from compose-template.yaml + versions-.env.""" + environment = read_environment() + if environment not in SUPPORTED_ENVIRONMENTS: + print( + f"Unsupported DOME_ENVIRONMENT='{environment}'. Use one of: {', '.join(SUPPORTED_ENVIRONMENTS)}.", + file=sys.stderr, + ) + sys.exit(1) + + versions_file = ROOT / f"versions-{environment}.env" + if not versions_file.exists(): + print(f"Missing {versions_file.name} for environment '{environment}'.", file=sys.stderr) + sys.exit(1) + + images = parse_env_file(versions_file) + + lines = (ROOT / "compose-template.yaml").read_text().splitlines() + result = [f"# Generated for environment: {environment}"] + current_service = None + + for line in lines: + match = SERVICE_IMAGE_RE.match(line) + if match: + current_service = match.group(1) + result.append(line) + continue + + if current_service and line.strip().startswith("image:"): + normalized = current_service.upper().replace("-", "_").replace(".", "_") + image_value = images.get(f"{normalized}_IMAGE") + result.append(f" image: {image_value}" if image_value else line) + current_service = None + continue + + result.append(line) + + (ROOT / "compose.yaml").write_text("\n".join(result) + "\n") + print(f"Generated: compose.yaml (environment: {environment})") + + +def run(cmd, env=None, check=True): + print(f"$ {' '.join(cmd)}") + return subprocess.run(cmd, env=env, check=check) + + +def confirm(prompt, default=True): + hint = "Y/n" if default else "y/N" + answer = input(f"{prompt} [{hint}] ").strip().lower() + if not answer: + return default + return answer in ("y", "yes") + + +class Step: + def __init__(self, key, alias, title, is_done, run_action, precondition=None, blocked_hint="", category="step"): + self.key = key + self.alias = alias + self.title = title + self.is_done = is_done + self.run_action = run_action + self.precondition = precondition or (lambda: True) + self.blocked_hint = blocked_hint + self.category = category + + @property + def available(self): + return self.precondition() + + +# --------------------------------------------------------------------------- +# Step 2) init: create runtime config files from *.example +# --------------------------------------------------------------------------- + +def step_a_done(): + return not missing_targets() and (ROOT / ".env.runtime").exists() + + +def step_a_run(): + targets = [t for _, t in customizable_targets()] + existing = [t for t in targets if t.exists()] + + if existing: + print("These configuration files already exist and would be overwritten with fresh templates:") + for target in existing: + print(f" - {target.relative_to(ROOT)}") + print() + choice = input( + "[b] back up then overwrite / [o] overwrite without backup / [C] cancel, do nothing: " + ).strip().lower() + if choice == "b": + backup_config_files(existing) + elif choice != "o": + print("Cancelled: no files were changed.") + return + init_config_files(force=True) + else: + init_config_files() + + listing = "\n".join(f" - {t.relative_to(ROOT)}" for t in targets) + print(f""" +Configuration files are ready. Before moving on: + + 1. Open and customize these files (each one has notes on the fields + you need to fill in): +{listing} + 2. In .env.runtime, set DOME_ENVIRONMENT to one of: sbx, dev, dev2, prod. + +Re-run this script when you're done: it will detect that automatically +and move on to the next step. +""") + + +# --------------------------------------------------------------------------- +# Step 3) pull versions + generate compose.yaml +# --------------------------------------------------------------------------- + +def step_b_done(): + return (ROOT / "compose.yaml").exists() + + +def step_b_run(): + env = os.environ.copy() + env["DRY_RUN"] = "1" + run([str(ROOT / "update.sh")], env=env) + + +SYSTEMD_UNIT_DIR = Path("/etc/systemd/system") +SERVICE_NAME = "access-node-update.service" +TIMER_NAME = "access-node-update.timer" + + +def systemctl_available(): + try: + subprocess.run(["systemctl", "--version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def step_c_done(): + if not systemctl_available(): + return False + result = subprocess.run(["systemctl", "is-enabled", TIMER_NAME], capture_output=True, text=True) + return result.stdout.strip() == "enabled" + + +def render_unit(name): + template = (ROOT / "systemd" / f"{name}.tmpl").read_text() + return template.replace("__WORKDIR__", str(ROOT)) + + +def print_manual_systemd_instructions(): + print(f""" +Manual systemd timer installation: + + sudo cp systemd/{SERVICE_NAME}.tmpl {SYSTEMD_UNIT_DIR / SERVICE_NAME} + sudo cp systemd/{TIMER_NAME}.tmpl {SYSTEMD_UNIT_DIR / TIMER_NAME} + sudo sed -i "s|__WORKDIR__|{ROOT}|g" {SYSTEMD_UNIT_DIR / SERVICE_NAME} + sudo systemctl daemon-reload + sudo systemctl enable --now {TIMER_NAME} + +Then check its status with: systemctl status {TIMER_NAME} +""") + + +def step_c_run(): + if not systemctl_available(): + print("systemd is not available on this host: if your production server uses it, follow these instructions there:") + print_manual_systemd_instructions() + return + + if not confirm( + "Should the wizard install and enable the auto-update timer now? (requires sudo)" + ): + print_manual_systemd_instructions() + return + + try: + service_content = render_unit(SERVICE_NAME) + timer_content = render_unit(TIMER_NAME) + for name, content in ((SERVICE_NAME, service_content), (TIMER_NAME, timer_content)): + dest = SYSTEMD_UNIT_DIR / name + subprocess.run(["sudo", "tee", str(dest)], input=content, text=True, capture_output=True, check=True) + run(["sudo", "systemctl", "daemon-reload"]) + run(["sudo", "systemctl", "enable", "--now", TIMER_NAME]) + print(f"Timer {TIMER_NAME} installed and started.") + except subprocess.CalledProcessError: + print("Automatic installation failed (insufficient sudo permissions?). Proceed manually:") + print_manual_systemd_instructions() + + +# --------------------------------------------------------------------------- +# Step 5) start the stack +# --------------------------------------------------------------------------- + +def step_d_done(): + result = subprocess.run( + ["docker", "compose", "-f", "compose.yaml", "ps", "--status", "running", "-q"], + capture_output=True, + text=True, + env=compose_env(), + ) + return result.returncode == 0 and bool(result.stdout.strip()) + + +def step_d_run(): + if step_d_done(): + if not confirm( + "The stack is already running — this will restart it and services will be briefly unavailable. Continue?", + default=False, + ): + print("Aborted: the stack was not restarted.") + return + + env = compose_env() + run(["docker", "compose", "-f", "compose.yaml", "pull"], env=env) + run(["docker", "compose", "-f", "compose.yaml", "up", "-d"], env=env) + run(["docker", "compose", "-f", "compose.yaml", "ps"], env=env) + + +# --------------------------------------------------------------------------- +# Command a) check stack status +# --------------------------------------------------------------------------- + +def step_e_run(): + result = subprocess.run( + ["docker", "compose", "-f", "compose.yaml", "ps", "--all", "--format", "json"], + capture_output=True, + text=True, + env=compose_env(), + ) + if result.returncode != 0: + print(result.stderr.strip() or "Failed to query stack status.") + return + + raw = result.stdout.strip() + if not raw: + print("No containers found for this stack (has it been started? see step 5).") + return + + try: + containers = json.loads(raw) + if isinstance(containers, dict): + containers = [containers] + except json.JSONDecodeError: + containers = [json.loads(line) for line in raw.splitlines() if line.strip()] + + containers.sort(key=lambda c: c.get("Service", "?")) + name_width = max(len(c.get("Service", "?")) for c in containers) + + print(f"{'SERVICE':<{name_width}} STATUS") + for c in containers: + service = c.get("Service", "?") + state = c.get("State", "unknown") + health = c.get("Health", "") + label = "UP" if state == "running" else state.upper() + suffix = f" ({health})" if health else "" + print(f"{service:<{name_width}} {label}{suffix}") + + +# --------------------------------------------------------------------------- +# Command: back up configuration files on demand +# --------------------------------------------------------------------------- + +def any_config_exists(): + return any(target.exists() for _, target in customizable_targets()) + + +def step_backup_run(): + targets = [target for _, target in customizable_targets() if target.exists()] + if not targets: + print("No configuration files exist yet — nothing to back up (see step 2).") + return + + print("These configuration files will be backed up:") + for target in targets: + print(f" - {target.relative_to(ROOT)}") + if not confirm("Create the backup now?"): + print("Aborted: no backup created.") + return + + backup_config_files(targets) + + +STEPS = [ + Step( + key="1", + alias="preflight", + title="Check dependencies (Docker, docker compose plugin)", + is_done=dependencies_ok, + run_action=step_preflight_run, + ), + Step( + key="2", + alias="init", + title="Initialize configuration files (from *.example)", + is_done=step_a_done, + run_action=step_a_run, + ), + Step( + key="3", + alias="generate-compose", + title="Pull updated versions and generate compose.yaml", + is_done=step_b_done, + run_action=step_b_run, + precondition=step_a_done, + blocked_hint="requires step (2) completed", + ), + Step( + key="4", + alias="config-auto-update", + title="Configure auto-update (systemd timer)", + is_done=step_c_done, + run_action=step_c_run, + precondition=step_b_done, + blocked_hint="requires step (3) completed", + ), + Step( + key="5", + alias="start", + title="Start the stack", + is_done=step_d_done, + run_action=step_d_run, + precondition=step_b_done, + blocked_hint="requires step (3) completed", + ), + Step( + key="a", + alias="status", + title="Check stack status", + is_done=lambda: True, + run_action=step_e_run, + precondition=step_b_done, + blocked_hint="requires step (3) completed", + category="command", + ), + Step( + key="b", + alias="backup", + title="Back up configuration files", + is_done=lambda: True, + run_action=step_backup_run, + precondition=any_config_exists, + blocked_hint="no configuration files to back up yet (see step 2)", + category="command", + ), +] + + +def find_step(value): + return next((s for s in STEPS if value in (s.key, s.alias)), None) + + +def print_menu(): + print() + print_runtime_summary() + print("Access-node installation status:\n") + for step in STEPS: + if step.category != "step": + continue + mark = f"{GREEN}[x]{RESET}" if step.is_done() else "[ ]" + line = f" {mark} {step.key}) {step.title}" + if not step.available: + line += f" ({step.blocked_hint})" + print(line) + + commands = [s for s in STEPS if s.category == "command"] + if commands: + print("\nOther commands:\n") + for step in commands: + mark = f"{RESET}[-]{RESET}" + # mark = f"{GRAY}[-]{RESET}" + line = f" {mark} {step.key}) {step.title}" + if not step.available: + line += f" ({step.blocked_hint})" + print(line) + print() + + +def next_available_step(): + for step in STEPS: + if step.category == "step" and step.available and not step.is_done(): + return step + return None + + +def run_step(step): + if not step.available: + print(f"Step ({step.key}) is not available yet: {step.blocked_hint}.") + return + log_action(f"{step.category} {step.key}) {step.title}") + try: + step.run_action() + except subprocess.CalledProcessError as exc: + print(f"Step ({step.key}) failed (exit {exc.returncode}). Fix the error above and re-run the wizard.") + sys.exit(exc.returncode) + + +def build_steps_epilog(): + steps = [s for s in STEPS if s.category == "step"] + commands = [s for s in STEPS if s.category == "command"] + labels = [f"{s.key}, {s.alias}" for s in steps + commands] + ["generate"] + width = max(len(label) for label in labels) + + lines = ["steps:"] + for step in steps: + lines.append(f" {f'{step.key}, {step.alias}':<{width}} {step.title}") + + lines.append("\nother commands:") + for step in commands: + lines.append(f" {f'{step.key}, {step.alias}':<{width}} {step.title}") + + lines.append( + f"\n" + f" {'generate':<{width}} (re)build compose.yaml from the local versions file\n" + f" {'':<{width}} (no Docker required; used by CI and update.sh)" + ) + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + epilog=build_steps_epilog(), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "step", + nargs="?", + choices=[c for s in STEPS for c in (s.key, s.alias)] + ["generate"], + metavar="STEP", + help="run a specific step directly (by letter or name), or 'generate', instead of the interactive menu (see steps below)", + ) + args = parser.parse_args() + + if args.step == "generate": + generate_compose() + return + + step = find_step(args.step) if args.step else None + + if step is None or step.key != "1": + preflight_checks() + + if step: + run_step(step) + return + + while True: + print_menu() + nxt = next_available_step() + if nxt is None: + print("All available steps are complete.") + return + choice = input( + f"Press Enter to run the recommended next step ({nxt.key}), " + f"type the letter/name of another step, or 'q' to quit: " + ).strip().lower() + if choice == "q": + return + if not choice: + run_step(nxt) + continue + match = find_step(choice) + if match is None: + print("Invalid choice.") + continue + run_step(match) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nInterrupted, exiting.") + sys.exit(130)