diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..88c8c5e9 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,156 @@ +name: Check + +on: + push: + branches: + - main + - "plan/**" + - "feature/**" + pull_request: + schedule: + # Related moving latest images refresh weekly; re-run the full compatibility + # suite after that window even when LocalDevStack source has not changed. + - cron: "30 2 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: check-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + static-and-contracts: + name: Static and contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck + + - name: Static validation + run: tests/static.sh + + - name: CLI contract + run: tests/cli-contract.sh + + - name: Environment contract + run: tests/env-contract.sh + + - name: Catalog contract + run: bash tests/catalog-contract.sh + + - name: PHP runtime contract + run: tests/runtime-php-contract.sh + + - name: Node runtime contract + run: tests/runtime-node-contract.sh + + - name: Networking characterization + run: tests/networking-contract.sh + + - name: Wrapper contract + run: tests/wrappers-contract.sh + + - name: Service hardening contract + run: bash tests/service-hardening-contract.sh + + - name: QoL contract + run: bash tests/qol-contract.sh + + - name: Permission contract + run: bash tests/permissions-contract.sh + + - name: Documentation contract + run: bash tests/docs-contract.sh + + compose: + name: Compose contract + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Docker versions + run: | + docker version + docker compose version + + - name: Render supported Compose matrices + run: tests/compose-contract.sh + + fake-llm: + name: Common LLM contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate common OpenAI-compatible LLM fixture + run: tests/ai-contract.sh + + published-images: + name: Published image baseline + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate published compatibility images + run: tests/published-images.sh + + windows-bridge: + name: Windows bridge + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Validate lds.bat contract + shell: pwsh + run: tests/windows-bridge.ps1 + + + docs: + name: Documentation build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: docs/requirements.txt + + - name: Install documentation dependencies + run: python -m pip install -r docs/requirements.txt + + - name: Build documentation + run: sphinx-build -W --keep-going -b html docs docs/_build/html + + + runtime-build: + name: Runtime build compatibility + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/plan/docker-ecosystem-bottom-up') + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Build current selected PHP/Node runtimes + run: bash tests/runtime-build-smoke.sh diff --git a/.gitignore b/.gitignore index 68191ca9..74c02d64 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,19 @@ !configuration !data/.gitignore !docker/compose +!docker/catalog/ +!docker/catalog/** !docker/conf !docker/dockerfiles +!docker/release.env !docs !logs +!lib/ +!lib/** +!.github/ +!.github/** +!tests/ +!tests/** !.gitignore !.gitattributes !.readthedocs.yaml diff --git a/README.md b/README.md index 03939553..5a344f51 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,400 @@ # LocalDevStack -LocalDevStack provides an easy-to-use Docker-based development environment for your projects. -All modules are **selective** and can be enabled via environment settings (Compose profiles). -Supports **multiple domains** and local TLS. - -> 1) Local development only. -> 2) Your domain(s) must be resolvable on your host: -> - add entries to your hosts file: ` `, **or** -> - use a DNS that resolves to your machine, **or** -> - use `*.localhost` (no hosts entry required in many setups) - -## Prerequisites (Docker) +**A Docker-based XAMPP alternative for modern PHP and Node.js local development.** + +LocalDevStack provides local domains, trusted HTTPS, selectable PHP and Node runtimes, databases and admin UIs, Mailpit, background workers, developer utilities, diagnostics, and optional local AI behind one `lds` command. + +It is designed for **trusted local development infrastructure**. It is not a production deployment stack. + +## Highlights + +- Nginx is the local HTTP/HTTPS front door on ports 80/443. +- Apache is always available as an alternate HTTP backend for domains that need it. +- PHP and Node runtimes are selected per domain and built as version-specific Alpine images. +- PostgreSQL, MySQL, MariaDB, MongoDB, Redis, and Elasticsearch are profile-driven. +- CloudBeaver, RedisInsight, Mongo Express, and Kibana are enabled with their related profiles. +- Mailpit is part of the core stack and persists captured mail. +- Runner provides cron, Supervisor, and log-rotation support. +- Tools owns domains, certificates, the admin UI, developer helpers, secrets integration, diagnostics, monitoring, and AI-consumer features. +- Optional `llm-ollama` provides a small local Ollama runtime with persistent models. +- Dynamic Docker networking through service DNS removes fixed subnet dependencies. +- Linux, Windows/Git Bash, WSL, and Docker Desktop workflows are supported by the host CLI. + +## Architecture at a glance + +```text +Host + └─ lds / lds.bat + │ + ├─ Docker Compose + │ ├─ server-tools + │ ├─ runner + │ ├─ mailpit + │ ├─ nginx + │ ├─ apache + │ ├─ optional databases/admin clients + │ ├─ generated PHP/Node runtimes + │ └─ optional llm-ollama + │ + ├─ configuration/ + └─ logs/ +``` -Install docker on your system first. If you already have docker installed, you can skip this step. -- It is recommended to use [Docker Engine](https://docs.docker.com/engine/install/). -- If Docker Engine not supported in your OS, use [Docker Desktop](https://docs.docker.com/desktop/) (although you can also install this on linux as well). +LocalDevStack uses three logical Docker networks: `Frontend`, `Backend`, and `DataStore`. Docker assigns their address ranges dynamically. Internal communication uses service names such as `nginx`, `postgres`, `redis`, `server-tools`, and `llm-ollama`. For single-stack compatibility the LLM service keeps the fixed container name `LLM_OLLAMA`; internal routing still uses the Compose service/hostname `llm-ollama`, never the fixed container name. -## Supported Project Languages +## Prerequisites -- PHP -- NodeJs +Install Docker first: ---- +- **Linux:** Docker Engine is preferred; Docker Desktop is also usable. +- **Windows:** Docker Desktop plus Git Bash. `lds.bat` bridges into the Bash CLI. +- **macOS:** Docker Desktop. -## Quickstart +Useful host tools such as `jq`, `yq`, `rg`, `fd`, `tree`, and `shellcheck` can be proxied through the running `server-tools` container when they are not installed on the host. Docker itself always remains a host requirement. -### 0) Default Layout +## Recommended layout -``` +```text project-root/ ├─ application/ │ ├─ site1/ │ ├─ site2/ │ └─ ... -└─ LocalDevStack/ (this repository) +└─ LocalDevStack/ ``` -This layout is flexible. If you want a different projects folder, set `PROJECT_DIR` in your env. +The default application bind mount is the sibling `application/` directory. Set `PROJECT_DIR` in `docker/.env` when your projects live elsewhere. -```bash -# supports relative/absolute path (recommended to use absolute path for less confusion) -PROJECT_DIR=/path/to/your/projects -``` +## Quick start -### 1) Clone ```bash git clone https://github.com/infocyph/LocalDevStack.git cd LocalDevStack -```` -### 2) Setup global shortcut and Permissions +chmod +x ./lds 2>/dev/null || true +sudo ./lds setup permissions # Linux/macOS +./lds setup init +./lds setup profile +./lds up +``` + +The first `up` starts the control plane and web stack. Then create a domain: -On Linux/Mac, ```bash -chmod +x ./lds 2>/dev/null || true -sudo ./lds setup permissions +./lds setup domain ``` -On Windows, -```cmd -./lds setup permissions + +For browser-trusted HTTPS, install the generated root CA: + +```bash +sudo ./lds certificate install # Linux ``` -Once ran, it will add a globally available shortcut (lds). And necessary permissions(linux/mac) will be assigned as well. +On Windows, use `lds.bat` or Git Bash. Certificate installation targets the current user's Windows root store and does not require the Unix `sudo` path. -### 3) Start the stack +Run these checks after setup: ```bash -lds start +lds doctor +lds urls +lds config validate ``` -### 4) Add a domain (vhost wizard) +## Built-in endpoints + +The exact optional endpoints shown by `lds urls` depend on enabled profiles. + +| Purpose | URL | +| --- | --- | +| Tools/Admin | `https://admin.localhost` | +| Mailpit | `https://webmail.localhost` | +| CloudBeaver | `https://db.localhost` | +| RedisInsight | `https://ri.localhost` | +| Mongo Express | `https://me.localhost` | +| Kibana | `https://kibana.localhost` | +| Local AI | `https://llm.localhost` | + +Use `lds open admin`, `lds open mail`, `lds open db`, `lds open redis`, `lds open mongo`, `lds open kibana`, or `lds open ai` to open a known endpoint. + +## Profiles + +The guided profile selector is: ```bash -lds setup domain +lds setup profile ``` -### 5) Optional: trust HTTPS locally (Root CA) +Catalog-managed optional profiles are `postgresql`, `mysql`, `mariadb`, `mongodb`, `redis`, `elasticsearch`, and `ai`. + +Re-running the wizard **replaces the catalog-managed selection** while preserving generated domain/runtime profiles and previously configured values. Choose `NONE` to clear catalog-managed profiles; `CANCEL / Back` leaves the current selection unchanged. + +Manual profile operations remain available: ```bash -sudo lds certificate install +lds profiles list +lds profiles add redis +lds profiles remove redis ``` +## PHP and Node runtimes + +The domain wizard keeps runtime version choice in the user's hands. A selected version becomes a version-specific local image: + +```text +localdevstack-php: +localdevstack-node: +``` -## Command hints (quick reference) +Both runtime families use Alpine variants. Runtime builds consume Scriptomatic from `main` by default. For reproducible debugging/release work, `SCRIPTOMATIC_REF` also accepts a full 40-character commit SHA. +Rebuild selected services with normal Docker cache preserved: ```bash -lds help +lds rebuild php84 +lds rebuild nginx +lds rebuild all +``` + +## Service image policy + +Fixed infrastructure images are declared directly in Compose. `docker/release.env` is reserved for genuinely variable release/build defaults such as `SCRIPTOMATIC_REF`. + +> Prefer the moving Alpine variant when the image family provides a suitable Alpine variant; otherwise use the normal moving latest tag. + +Examples: + +- PostgreSQL: `postgres:alpine` +- Tools / Runner / Nginx / Apache: published `:latest` +- MySQL / MariaDB / MongoDB / Redis: their supported moving defaults +- XDNA2 NPU local AI: `infocyph/llm-fastflow:latest` +- CPU/NVIDIA local AI: `infocyph/llm-ollama:latest` +- AMD ROCm local AI: `infocyph/llm-ollama:amd-latest` + +Elastic does not provide a usable moving `latest` alias for this stack, so Elasticsearch, Kibana, and Filebeat share `ELASTICSEARCH_VERSION` and default to the current stable `9.5.4`; overriding that one value advances or pins all three together. + +Inspect the effective defaults with `lds images`. + +## Environment ownership and precedence + +Tracked release/build defaults live in `docker/release.env`; user LocalDevStack settings live in `docker/.env`; the repository-root `.env` remains application-facing state where applicable. Fixed Tools/Runner/Nginx/Apache image names are not env-overridable because they have no runtime variant choice. + +LocalDevStack control precedence is: + +```text +built-in fallback + < docker/release.env + < docker/.env + < command-scoped shell environment +``` + +`docker/.env` is read as dotenv data; it is not shell-sourced as executable code. + +Useful inspection commands: + +```bash +lds config env-used +lds config show +lds config show --json +lds config show --raw +lds config services +lds config profiles +lds config validate +``` + +`config show` is redacted by default. `--raw` can expose credentials. + +## Databases and clients + +Applications connect through Docker DNS names, not fixed IP addresses: `postgres`, `mysql`, `mariadb`, `mongodb`, `redis`, and `elasticsearch`. + +Host-side wrappers forward into the appropriate LocalDevStack service: + +```bash +lds pg ... +lds psql ... +lds my ... +lds mysql ... +lds maria ... +lds mariadb ... +lds redis-cli ... +lds mongo ... +lds mongosh ... +lds es ... +``` + +See `docs/guides/databases-and-clients.rst` for the profile/client map. + +## Optional local AI + +Enable the `ai` profile through `lds setup profile`. + +LocalDevStack runs exactly one provider at a time: + +```text +supported AMD XDNA2 NPU -> infocyph/llm-fastflow:latest +NVIDIA GPU -> infocyph/llm-ollama:latest +AMD ROCm GPU -> infocyph/llm-ollama:amd-latest +otherwise -> infocyph/llm-ollama:latest +``` + +`llm-fastflow` and `llm-ollama` are mutually exclusive. The selected service owns the common Docker alias `llm` on port `11434`, so provider-neutral consumers use: + +```text +Tools consumer -> http://llm:11434 +User HTTPS -> https://llm.localhost +Host loopback -> http://127.0.0.1:11434 ``` -### Core stack +Provider-specific `https://llm-ollama.localhost` and `https://llm-fastflow.localhost` remain available for diagnostics/native operations. + +Automatic runtime selection prefers a supported XDNA2 NPU, then NVIDIA, then AMD ROCm device nodes, then CPU. Override it explicitly when needed: ```bash -lds up # start stack -lds start # alias of up -lds stop # stop stack (down) -lds down # alias of stop -lds restart # stop + up + HTTP reload -lds reload # recreate + HTTP reload -lds rebuild all # rebuild/pull images -lds config # show resolved docker compose config -lds http reload # reload the HTTP load balancer (nginx/apache) -lds tools # shell into SERVER_TOOLS container -lds doctor # host diagnostics +lds llm runtime auto +lds llm runtime npu +lds llm runtime nvidia +lds llm runtime amd +lds llm runtime cpu ``` -### Setup +Provider defaults are intentionally different: FastFlow/NPU uses `qwen3.5:9b`; Ollama uses `qwen3:14b`. New setup leaves `LDS_AI_MODEL` blank so the selected provider default can apply. An explicit `LDS_AI_MODEL` overrides whichever provider is active. + +Common commands: ```bash -lds setup init -lds setup permissions -lds setup profile # (or: lds setup profiles) choose which services to configure -lds setup domain +lds ai status +lds ai ask "Explain this error" +lds ai troubleshoot ... +lds ai review ... +lds ai repo-review ... + +lds llm provider +lds llm models +lds llm pull +lds llm run +lds llm ask "Explain dependency injection briefly" +lds llm chat ... + +# Host Graphify through the common LocalDevStack /v1 endpoint +lds graphify +lds graphify ./your-project --mode deep +# FastFlow already defaults to --token-budget 4000 --max-concurrency 1; +# pass explicit values only when you want to override them. ``` -### Certificates +`lds llm` dispatches to the active provider. Ollama-only low-level commands (`ps`, `show`, `unload`, `ollama`) and FastFlow-only commands (`validate`, `check`, `flm`) are guarded and rejected when the other provider is active. + +Nginx owns the loopback-only native route `127.0.0.1:11434 -> nginx:11434 -> llm:11434`. Provider containers do not publish host ports. + +`lds graphify` uses `http://llm.localhost:11434/v1` and validates the selected model through `/v1/models`. Backend selection follows the active provider automatically: FastFlow uses Graphify's generic `openai` backend, while Ollama uses Graphify's native `ollama` backend. For FastFlow, LocalDevStack also defaults to `--token-budget 4000 --max-concurrency 1` unless explicitly overridden; this keeps Qwen3.5 9B semantic chunks inside a safe local context budget. + +The built-in Compose layout keeps both provider definitions in `docker/compose/companion.yaml`, but runtime-generated profile selectors enable exactly one. NVIDIA/ROCm hardware augmentation is generated ephemerally under `docker/.runtime/`; FastFlow's `/dev/accel/accel0` + memlock contract lives in its tracked service definition. + +Ollama model state persists in `LLMModels` (`/root/.ollama`). FastFlow model state persists in `LLMFastFlowModels` (`/models`). Both providers receive no Docker socket and no project/repository bind mount by default. + +See `docs/guides/local-ai.rst` for the full runtime, model, Graphify, and trust-boundary contract. +## Storage and trust boundaries + +Important named volumes include `NginxHosts`, `ApacheHosts`, `SSLKeys`, `SSLRootCA`, `FPMPools`, `FPMSocks`, `ComposerGlobal`, `GitConfig`, `ToolsState`, database/admin stores, `EmailStore`, and `LLMModels`. + +Generated Nginx/Apache vhosts are Docker-managed state. `lds domain ls`, support tracing, and support bundles read the persisted vhost state through the control plane. + +`server-tools` and `runner` intentionally receive `/var/run/docker.sock`. Docker socket access is equivalent to powerful host Docker control. Ordinary databases, admin clients, Nginx/Apache, generated runtimes, and `llm-ollama` do not receive it unless a user explicitly opts into a separate ad-hoc runner `--sock` flow. + +## Operations and support ```bash -lds certificate install -lds certificate uninstall -lds certificate uninstall --all +lds up +lds down +lds restart +lds restart nginx +lds status +lds ps +lds logs nginx --follow +lds stack diff ``` -### Run (ad-hoc Dockerfile runner) +A normal restart does not pull fresh images. Use `lds rebuild ` when you want to refresh/recreate a service. + +The first `up` or `start` after upgrading from the historical fixed-network layout safely migrates proven LocalDevStack legacy networks to dynamic bridges while preserving named volumes. + +Support tools: + +```bash +lds doctor +lds support trace project.localhost +lds support bundle --redact +``` + +Redacted support bundles are the default. `--full` is intentionally raw and can contain credentials or other sensitive material. + +Cleanup is scoped by default: + +```bash +lds clean --yes +lds clean --yes --volumes +``` + +Host-wide Docker pruning is a separate explicit action: + +```bash +lds clean --global --yes +``` + +That global mode can remove unrelated stopped containers, unused images/networks, build cache, and optionally volumes. `lds down --volumes --yes` is also destructive and should not be used for normal upgrades. + +## Ad-hoc Dockerfile runner + +From a directory containing a Dockerfile: ```bash lds run -lds run --publish 8025:8025 -lds run ps +lds run shell lds run logs lds run stop lds run rm -lds run open 8025 ``` -### Shortcuts +The current directory is mounted at `/workspace`. ```bash -# Run your hosts file using container -lds php -v -lds composer install -lds node -v -lds npm i -lds npx +lds run --publish 8080:8080 +lds run --mount ./data:/data +``` + +`--sock` deliberately grants the ad-hoc container the host Docker socket and should be used only for trusted images/code. -# Login into the service containers -lds my --login -lds maria --login -lds pg --login -lds redis --login +## Notifications -# if You are not getting access to certain IP that is used via vpn -lds vpn-fix +```bash +lds notify watch +lds notify test "LocalDevStack" "Notifications work" ``` -> Tip: If you forget anything, `lds help` is the source of truth. +Linux/WSLg uses `notify-send` when available. Windows/Git Bash and compatible WSL environments use PowerShell toast support. Other hosts fall back to terminal output. -## CLI help (built-in “man”) +## Command reference ```bash lds help +lds help --markdown ``` ---- +The complete reference is maintained in `docs/reference/cli.rst`. ## Documentation -This README stays intentionally short. - -* Full documentation: https://docs.infocyph.com/projects/LocalDevStack -* Quick reference: `lds help ...` - ---- +- Full docs: https://docs.infocyph.com/projects/LocalDevStack +- Getting started: `docs/quickstart.rst` +- Architecture: `docs/concepts/architecture.rst` +- Profiles/env: `docs/concepts/profiles-and-env.rst` +- Storage: `docs/concepts/storage-layout.rst` +- Domain setup: `docs/guides/domain-setup.rst` +- Databases/clients: `docs/guides/databases-and-clients.rst` +- Local AI: `docs/guides/local-ai.rst` +- Operations/support: `docs/guides/operations-and-support.rst` +- Ad-hoc runner: `docs/guides/ad-hoc-runner.rst` +- CLI reference: `docs/reference/cli.rst` ## License diff --git a/bin/php b/bin/php index cee67318..ecdcc30e 100755 --- a/bin/php +++ b/bin/php @@ -130,11 +130,11 @@ port_in_use() { return 1 fi if command -v ss >/dev/null 2>&1; then - ss -lnt 2>/dev/null | grep -qE "[:.]$port[[:space:]]" && return 0 + ss -lnt 2>/dev/null | grep -qE "[:.]${port}[[:space:]]" && return 0 return 1 fi if command -v netstat >/dev/null 2>&1; then - netstat -lnt 2>/dev/null | grep -qE "[:.]$port[[:space:]]" && return 0 + netstat -lnt 2>/dev/null | grep -qE "[:.]${port}[[:space:]]" && return 0 return 1 fi return 1 diff --git a/docker/catalog/services.psv b/docker/catalog/services.psv new file mode 100644 index 00000000..fc6ec8d6 --- /dev/null +++ b/docker/catalog/services.psv @@ -0,0 +1,8 @@ +# key|profile|display_name|service_key|version_env|setup_defaults|setup_prompts|admin_client|volume|url|category|optional|default_enabled|runtime_modes +POSTGRESQL|postgresql|PostgreSQL|postgres|POSTGRES_VERSION|POSTGRES_VERSION=alpine;POSTGRES_USER=postgres;POSTGRES_PASSWORD=postgres;POSTGRES_DATABASE=postgres|Version;User;Password;Database|cloudbeaver|PostgresStore|https://db.localhost|database|1|0| +MYSQL|mysql|MySQL|mysql|MYSQL_VERSION|MYSQL_VERSION=latest;MYSQL_ROOT_PASSWORD=12345;MYSQL_USER=infocyph;MYSQL_PASSWORD=12345;MYSQL_DATABASE=localdb|Version;Root password;User;Password;Database|cloudbeaver|MySQLStore|https://db.localhost|database|1|0| +MARIADB|mariadb|MariaDB|mariadb|MARIADB_VERSION|MARIADB_VERSION=latest;MARIADB_ROOT_PASSWORD=12345;MARIADB_USER=infocyph;MARIADB_PASSWORD=12345;MARIADB_DATABASE=localdb|Version;Root password;User;Password;Database|cloudbeaver|MariaDBStore|https://db.localhost|database|1|0| +ELASTICSEARCH|elasticsearch|Elasticsearch|elasticsearch|ELASTICSEARCH_VERSION|ELASTICSEARCH_VERSION=9.5.4|Version|kibana|ElasticSearchStore|https://kibana.localhost|search|1|0| +MONGODB|mongodb|MongoDB|mongodb|MONGODB_VERSION|MONGODB_VERSION=latest;MONGODB_ROOT_USERNAME=root;MONGODB_ROOT_PASSWORD=12345|Version;Root username;Root password|mongo-express|MongoDBStore|https://me.localhost|database|1|0| +REDIS|redis|Redis|redis|REDIS_VERSION|REDIS_VERSION=latest|Version|redis-insight|RedisStore|https://ri.localhost|cache|1|0| +AI|ai|Local AI|llm|LDS_AI_RUNTIME|LDS_AI_MODEL=;LDS_AI_THINK=|Model override (blank = provider default);Thinking override (blank = provider default, true/false)||LLMModels|https://llm.localhost|ai|1|0|cpu,nvidia,amd,npu diff --git a/docker/compose/companion.yaml b/docker/compose/companion.yaml index 20ac3cdb..b442acc0 100644 --- a/docker/compose/companion.yaml +++ b/docker/compose/companion.yaml @@ -16,8 +16,21 @@ services: - SOPS_CFG_DIR=/etc/share/sops/config - SOPS_GLOBAL_DIR=/etc/share/sops/global - SOPS_REPO_DIR=/etc/share/vhosts/sops + - COMPOSE_PROFILES=${COMPOSE_PROFILES:-} + - LDS_AI_ENABLED=${LDS_AI_ENABLED:-auto} + - LDS_AI_PROVIDER=${LDS_AI_PROVIDER:-llm} + - LDS_AI_URL=${LDS_AI_URL:-http://llm:11434} + - LDS_AI_MODEL=${LDS_AI_MODEL:-qwen3:14b} + - LDS_AI_THINK=${LDS_AI_THINK:-} + - LDS_AI_CONNECT_TIMEOUT=${LDS_AI_CONNECT_TIMEOUT:-2} + - LDS_AI_PREFLIGHT_TIMEOUT=${LDS_AI_PREFLIGHT_TIMEOUT:-5} + - LDS_AI_TIMEOUT=${LDS_AI_TIMEOUT:-1800} + - LDS_AI_AVAILABILITY_TTL=${LDS_AI_AVAILABILITY_TTL:-5} + - LDS_AI_MAX_CONTEXT_BYTES=${LDS_AI_MAX_CONTEXT_BYTES:-524288} + - LDS_AI_MAX_REQUEST_BYTES=${LDS_AI_MAX_REQUEST_BYTES:-1048576} + - LDS_AI_MAX_RESPONSE_BYTES=${LDS_AI_MAX_RESPONSE_BYTES:-2097152} volumes: - - "${PROJECT_DIR:-./../../../application}:/app" + - ${PROJECT_DIR:-./../../../application}:/app - lds_ssl_roots:/etc/share/rootCA - lds_apache_host:/etc/share/vhosts/apache - lds_nginx_host:/etc/share/vhosts/nginx @@ -25,24 +38,89 @@ services: - lds_fpm_pools:/etc/share/vhosts/fpm - lds_composer_global:/etc/share/vhosts/composer - lds_git_config:/git-config + - lds_tools_state:/etc/share/state - ../../configuration/compose:/etc/share/vhosts/docker-compose - ../../configuration/scheduler/cron-jobs:/etc/share/scheduler/cron-jobs - ../../configuration/scheduler/supervisor:/etc/share/scheduler/supervisor - ../../configuration/sops/config:/etc/share/sops/config - ../../configuration/sops/global:/etc/share/sops/global - ../../configuration/sops/keys:/etc/share/sops/keys - - "../../configuration/ssh:/home/root/.ssh:ro" - - "${SOP_REPO:-./../../../sops-repo}:/etc/share/vhosts/sops" + - ../../configuration/ssh:/home/root/.ssh:ro + - ${SOP_REPO:-./../../../sops-repo}:/etc/share/vhosts/sops - ../../configuration/ssl:/etc/share/certs - ../../logs:/global/log:ro - /var/run/docker.sock:/var/run/docker.sock + networks: + - frontend + - backend + - datastore + + llm-ollama: + container_name: LLM_OLLAMA + hostname: llm-ollama + image: infocyph/llm-ollama:latest + restart: unless-stopped + profiles: ["${LDS_AI_OLLAMA_PROFILE:-ai}"] + environment: + - LLM_OLLAMA_MODEL=${LDS_AI_MODEL:-qwen3:14b} + - LLM_THINK=${LDS_AI_THINK:-} + - LLM_OLLAMA_SYSTEM=${LLM_OLLAMA_SYSTEM:-} + - LLM_OLLAMA_INPUT_WARN_BYTES=${LLM_OLLAMA_INPUT_WARN_BYTES:-1048576} + - LLM_OLLAMA_INPUT_MAX_BYTES=${LLM_OLLAMA_INPUT_MAX_BYTES:-0} + - LLM_OLLAMA_ATTACHMENT_MAX_BYTES=${LLM_OLLAMA_ATTACHMENT_MAX_BYTES:-16777216} + - LLM_OLLAMA_ATTACHMENTS_MAX_BYTES=${LLM_OLLAMA_ATTACHMENTS_MAX_BYTES:-33554432} + - LLM_OLLAMA_ATTACHMENT_MAX_COUNT=${LLM_OLLAMA_ATTACHMENT_MAX_COUNT:-16} + - LLM_OLLAMA_PDF_MAX_PAGES=${LLM_OLLAMA_PDF_MAX_PAGES:-24} + - LLM_OLLAMA_PDF_DPI=${LLM_OLLAMA_PDF_DPI:-120} + - LLM_OLLAMA_ALLOW_LARGE_INPUT=${LLM_OLLAMA_ALLOW_LARGE_INPUT:-0} + - OLLAMA_IGPU_ENABLE=${LDS_AI_IGPU_ENABLE:-0} + - OLLAMA_NUM_PARALLEL=${OLLAMA_NUM_PARALLEL:-1} + - OLLAMA_MAX_LOADED_MODELS=${OLLAMA_MAX_LOADED_MODELS:-1} + - OLLAMA_KEEP_ALIVE=${OLLAMA_KEEP_ALIVE:-5m} + - OLLAMA_NO_CLOUD=${OLLAMA_NO_CLOUD:-1} + volumes: + - lds_llm:/root/.ollama networks: frontend: - ipv4_address: 172.28.0.13 + aliases: [llm] backend: - ipv4_address: 172.29.0.10 - datastore: - ipv4_address: 172.30.0.10 + aliases: [llm] + + llm-fastflow: + container_name: LLM_FASTFLOW + hostname: llm-fastflow + image: infocyph/llm-fastflow:latest + restart: unless-stopped + profiles: ["${LDS_AI_FASTFLOW_PROFILE:-__lds-ai-disabled-fastflow}"] + environment: + - LLM_FASTFLOW_MODEL=${LDS_AI_MODEL:-qwen3.5:9b} + - LLM_THINK=${LDS_AI_THINK:-} + - FLM_MODEL_PATH=/models + - FLM_SERVE_PORT=11434 + - FLM_HOST=0.0.0.0 + - FLM_CORS=0 + - FLM_DISABLE_UPDATE_CHECK=1 + - LLM_FASTFLOW_INPUT_WARN_BYTES=${LLM_FASTFLOW_INPUT_WARN_BYTES:-1048576} + - LLM_FASTFLOW_INPUT_MAX_BYTES=${LLM_FASTFLOW_INPUT_MAX_BYTES:-0} + - LLM_FASTFLOW_ATTACHMENT_MAX_BYTES=${LLM_FASTFLOW_ATTACHMENT_MAX_BYTES:-16777216} + - LLM_FASTFLOW_ATTACHMENTS_MAX_BYTES=${LLM_FASTFLOW_ATTACHMENTS_MAX_BYTES:-33554432} + - LLM_FASTFLOW_ATTACHMENT_MAX_COUNT=${LLM_FASTFLOW_ATTACHMENT_MAX_COUNT:-16} + - LLM_FASTFLOW_PDF_MAX_PAGES=${LLM_FASTFLOW_PDF_MAX_PAGES:-24} + - LLM_FASTFLOW_PDF_DPI=${LLM_FASTFLOW_PDF_DPI:-120} + - LLM_FASTFLOW_ALLOW_LARGE_INPUT=${LLM_FASTFLOW_ALLOW_LARGE_INPUT:-0} + devices: + - "${LDS_AI_ACCEL_DEVICE:-/dev/accel/accel0}:/dev/accel/accel0" + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - lds_llm_fastflow:/models + networks: + frontend: + aliases: [llm] + backend: + aliases: [llm] runner: container_name: RUNNER @@ -69,10 +147,10 @@ services: - ../../logs/redis-insight:/global/log/redis-insight - /var/run/docker.sock:/var/run/docker.sock depends_on: - - server-tools + server-tools: + condition: service_healthy networks: - backend: - ipv4_address: 172.29.0.11 + - backend mailpit: container_name: MAILPIT @@ -91,12 +169,11 @@ services: - lds_ssl_keys:/certs:ro - lds_mail:/data depends_on: - - server-tools + server-tools: + condition: service_healthy networks: - frontend: - ipv4_address: 172.28.0.12 - backend: - ipv4_address: 172.29.0.12 + - frontend + - backend healthcheck: test: [ "CMD", "wget", "-qO-", "http://127.0.0.1:8025/" ] interval: 10s diff --git a/docker/compose/db-client.yaml b/docker/compose/db-client.yaml index 13764616..25662fc7 100644 --- a/docker/compose/db-client.yaml +++ b/docker/compose/db-client.yaml @@ -12,17 +12,13 @@ services: image: redis/redisinsight:latest profiles: [redis] depends_on: - - redis + redis: + condition: service_healthy environment: - RI_REDIS_HOST=redis volumes: - lds_ri:/data - ../../logs/redis-insight:/var/log/redis-insight - networks: - datastore: - ipv4_address: 172.30.0.150 - frontend: - ipv4_address: 172.28.0.150 cloudbeaver: <<: *db-client-service @@ -40,11 +36,6 @@ services: interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.151 - frontend: - ipv4_address: 172.28.0.151 mongo-express: <<: *db-client-service @@ -53,7 +44,8 @@ services: image: mongo-express:${MONGO_EXPRESS_VERSION:-latest} profiles: [mongodb] depends_on: - - mongodb + mongodb: + condition: service_healthy environment: - TZ=${TZ:-} - ME_CONFIG_BASICAUTH=false @@ -62,45 +54,36 @@ services: - ME_CONFIG_MONGODB_URL=mongodb://${MONGODB_ROOT_USERNAME:-root}:${MONGODB_ROOT_PASSWORD:-12345}@mongodb:${MONGODB_PORT:-27017}/admin volumes: - ../../logs/mongo-express:/var/log/mongo-express - networks: - datastore: - ipv4_address: 172.30.0.152 - frontend: - ipv4_address: 172.28.0.152 kibana: <<: *db-client-service container_name: KIBANA hostname: kibana - image: kibana:${ELASTICSEARCH_VERSION:-9.3.0} + image: docker.elastic.co/kibana/kibana:${ELASTICSEARCH_VERSION:-9.5.4} profiles: [elasticsearch] depends_on: - - elasticsearch + elasticsearch: + condition: service_healthy environment: - TZ=${TZ:-} - "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" volumes: - lds_kb:/usr/share/kibana/data - ../../logs/kibana:/usr/share/kibana/logs - networks: - datastore: - ipv4_address: 172.30.0.153 - frontend: - ipv4_address: 172.28.0.153 filebeat: - image: docker.elastic.co/beats/filebeat:${ELASTICSEARCH_VERSION:-9.3.0} + image: docker.elastic.co/beats/filebeat:${ELASTICSEARCH_VERSION:-9.5.4} container_name: FILEBEAT restart: unless-stopped user: root profiles: [filebeat] depends_on: - - elasticsearch + elasticsearch: + condition: service_healthy volumes: - lds_fbeat:/usr/share/filebeat/data - ../conf/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro - ../../logs:/global/log:ro command: ["--strict.perms=false"] networks: - datastore: - ipv4_address: 172.30.0.154 + - datastore diff --git a/docker/compose/db.yaml b/docker/compose/db.yaml index 9af78c1d..48a7cbba 100644 --- a/docker/compose/db.yaml +++ b/docker/compose/db.yaml @@ -21,9 +21,6 @@ services: interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.100 postgres: <<: *db-service @@ -40,25 +37,16 @@ services: - lds_pg:/var/lib/postgresql - ../../logs/postgresql:/var/log/postgresql - ../conf/pg_hba.conf:/etc/postgresql/pg_hba.conf -# - ../conf/postgresql.conf:/etc/postgresql/postgresql.conf command: [ "postgres", "-c", "hba_file=/etc/postgresql/pg_hba.conf", -# "-c", "config_file=/etc/postgresql/postgresql.conf", ] healthcheck: - test: - [ - "CMD-SHELL", - "PGPASSWORD=${POSTGRES_PASSWORD:-postgres} pg_isready -U ${POSTGRES_USER:-postgres} -h localhost -d ${POSTGRES_DB:-postgres}" - ] + test: ["CMD", "pg_isready", "-h", "127.0.0.1"] interval: 30s timeout: 10s retries: 5 - networks: - datastore: - ipv4_address: 172.30.0.101 mysql: <<: *db-service @@ -76,13 +64,10 @@ services: - lds_my:/var/lib/mysql - ../../logs/mysql:/var/log/mysql healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -u root -p${MYSQL_ROOT_PASSWORD:-12345}"] + test: ["CMD", "mysqladmin", "ping", "-h127.0.0.1", "--silent"] interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.102 mongodb: <<: *db-service @@ -98,17 +83,10 @@ services: - lds_mongo:/data/db - ../../logs/mongodb:/var/log/mongodb healthcheck: - test: - [ - "CMD-SHELL", - "mongosh \"mongodb://${MONGODB_ROOT_USERNAME:-root}:${MONGODB_ROOT_PASSWORD:-12345}@localhost:${MONGODB_PORT:-27017}/admin\" --eval \"db.adminCommand('ping')\" --quiet || exit 1" - ] + test: ["CMD", "mongosh", "--host", "127.0.0.1", "--quiet", "--eval", "db.adminCommand('ping')"] interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.103 mariadb: <<: *db-service @@ -126,19 +104,16 @@ services: - lds_maria:/var/lib/mysql - ../../logs/mariadb:/var/log/mysql healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p${MARIADB_ROOT_PASSWORD:-12345}"] + test: ["CMD", "mysqladmin", "ping", "-h127.0.0.1", "--silent"] interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.104 elasticsearch: <<: *db-service container_name: ELASTICSEARCH hostname: elasticsearch - image: elasticsearch:${ELASTICSEARCH_VERSION:-9.3.0} + image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION:-9.5.4} profiles: [elasticsearch] environment: - TZ=${TZ:-} @@ -154,6 +129,3 @@ services: interval: 30s timeout: 10s retries: 3 - networks: - datastore: - ipv4_address: 172.30.0.105 diff --git a/docker/compose/http.yaml b/docker/compose/http.yaml index 6091018c..dd8fd9bb 100644 --- a/docker/compose/http.yaml +++ b/docker/compose/http.yaml @@ -6,33 +6,33 @@ services: restart: unless-stopped environment: - TZ=${TZ:-} + - LLM_PROXY_TIMEOUT_SECONDS=${LDS_AI_TIMEOUT:-1800} ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" + - "127.0.0.1:11434:11434" volumes: - - "${PROJECT_DIR:-./../../../application}:/app" + - ${PROJECT_DIR:-./../../../application}:/app - lds_nginx_host:/etc/nginx/conf.d - lds_ssl_keys:/etc/mkcert:ro - lds_ssl_roots:/etc/share/rootCA:ro - lds_fpm_sock:/run/php-fpm:ro - ../../logs/nginx:/var/log/nginx - extra_hosts: - - "host.docker.internal:host-gateway" networks: - frontend: - ipv4_address: 172.28.0.10 + - frontend depends_on: - - server-tools + server-tools: + condition: service_healthy apache: container_name: APACHE hostname: apache image: infocyph/apache:latest - restart: always + restart: unless-stopped environment: - TZ=${TZ:-} volumes: - - "${PROJECT_DIR:-./../../../application}:/app" + - ${PROJECT_DIR:-./../../../application}:/app - lds_ssl_keys:/etc/mkcert:ro - lds_apache_host:/usr/local/apache2/conf/vhosts:ro - lds_ssl_roots:/etc/share/rootCA:ro @@ -41,5 +41,4 @@ services: depends_on: - nginx networks: - frontend: - ipv4_address: 172.28.0.11 + - frontend diff --git a/docker/compose/main.yaml b/docker/compose/main.yaml index 2e189244..9a70e178 100644 --- a/docker/compose/main.yaml +++ b/docker/compose/main.yaml @@ -7,10 +7,7 @@ networks: com.docker.compose.project: "LocalDevStack" com.docker.compose.network: "frontend" com.infocyph.stack: "LocalDevStack" - ipam: - config: - - subnet: 172.28.0.0/24 - gateway: 172.28.0.1 + com.infocyph.network-schema: "dynamic-v1" backend: name: Backend driver: bridge @@ -18,10 +15,7 @@ networks: com.docker.compose.project: "LocalDevStack" com.docker.compose.network: "backend" com.infocyph.stack: "LocalDevStack" - ipam: - config: - - subnet: 172.29.0.0/24 - gateway: 172.29.0.1 + com.infocyph.network-schema: "dynamic-v1" datastore: name: DataStore driver: bridge @@ -29,10 +23,7 @@ networks: com.docker.compose.project: "LocalDevStack" com.docker.compose.network: "datastore" com.infocyph.stack: "LocalDevStack" - ipam: - config: - - subnet: 172.30.0.0/24 - gateway: 172.30.0.1 + com.infocyph.network-schema: "dynamic-v1" volumes: lds_fpm_sock: name: FPMSocks @@ -148,6 +139,24 @@ volumes: com.infocyph.lds: "1" com.infocyph.stack: "LocalDevStack" com.infocyph.purpose: "Filebeat Data" + lds_llm: + name: LLMModels + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Ollama Model Store" + lds_llm_fastflow: + name: LLMFastFlowModels + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "FastFlow Model Store" + lds_tools_state: + name: ToolsState + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Tools Durable State" include: - docker/compose/companion.yaml - docker/compose/db.yaml diff --git a/docker/conf/pg_hba.conf b/docker/conf/pg_hba.conf index 5534f88c..01d95332 100644 --- a/docker/conf/pg_hba.conf +++ b/docker/conf/pg_hba.conf @@ -11,8 +11,8 @@ host all all 127.0.0.1/32 scram-sha-256 # 3. IPv6 loopback host all all ::1/128 scram-sha-256 -# 4. Docker “datastore” network -host all all 172.30.0.0/24 scram-sha-256 +# 4. Docker-attached networks (dynamic bridge subnet) +host all all samenet scram-sha-256 # 5. (Optional) pgAdmin or external tools # If you really need to allow any host, you can—but it’s safer to lock this diff --git a/docker/conf/postgresql.conf b/docker/conf/postgresql.conf index 83ad60d0..e1799637 100644 --- a/docker/conf/postgresql.conf +++ b/docker/conf/postgresql.conf @@ -1,3 +1,9 @@ +# INACTIVE REFERENCE CONFIGURATION +# LocalDevStack does not mount or activate this file by default. +# The supported PostgreSQL runtime currently uses the image defaults plus +# docker/conf/pg_hba.conf. Any future activation of this file requires explicit +# version-specific validation and a Compose contract test. +# ########################## # CONNECTIONS & AUTHENTICATION ########################## diff --git a/docker/conf/www.conf b/docker/conf/www.conf deleted file mode 100644 index 70bc610a..00000000 --- a/docker/conf/www.conf +++ /dev/null @@ -1,417 +0,0 @@ -[global] -error_log = /proc/self/fd/2 -; Start a new pool named 'www'. -; the variable $pool can be used in any directive and will be replaced by the -; pool name ('www' here) -[www] - - - -; Per pool prefix -; It only applies on the following directives: -; - 'access.log' -; - 'slowlog' -; - 'listen' (unixsocket) -; - 'chroot' -; - 'chdir' -; - 'php_values' -; - 'php_admin_values' -; When not set, the global prefix (or NONE) applies instead. -; Note: This directive can also be relative to the global prefix. -; Default Value: none -;prefix = /path/to/pools/$pool - -; Unix user/group of processes -; Note: The user is mandatory. If the group is not set, the default user's group -; will be used. -user = www-data -group = www-data - -; The address on which to accept FastCGI requests. -; Valid syntaxes are: -; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on -; a specific port; -; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on -; a specific port; -; 'port' - to listen on a TCP socket to all addresses -; (IPv6 and IPv4-mapped) on a specific port; -; '/path/to/unix/socket' - to listen on a unix socket. -; Note: This value is mandatory. -listen = 0.0.0.0:9000 - -; Set listen(2) backlog. -; Default Value: 511 (-1 on FreeBSD and OpenBSD) -;listen.backlog = 511 - -; Set permissions for unix socket, if one is used. In Linux, read/write -; permissions must be set in order to allow connections from a web server. Many -; BSD-derived systems allow connections regardless of permissions. -; Default Values: user and group are set as the running user -; mode is set to 0660 -;listen.owner = www-data -;listen.group = www-data -;listen.mode = 0660 -; When POSIX Access Control Lists are supported you can set them using -; these options, value is a comma separated list of user/group names. -; When set, listen.owner and listen.group are ignored -;listen.acl_users = -;listen.acl_groups = - -; List of addresses (IPv4/IPv6) of FastCGI clients which are allowed to connect. -; Equivalent to the FCGI_WEB_SERVER_ADDRS environment variable in the original -; PHP FCGI (5.2.2+). Makes sense only with a tcp listening socket. Each address -; must be separated by a comma. If this value is left blank, connections will be -; accepted from any ip address. -; Default Value: any -;listen.allowed_clients = 127.0.0.1 - -; Specify the nice(2) priority to apply to the pool processes (only if set) -; The value can vary from -19 (highest priority) to 20 (lower priority) -; Note: - It will only work if the FPM master process is launched as root -; - The pool processes will inherit the master process priority -; unless it specified otherwise -; Default Value: no set -; process.priority = -19 - -; Choose how the process manager will control the number of child processes. -; Possible Values: -; static - a fixed number (pm.max_children) of child processes; -; dynamic - the number of child processes are set dynamically based on the -; following directives. With this process management, there will be -; always at least 1 children. -; pm.max_children - the maximum number of children that can -; be alive at the same time. -; pm.start_servers - the number of children created on startup. -; pm.min_spare_servers - the minimum number of children in 'idle' -; state (waiting to process). If the number -; of 'idle' processes is less than this -; number then some children will be created. -; pm.max_spare_servers - the maximum number of children in 'idle' -; state (waiting to process). If the number -; of 'idle' processes is greater than this -; number then some children will be killed. -; ondemand - no children are created at startup. Children will be forked when -; new requests will connect. The following parameter are used: -; pm.max_children - the maximum number of children that -; can be alive at the same time. -; pm.process_idle_timeout - The number of seconds after which -; an idle process will be killed. -; Note: This value is mandatory. -pm = static - -; The number of child processes to be created when pm is set to 'static' and the -; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'. -; This value sets the limit on the number of simultaneous requests that will be -; served. Equivalent to the ApacheMaxClients directive with mpm_prefork. -; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP -; CGI. The below defaults are based on a server without much resources. Don't -; forget to tweak pm.* to fit your needs. -; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand' -; Note: This value is mandatory. -pm.max_children = 300 - -; The number of child processes created on startup. -; Note: Used only when pm is set to 'dynamic' -; Default Value: min_spare_servers + (max_spare_servers - min_spare_servers) / 2 -pm.start_servers = 12 - -; The desired minimum number of idle server processes. -; Note: Used only when pm is set to 'dynamic' -; Note: Mandatory when pm is set to 'dynamic' -pm.min_spare_servers = 6 - -; The desired maximum number of idle server processes. -; Note: Used only when pm is set to 'dynamic' -; Note: Mandatory when pm is set to 'dynamic' -pm.max_spare_servers = 12 - -; The number of seconds after which an idle process will be killed. -; Note: Used only when pm is set to 'ondemand' -; Default Value: 10s -pm.process_idle_timeout = 8s; - -; The number of requests each child process should execute before respawning. -; This can be useful to work around memory leaks in 3rd party libraries. For -; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS. -; Default Value: 0 -pm.max_requests = 300 - -; The URI to view the FPM status page. If this value is not set, no URI will be -; recognized as a status page. It shows the following informations: -; pool - the name of the pool; -; process manager - static, dynamic or ondemand; -; start time - the date and time FPM has started; -; start since - number of seconds since FPM has started; -; accepted conn - the number of request accepted by the pool; -; listen queue - the number of request in the queue of pending -; connections (see backlog in listen(2)); -; max listen queue - the maximum number of requests in the queue -; of pending connections since FPM has started; -; listen queue len - the size of the socket queue of pending connections; -; idle processes - the number of idle processes; -; active processes - the number of active processes; -; total processes - the number of idle + active processes; -; max active processes - the maximum number of active processes since FPM -; has started; -; max children reached - number of times, the process limit has been reached, -; when pm tries to start more children (works only for -; pm 'dynamic' and 'ondemand'); -; Value are updated in real time. -; Example output: -; pool: www -; process manager: static -; start time: 01/Jul/2011:17:53:49 +0200 -; start since: 62636 -; accepted conn: 190460 -; listen queue: 0 -; max listen queue: 1 -; listen queue len: 42 -; idle processes: 4 -; active processes: 11 -; total processes: 15 -; max active processes: 12 -; max children reached: 0 -; -; By default the status page output is formatted as text/plain. Passing either -; 'html', 'xml' or 'json' in the query string will return the corresponding -; output syntax. Example: -; http://www.foo.bar/status -; http://www.foo.bar/status?json -; http://www.foo.bar/status?html -; http://www.foo.bar/status?xml -; -; By default the status page only outputs short status. Passing 'full' in the -; query string will also return status for each pool process. -; Example: -; http://www.foo.bar/status?full -; http://www.foo.bar/status?json&full -; http://www.foo.bar/status?html&full -; http://www.foo.bar/status?xml&full -; The Full status returns for each process: -; pid - the PID of the process; -; state - the state of the process (Idle, Running, ...); -; start time - the date and time the process has started; -; start since - the number of seconds since the process has started; -; requests - the number of requests the process has served; -; request duration - the duration in µs of the requests; -; request method - the request method (GET, POST, ...); -; request URI - the request URI with the query string; -; content length - the content length of the request (only with POST); -; user - the user (PHP_AUTH_USER) (or '-' if not set); -; script - the main script called (or '-' if not set); -; last request cpu - the %cpu the last request consumed -; it's always 0 if the process is not in Idle state -; because CPU calculation is done when the request -; processing has terminated; -; last request memory - the max amount of memory the last request consumed -; it's always 0 if the process is not in Idle state -; because memory calculation is done when the request -; processing has terminated; -; If the process is in Idle state, then informations are related to the -; last request the process has served. Otherwise informations are related to -; the current request being served. -; Example output: -; ************************ -; pid: 31330 -; state: Running -; start time: 01/Jul/2011:17:53:49 +0200 -; start since: 63087 -; requests: 12808 -; request duration: 1250261 -; request method: GET -; request URI: /test_mem.php?N=10000 -; content length: 0 -; user: - -; script: /home/fat/web/docs/php/test_mem.php -; last request cpu: 0.00 -; last request memory: 0 -; -; Note: There is a real-time FPM status monitoring sample web page available -; It's available in: /usr/local/share/php/fpm/status.html -; -; Note: The value must start with a leading slash (/). The value can be -; anything, but it may not be a good idea to use the .php extension or it -; may conflict with a real PHP file. -; Default Value: not set -pm.status_path = /php_status - -; The ping URI to call the monitoring page of FPM. If this value is not set, no -; URI will be recognized as a ping page. This could be used to test from outside -; that FPM is alive and responding, or to -; - create a graph of FPM availability (rrd or such); -; - remove a server from a group if it is not responding (load balancing); -; - trigger alerts for the operating team (24/7). -; Note: The value must start with a leading slash (/). The value can be -; anything, but it may not be a good idea to use the .php extension or it -; may conflict with a real PHP file. -; Default Value: not set -;ping.path = /ping - -; This directive may be used to customize the response of a ping request. The -; response is formatted as text/plain with a 200 response code. -; Default Value: pong -;ping.response = pong - -; The access log file -; Default: not set -;access.log = log/$pool.access.log - -; The access log format. -; The following syntax is allowed -; %%: the '%' character -; %C: %CPU used by the request -; it can accept the following format: -; - %{user}C for user CPU only -; - %{system}C for system CPU only -; - %{total}C for user + system CPU (default) -; %d: time taken to serve the request -; it can accept the following format: -; - %{seconds}d (default) -; - %{miliseconds}d -; - %{mili}d -; - %{microseconds}d -; - %{micro}d -; %e: an environment variable (same as $_ENV or $_SERVER) -; it must be associated with embraces to specify the name of the env -; variable. Some exemples: -; - server specifics like: %{REQUEST_METHOD}e or %{SERVER_PROTOCOL}e -; - HTTP headers like: %{HTTP_HOST}e or %{HTTP_USER_AGENT}e -; %f: script filename -; %l: content-length of the request (for POST request only) -; %m: request method -; %M: peak of memory allocated by PHP -; it can accept the following format: -; - %{bytes}M (default) -; - %{kilobytes}M -; - %{kilo}M -; - %{megabytes}M -; - %{mega}M -; %n: pool name -; %o: output header -; it must be associated with embraces to specify the name of the header: -; - %{Content-Type}o -; - %{X-Powered-By}o -; - %{Transfert-Encoding}o -; - .... -; %p: PID of the child that serviced the request -; %P: PID of the parent of the child that serviced the request -; %q: the query string -; %Q: the '?' character if query string exists -; %r: the request URI (without the query string, see %q and %Q) -; %R: remote IP address -; %s: status (response code) -; %t: server time the request was received -; it can accept a strftime(3) format: -; %d/%b/%Y:%H:%M:%S %z (default) -; The strftime(3) format must be encapsuled in a %{}t tag -; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t -; %T: time the log has been written (the request has finished) -; it can accept a strftime(3) format: -; %d/%b/%Y:%H:%M:%S %z (default) -; The strftime(3) format must be encapsuled in a %{}t tag -; e.g. for a ISO8601 formatted timestring, use: %{%Y-%m-%dT%H:%M:%S%z}t -; %u: remote user -; -; Default: "%R - %u %t \"%m %r\" %s" -;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{mili}d %{kilo}M %C%%" - -; The log file for slow requests -; Default Value: not set -; Note: slowlog is mandatory if request_slowlog_timeout is set -;slowlog = log/$pool.log.slow - -; The timeout for serving a single request after which a PHP backtrace will be -; dumped to the 'slowlog' file. A value of '0s' means 'off'. -; Available units: s(econds)(default), m(inutes), h(ours), or d(ays) -; Default Value: 0 -;request_slowlog_timeout = 0 - -; The timeout for serving a single request after which the worker process will -; be killed. This option should be used when the 'max_execution_time' ini option -; does not stop script execution for some reason. A value of '0' means 'off'. -; Available units: s(econds)(default), m(inutes), h(ours), or d(ays) -; Default Value: 0 -;request_terminate_timeout = 0 - -; Set open file descriptor rlimit. -; Default Value: system defined value -;rlimit_files = 1024 - -; Set max core size rlimit. -; Possible Values: 'unlimited' or an integer greater or equal to 0 -; Default Value: system defined value -;rlimit_core = 0 - -; Chroot to this directory at the start. This value must be defined as an -; absolute path. When this value is not set, chroot is not used. -; Note: you can prefix with '$prefix' to chroot to the pool prefix or one -; of its subdirectories. If the pool prefix is not set, the global prefix -; will be used instead. -; Note: chrooting is a great security feature and should be used whenever -; possible. However, all PHP paths will be relative to the chroot -; (error_log, sessions.save_path, ...). -; Default Value: not set -;chroot = - -; Chdir to this directory at the start. -; Note: relative path can be used. -; Default Value: current directory or / when chroot -;chdir = /var/www - -; Redirect worker stdout and stderr into main error log. If not set, stdout and -; stderr will be redirected to /dev/null according to FastCGI specs. -; Note: on highloaded environement, this can cause some delay in the page -; process time (several ms). -; Default Value: no -;catch_workers_output = yes - -; Clear environment in FPM workers -; Prevents arbitrary environment variables from reaching FPM worker processes -; by clearing the environment in workers before env vars specified in this -; pool configuration are added. -; Setting to "no" will make all environment variables available to PHP code -; via getenv(), $_ENV and $_SERVER. -; Default Value: yes -;clear_env = no - -; Limits the extensions of the main script FPM will allow to parse. This can -; prevent configuration mistakes on the web server side. You should only limit -; FPM to .php extensions to prevent malicious users to use other extensions to -; execute php code. -; Note: set an empty value to allow all extensions. -; Default Value: .php -;security.limit_extensions = .php .php3 .php4 .php5 .php7 - -; Pass environment variables like LD_LIBRARY_PATH. All $VARIABLEs are taken from -; the current environment. -; Default Value: clean env -;env[HOSTNAME] = $HOSTNAME -;env[PATH] = /usr/local/bin:/usr/bin:/bin -;env[TMP] = /tmp -;env[TMPDIR] = /tmp -;env[TEMP] = /tmp - -; Additional php.ini defines, specific to this pool of workers. These settings -; overwrite the values previously defined in the php.ini. The directives are the -; same as the PHP SAPI: -; php_value/php_flag - you can set classic ini defines which can -; be overwritten from PHP call 'ini_set'. -; php_admin_value/php_admin_flag - these directives won't be overwritten by -; PHP call 'ini_set' -; For php_*flag, valid values are on, off, 1, 0, true, false, yes or no. - -; Defining 'extension' will load the corresponding shared extension from -; extension_dir. Defining 'disable_functions' or 'disable_classes' will not -; overwrite previously defined php.ini values, but will append the new value -; instead. - -; Note: path INI options can be relative and will be expanded with the prefix -; (pool, global or /usr/local) - -; Default Value: nothing is defined by default except the values in php.ini and -; specified at startup with the -d argument -;php_admin_value[sendmail_path] = /usr/sbin/sendmail -t -i -f www@my.domain.com -;php_flag[display_errors] = on -;php_admin_value[error_log] = /var/log/fpm-php.www.log -;php_admin_flag[log_errors] = on -;php_admin_value[memory_limit] = 32M diff --git a/docker/dockerfiles/node.Dockerfile b/docker/dockerfiles/node.Dockerfile index 5775aa57..4e41d09f 100644 --- a/docker/dockerfiles/node.Dockerfile +++ b/docker/dockerfiles/node.Dockerfile @@ -1,5 +1,6 @@ ARG NODE_VERSION=current FROM node:${NODE_VERSION}-alpine +ARG NODE_VERSION LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDevStack" LABEL org.opencontainers.image.description="NodeJS Alpine" @@ -10,20 +11,46 @@ ARG USERNAME=dockery ENV USERNAME=${USERNAME} ARG UID=1000 ARG GID=1000 -ARG LINUX_PKG -ARG LINUX_PKG_VERSIONED -ARG NODE_GLOBAL -ARG NODE_GLOBAL_VERSIONED +ARG LINUX_PKG="" +ARG LINUX_PKG_VERSIONED="" +ARG NODE_GLOBAL="" +ARG NODE_GLOBAL_VERSIONED="" +ARG SCRIPTOMATIC_REF=main +ARG SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT=10 +ARG SCRIPTOMATIC_DOWNLOAD_MAX_TIME=120 +ARG SCRIPTOMATIC_DOWNLOAD_RETRIES=3 ENV PATH="/usr/local/bin:/usr/bin:/bin:/usr/games:$PATH" \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ NPM_CONFIG_CACHE=/home/${USERNAME}/.npm \ GIT_CONFIG_GLOBAL=/git-config/.gitconfig -ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/node-cli-setup.sh /usr/local/bin/cli-setup.sh -RUN apk add --no-cache bash && \ - NODE_VERSION="$(node -v | sed 's/^v//')" && \ - bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${NODE_VERSION}" +RUN set -eux; \ + apk add --no-cache bash curl; \ + bootstrap="$(mktemp /tmp/scriptomatic-node.XXXXXX)"; \ + curl --fail --silent --show-error --location \ + --connect-timeout "${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}" \ + --max-time "${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}" \ + --retry "${SCRIPTOMATIC_DOWNLOAD_RETRIES}" \ + --retry-delay 1 \ + --retry-connrefused \ + "https://raw.githubusercontent.com/infocyph/Scriptomatic/${SCRIPTOMATIC_REF}/bash/node-cli-setup.sh" \ + -o "$bootstrap"; \ + test -s "$bootstrap"; \ + bash -n "$bootstrap"; \ + resolved_node_version="$(node -v | sed 's/^v//')"; \ + UID="${UID}" \ + GID="${GID}" \ + LINUX_PKG="${LINUX_PKG}" \ + LINUX_PKG_VERSIONED="${LINUX_PKG_VERSIONED}" \ + NODE_GLOBAL="${NODE_GLOBAL}" \ + NODE_GLOBAL_VERSIONED="${NODE_GLOBAL_VERSIONED}" \ + SCRIPTOMATIC_REF="${SCRIPTOMATIC_REF}" \ + SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT="${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}" \ + SCRIPTOMATIC_DOWNLOAD_MAX_TIME="${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}" \ + SCRIPTOMATIC_DOWNLOAD_RETRIES="${SCRIPTOMATIC_DOWNLOAD_RETRIES}" \ + bash "$bootstrap" "${USERNAME}" "$resolved_node_version"; \ + rm -f "$bootstrap" USER ${USERNAME} WORKDIR /app diff --git a/docker/dockerfiles/php.Dockerfile b/docker/dockerfiles/php.Dockerfile index 9d0ec641..5636d6e1 100644 --- a/docker/dockerfiles/php.Dockerfile +++ b/docker/dockerfiles/php.Dockerfile @@ -1,5 +1,6 @@ ARG PHP_VERSION=8.4 FROM php:${PHP_VERSION}-fpm-alpine +ARG PHP_VERSION LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDevStack" LABEL org.opencontainers.image.description="PHP FPM Alpine" @@ -9,20 +10,48 @@ LABEL org.opencontainers.image.authors="infocyph,abmmhasan" ARG USERNAME=dockery ENV USERNAME=${USERNAME} ARG PHP_PROFILE_KEY=84 -ARG LINUX_PKG -ARG LINUX_PKG_VERSIONED -ARG PHP_EXT -ARG PHP_EXT_VERSIONED +ARG LINUX_PKG="" +ARG LINUX_PKG_VERSIONED="" +ARG PHP_EXT="" +ARG PHP_EXT_VERSIONED="" ARG UID=1000 ARG GID=1000 +ARG SCRIPTOMATIC_REF=main +ARG SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT=10 +ARG SCRIPTOMATIC_DOWNLOAD_MAX_TIME=120 +ARG SCRIPTOMATIC_DOWNLOAD_RETRIES=3 ENV PATH="/usr/local/bin:/usr/bin:/bin:/usr/games:$PATH" \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ GIT_CONFIG_GLOBAL=/git-config/.gitconfig \ COMPOSER_HOME=/home/${USERNAME}/.composer/php${PHP_PROFILE_KEY} -ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/php-cli-setup.sh /usr/local/bin/cli-setup.sh -RUN apk add --no-cache bash && PHP_PROFILE_KEY="${PHP_PROFILE_KEY}" bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${PHP_VERSION}" +RUN set -eux; \ + apk add --no-cache bash curl; \ + bootstrap="$(mktemp /tmp/scriptomatic-php.XXXXXX)"; \ + curl --fail --silent --show-error --location \ + --connect-timeout "${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}" \ + --max-time "${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}" \ + --retry "${SCRIPTOMATIC_DOWNLOAD_RETRIES}" \ + --retry-delay 1 \ + --retry-connrefused \ + "https://raw.githubusercontent.com/infocyph/Scriptomatic/${SCRIPTOMATIC_REF}/bash/php-cli-setup.sh" \ + -o "$bootstrap"; \ + test -s "$bootstrap"; \ + bash -n "$bootstrap"; \ + UID="${UID}" \ + GID="${GID}" \ + LINUX_PKG="${LINUX_PKG}" \ + LINUX_PKG_VERSIONED="${LINUX_PKG_VERSIONED}" \ + PHP_EXT="${PHP_EXT}" \ + PHP_EXT_VERSIONED="${PHP_EXT_VERSIONED}" \ + PHP_PROFILE_KEY="${PHP_PROFILE_KEY}" \ + SCRIPTOMATIC_REF="${SCRIPTOMATIC_REF}" \ + SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT="${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}" \ + SCRIPTOMATIC_DOWNLOAD_MAX_TIME="${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}" \ + SCRIPTOMATIC_DOWNLOAD_RETRIES="${SCRIPTOMATIC_DOWNLOAD_RETRIES}" \ + bash "$bootstrap" "${USERNAME}" "${PHP_VERSION}"; \ + rm -f "$bootstrap" USER ${USERNAME} WORKDIR /app diff --git a/docker/release.env b/docker/release.env new file mode 100644 index 00000000..10c037ba --- /dev/null +++ b/docker/release.env @@ -0,0 +1,3 @@ +# LocalDevStack release-owned defaults that genuinely vary at build/runtime. +# Fixed infrastructure images are declared directly in Compose. +SCRIPTOMATIC_REF=main diff --git a/docs/concepts/architecture.rst b/docs/concepts/architecture.rst index b7d15a1d..4aee0389 100644 --- a/docs/concepts/architecture.rst +++ b/docs/concepts/architecture.rst @@ -1,33 +1,223 @@ Architecture ============ -LocalDevStack is composed of: +LocalDevStack separates host orchestration, trusted control-plane duties, web routing, +application runtimes, persistent service state, and optional local AI. -- The **orchestrator**: ``lds`` / ``lds.bat`` (selects profiles, runs Compose, common workflows) -- The **HTTP layer**: Nginx (front proxy) and optionally Apache (backend HTTP) depending on your stack choice -- The **runtimes**: PHP (FPM) and Node (and future stacks) -- The **control plane**: the **server-tools** image (domain/vhost generation, TLS automation, secrets helpers) -- The **runner**: supervisord + cron + logrotate and helper exec wrappers +Host Orchestration +------------------ -Key idea --------- +``lds`` / ``lds.bat`` + Host-side orchestration for setup, profiles, Compose, diagnostics, rebuilds, domains, + TLS installation, support tooling, and convenience wrappers. -Instead of a monolithic "one container does everything" model, LocalDevStack uses: +``docker/release.env`` + Tracked release/build defaults that genuinely vary, such as ``SCRIPTOMATIC_REF``. Fixed infrastructure image names live directly in Compose. -- Compose profiles to enable only what you need -- Generated configuration artifacts (vhosts, certificates) persisted on the host -- Stable container names/hostnames to keep local routing predictable +``docker/.env`` + User-owned LocalDevStack configuration. -How containers cooperate +``configuration/compose/`` + Generated runtime Compose fragments discovered and merged into the effective stack. + +Core Services +------------- + +``server-tools`` + Trusted control plane. Tools owns vhost generation, certificate generation, durable + domain/runtime metadata, admin UI behavior, secrets helpers, Git/dev utilities, + monitoring, and AI-consumer commands. + +``runner`` + Background execution layer for Supervisor, cron definitions, and log rotation. + +``mailpit`` + Persistent local mail capture with LocalDevStack TLS material. + +``nginx`` + Host-facing HTTP/HTTPS front door. Ports 80/443 are published here. + +``apache`` + Always-available alternate HTTP backend. Individual domains decide whether to route + through Apache; the container remains part of the core stack so CLI and Admin Panel + domain creation retain the same capabilities. + +Application Runtimes +-------------------- + +PHP and Node runtimes are generated per selected version. The domain wizard preserves +the user's explicit version choice. + +Generated image identities are:: + + localdevstack-php: + localdevstack-node: + +Both runtime families use Alpine variants. Scriptomatic supplies common runtime bootstrap +behavior and installs Toolset according to its own current contract. + +Optional Data Services +---------------------- + +Catalog-managed profiles currently include:: + + postgresql + mysql + mariadb + mongodb + redis + elasticsearch + ai + +Related admin clients are enabled through the same profiles where applicable: +CloudBeaver, RedisInsight, Mongo Express, and Kibana. + +An advanced ``filebeat`` profile exists in Compose for Elastic log ingestion. It is not +part of the normal guided catalog and should be enabled deliberately alongside +Elasticsearch. + +Networking +---------- + +LocalDevStack keeps three logical networks:: + + Frontend + Backend + DataStore + +Docker assigns their address ranges dynamically. Core services do not rely on fixed +private subnet addresses. + +Service-to-service traffic uses Docker DNS names such as:: + + server-tools + runner + mailpit + nginx + apache + postgres + mysql + mariadb + mongodb + redis + elasticsearch + llm + llm-ollama + llm-fastflow + +The historical ``lds vpn-fix`` command remains only as a deprecated compatibility +message because LocalDevStack no longer owns fixed bridge subnets. + +Legacy Network Migration ------------------------ -1. You generate vhost configs (via ``lds setup domain``). -2. The Tools container can scan all vhosts and generate certificates. -3. Nginx loads hosts and routes requests either: +``lds up`` and ``lds start`` call the legacy-network migration guard before starting the +stack. + +The migration is deliberately conservative: + +1. only the known historical network names are considered; +2. the expected historical subnet must match; +3. ownership labels must prove the network belongs to LocalDevStack; +4. attached containers must also belong to the effective Compose project; +5. Compose is brought down without ``-v``; +6. only proven legacy networks are removed. + +Persistent named volumes are not removed by this migration. + +Domain Flow +----------- + +A normal domain flow is: + +1. ``lds setup domain`` delegates domain/runtime generation to Tools; +2. Tools writes Nginx/Apache vhosts into persistent named volumes; +3. Tools writes runtime Compose fragments under ``configuration/compose/``; +4. generated runtime/server profiles are updated; +5. LocalDevStack recreates the effective stack; +6. Nginx routes through Docker service names or PHP-FPM sockets. + +Removing a domain follows the corresponding Tools ``rmhost`` state and removes generated +profiles before recreating the stack. + +AI Flow +------- + +When the ``ai`` profile is enabled, LocalDevStack selects exactly one provider: + +.. code-block:: text + + supported XDNA2 NPU -> llm-fastflow + NVIDIA/ROCm/CPU -> llm-ollama + +The selected service owns the common Docker DNS alias ``llm`` on internal port ``11434``. +Tools consumes ``http://llm:11434`` and Nginx exposes ``https://llm.localhost`` plus the +loopback-only native route ``http://127.0.0.1:11434``. Provider-specific Nginx routes +remain available for diagnostics/native operations. + +``lds ai`` delegates higher-level operational/developer AI to Tools. ``lds llm`` resolves +the active provider and dispatches provider/model operations to ``llm-fastflow`` or +``llm-ollama`` as appropriate. + +Both provider definitions live in ``docker/compose/companion.yaml`` but dynamic profile +selection ensures they are mutually exclusive. FastFlow uses ``infocyph/llm-fastflow:latest`` +for ``npu``. Ollama uses ``latest`` for CPU/NVIDIA and ``amd-latest`` for AMD/ROCm. + +Provider defaults also differ: FastFlow uses ``qwen3.5:9b`` and Ollama uses +``qwen3:14b`` unless ``LDS_AI_MODEL`` is explicitly set. + +NVIDIA/ROCm hardware augmentation is generated temporarily under ``docker/.runtime/``. +FastFlow's XDNA2 device and memlock contract is tracked directly in its service definition. +Project Identity +---------------- + +The Compose project contract defaults to ``LocalDevStack``. Label-scoped commands such +as diagnostics, events, support bundles, and cleanup use the effective Compose project +name instead of deriving identity from the checkout directory. + +An explicit ``COMPOSE_PROJECT_NAME`` override therefore remains coherent with these +operations. + +Tool Proxying +------------- + +Some non-privileged developer utilities can be resolved from ``server-tools`` when they +are unavailable on the host. Host binaries win first. Docker and other host-control +commands are never proxied. + +Disable proxying for a command with:: + + LDS_PROXY_TOOLS=0 lds + +Trust Boundaries +---------------- + +``server-tools`` and ``runner`` intentionally receive ``/var/run/docker.sock`` because +their supported workflows control sibling containers. Docker socket access is +equivalent to powerful host Docker control. + +The Docker socket is not mounted into: + +- ``llm-ollama``; +- ``llm-fastflow``; +- databases; +- database admin clients; +- Nginx or Apache; +- ordinary generated runtimes. + +The separate ad-hoc ``lds run --sock`` option is an explicit opt-in and should be used +only with trusted Dockerfiles/code. + +Neither LLM provider receives a project/repository mount by default. AI output is not +automatically executed as shell, SQL, or code. + +Persistence +----------- + +Runtime-generated vhosts, certificate material, databases, Mailpit data, PHP-FPM state, +Tools control state, and AI models use named Docker volumes. - - directly to PHP-FPM (fastcgi) or - - to Apache (reverse proxy) when Apache mode is enabled or - - to a Node service (reverse proxy). +Host-editable/generated configuration, logs, SOPS/Age state, optional SSH material, and +public TLS exports remain under the repository. -4. The Runner handles background services (cron/logrotate) and gives you a consistent place for helper utilities. -5. You also get following services: EMail, DB, Caching +See :doc:`storage-layout` for ownership details. diff --git a/docs/concepts/profiles-and-env.rst b/docs/concepts/profiles-and-env.rst index 310978e5..5c24e4a6 100644 --- a/docs/concepts/profiles-and-env.rst +++ b/docs/concepts/profiles-and-env.rst @@ -1,37 +1,327 @@ Profiles and Environment ======================== -LocalDevStack uses Docker Compose **profiles** so you can enable only the services you want for a given project. +LocalDevStack uses Docker Compose profiles to keep optional services explicit while the +core development/control plane remains available. -Setting up Environment usable by Your Projects ----------------------- +Guided Profile Setup +-------------------- -- Use root ``.env`` (project-level overrides) -- These are common & directly shared to projects you host -- If you want project specific only a single project you need to handle that inside your project +Use:: -Guided setup for on demands services (DB, Cache) ------------- + lds setup profile + +The host-side service catalog is tracked at:: + + docker/catalog/services.psv + +It describes service/profile names, setup defaults/prompts, convenience URLs, +persistent-volume metadata, and supported AI runtime modes. + +Catalog-managed optional profiles are: + +- ``postgresql``; +- ``mysql``; +- ``mariadb``; +- ``mongodb``; +- ``redis``; +- ``elasticsearch``; +- ``ai``. + +PHP/Node domain runtimes are selected separately by the domain wizard. + +Replacement Semantics +--------------------- + +Re-running ``lds setup profile`` replaces only the catalog-managed service selection. +Generated domain/runtime profiles are preserved. + +For example, if the existing profile set is conceptually:: + + mysql,redis,ai,apache,php84 + +and the guided selector is re-run with only PostgreSQL selected, the resulting set keeps +the generated runtime/server profiles:: + + postgresql,apache,php84 + +while removing the previously selected catalog services. + +Choosing ``NONE`` clears all catalog-managed service profiles while preserving generated +domain/runtime profiles. Choosing ``CANCEL / Back`` leaves the existing selection +unchanged. When a selected service is configured again, existing user values are reused +as prompt defaults; secret-like values are preserved without printing them. + +Manual profile operations remain available:: + + lds profiles list + lds profiles add redis + lds profiles remove redis + +The advanced Compose-only ``filebeat`` profile can be enabled manually when required. +It should be used with Elasticsearch. + +Environment Ownership +--------------------- + +Tracked product/release defaults live in:: + + docker/release.env + +User LocalDevStack settings live in:: + + docker/.env + +The repository-root ``.env`` remains project/application-facing state where applicable; +it is not the release-default manifest. + +Effective LocalDevStack control precedence is:: + + built-in fallback + < docker/release.env + < docker/.env + < command-scoped shell environment + +LocalDevStack reads dotenv values as data. It does not blindly shell-source +``docker/.env``. + +Initialization +-------------- + +Run:: + + lds setup init + +to initialize workstation defaults such as: + +- ``TZ``; +- ``GIT_USER_NAME``; +- ``GIT_USER_EMAIL``. + +Setup also maintains LocalDevStack host identity values such as ``WORKING_DIR`` and, +on non-root Unix invocation, ``USER``, ``UID``, and ``GID``. + +Common Configuration Keys +------------------------- + +Frequently used user overrides include:: + + PROJECT_DIR=/path/to/application + HTTP_PORT=80 + HTTPS_PORT=443 + COMPOSE_PROFILES=... + COMPOSE_PROJECT_NAME=LocalDevStack + +Fixed Infrastructure Images +--------------------------- + +Tools, Runner, Nginx, and Apache are fixed LocalDevStack product components and are +declared directly in Compose as their published ``:latest`` images. LocalDevStack does +not expose redundant ``LDS_*_IMAGE`` overrides for them. + +``docker/release.env`` is reserved for defaults that genuinely vary, such as +``SCRIPTOMATIC_REF``. + +Useful Inspection Commands +-------------------------- + +Show environment keys without printing values:: + + lds config env-used + +Show effective Compose config with secrets redacted:: + + lds config show + +JSON form:: + + lds config show --json + +Show raw effective Compose config only when intentionally needed:: -The ``lds`` CLI typically includes helpers like: + lds config show --raw -- ``lds setup profiles`` +Show resolved services/profiles:: -These helpers are opinionated: they try to keep profiles and generated configs consistent. + lds config services + lds config profiles -Manual setup (Don't use unless you are fully aware of internals) +Validate Compose and mounted scheduler configuration:: + + lds config validate + +Show selected/catalog profiles:: + + lds profiles list + +Image Defaults +-------------- + +The default policy is: + + Prefer the moving Alpine variant when the image family provides a suitable one; + otherwise use its normal moving latest tag. + +Examples: + +- PostgreSQL defaults to ``postgres:alpine``. +- Tools, Runner, Nginx, and Apache use their published ``:latest`` aliases. +- XDNA2 NPU local AI uses ``infocyph/llm-fastflow:latest``. +- CPU/NVIDIA local AI uses ``infocyph/llm-ollama:latest``. +- AMD ROCm local AI uses ``infocyph/llm-ollama:amd-latest``. +- Elasticsearch, Kibana, and Filebeat share ``ELASTICSEARCH_VERSION`` and default to the current stable ``9.5.4`` because this Elastic image set does not expose a usable moving ``latest`` alias. + +Run:: + + lds images + +to inspect the effective image set. Fixed product images are reported directly; database/runtime/LLM selections reflect their actual configurable state. + +Runtime Version Selection +------------------------- + +Tools publishes the runtime catalog used by ``mkhost``. The user still selects a runtime +version per domain. + +PHP selection becomes: + +- ``PHP_VERSION`` build input; +- ``localdevstack-php:`` image identity; +- Alpine PHP-FPM base. + +Node selection becomes: + +- ``NODE_VERSION`` build input; +- ``localdevstack-node:`` image identity; +- Alpine Node base. + +Runtime rebuilds keep normal Docker build cache while using ``--pull`` to refresh the +selected base. + +Scriptomatic ------------ -Enable PHP 8.4 + MariaDB + Redis: +Runtime builds consume Scriptomatic from ``main`` by default:: + + SCRIPTOMATIC_REF=main + +A full 40-character commit SHA is also accepted for reproducible debugging/release +builds. Other arbitrary refs are rejected. + +AI Settings +----------- + +Important LocalDevStack AI settings include:: + + LDS_AI_ENABLED=auto + LDS_AI_PROVIDER=llm + LDS_AI_URL=http://llm:11434 + LDS_AI_MODEL= + LDS_AI_RUNTIME= + LDS_AI_IGPU_ENABLE= + +``LDS_AI_PROVIDER`` and ``LDS_AI_URL`` describe the common consumer contract. They do +not identify which backend implementation is active. + +When ``LDS_AI_RUNTIME`` is not explicitly set, LocalDevStack detects the preferred +runtime in this order: supported XDNA2 NPU, NVIDIA, AMD ROCm, CPU. + +Provider mapping is:: + + npu -> FastFlow / infocyph/llm-fastflow:latest / qwen3.5:9b + nvidia -> Ollama / infocyph/llm-ollama:latest / qwen3:14b + amd -> Ollama / infocyph/llm-ollama:amd-latest / qwen3:14b + cpu -> Ollama / infocyph/llm-ollama:latest / qwen3:14b + +Only one provider service is enabled. The selected service owns the ``llm`` Docker alias +on port ``11434``. + +Use:: + + lds llm runtime auto + lds llm runtime npu + lds llm runtime nvidia + lds llm runtime amd + lds llm runtime cpu + +to control the runtime explicitly. ``auto`` clears the explicit override. + +Ollama image tags are selected directly from the effective runtime: CPU/NVIDIA use +``infocyph/llm-ollama:latest``, while AMD/ROCm uses +``infocyph/llm-ollama:amd-latest``. + +New setup leaves ``LDS_AI_MODEL`` blank so the active provider default applies. An +explicit model override is forwarded to both Tools and the active provider. Keep it blank +when switching providers automatically unless the same model exists in both runtimes. + +On an AMD CPU with the AMD runtime, the automatic ``LDS_AI_IGPU_ENABLE`` value is ``1`` +and is forwarded as ``OLLAMA_IGPU_ENABLE=1``; otherwise the derived value is ``0``. + +Ollama-side options include:: + + LLM_OLLAMA_SYSTEM= + LLM_OLLAMA_INPUT_WARN_BYTES=1048576 + LLM_OLLAMA_INPUT_MAX_BYTES=0 + LLM_OLLAMA_ATTACHMENT_MAX_BYTES=16777216 + LLM_OLLAMA_ATTACHMENTS_MAX_BYTES=33554432 + LLM_OLLAMA_ATTACHMENT_MAX_COUNT=16 + LLM_OLLAMA_PDF_MAX_PAGES=24 + LLM_OLLAMA_PDF_DPI=120 + LLM_OLLAMA_ALLOW_LARGE_INPUT=0 + OLLAMA_NUM_PARALLEL=1 + OLLAMA_MAX_LOADED_MODELS=1 + OLLAMA_KEEP_ALIVE=5m + OLLAMA_NO_CLOUD=1 + +FastFlow-side input options include:: + + LLM_FASTFLOW_INPUT_WARN_BYTES=1048576 + LLM_FASTFLOW_INPUT_MAX_BYTES=0 + LLM_FASTFLOW_ATTACHMENT_MAX_BYTES=16777216 + LLM_FASTFLOW_ATTACHMENTS_MAX_BYTES=33554432 + LLM_FASTFLOW_ATTACHMENT_MAX_COUNT=16 + LLM_FASTFLOW_PDF_MAX_PAGES=24 + LLM_FASTFLOW_PDF_DPI=120 + LLM_FASTFLOW_ALLOW_LARGE_INPUT=0 + +Tools consumer controls remain separate:: + + LDS_AI_CONNECT_TIMEOUT=2 + LDS_AI_PREFLIGHT_TIMEOUT=5 + LDS_AI_TIMEOUT=1800 + LDS_AI_AVAILABILITY_TTL=5 + LDS_AI_MAX_CONTEXT_BYTES=524288 + LDS_AI_MAX_REQUEST_BYTES=1048576 + LDS_AI_MAX_RESPONSE_BYTES=2097152 + +The same generation timeout is passed to Nginx as ``LLM_PROXY_TIMEOUT_SECONDS``. + +Both provider definitions live in ``docker/compose/companion.yaml``. No tracked AI +runtime-variant YAML files exist; ``lds`` creates temporary fragments under +``docker/.runtime/`` only for NVIDIA/ROCm augmentation. +Compose Extras +-------------- + +LocalDevStack discovers ``*.yaml`` and ``*.yml`` files under:: + + configuration/compose/ + +and merges them after the built-in product Compose files. This is where generated runtime +fragments live. + +Use the global option:: -.. code-block:: none + lds --reload-extras - COMPOSE_PROFILES=php84,mariadb,redis +when a workflow must force an extras rescan before the command. -Enable Apache mode (Nginx -> Apache -> PHP-FPM): +Tool Proxy Control +------------------ -.. code-block:: none +Proxy-safe utilities can fall back to ``server-tools`` when unavailable on the host. +Disable that behavior with:: - COMPOSE_PROFILES=apache,php84 + LDS_PROXY_TOOLS=0 lds -These are just some of the samples. +Host-control utilities such as Docker, mount operations, chmod/chown, and system service +control are never proxied. diff --git a/docs/concepts/storage-layout.rst b/docs/concepts/storage-layout.rst index 592c548f..0ee15a02 100644 --- a/docs/concepts/storage-layout.rst +++ b/docs/concepts/storage-layout.rst @@ -1,26 +1,175 @@ Storage Layout ============== -To keep LocalDevStack reproducible, generated artifacts are persisted on the host and mounted into containers. +LocalDevStack separates Docker-managed persistent runtime state from host-managed +configuration, exports, and logs. -Directories ------------------------- +Named Volumes +------------- -LocalDevStack uses a ``configuration/`` root (mounted into the stack). Keep all user-managed and generated -artifacts here: +Important named volumes include: -- ``configuration/nginx``: Nginx host configs (primary entry in most setups) -- ``configuration/apache``: Apache host configs (only if Apache mode is enabled) -- ``configuration/ssl``: generated certificates -- ``configuration/rootCA``: Root CA store (persist this to keep browser trust stable, see :doc:`tls-and-certificates`.) -- ``configuration/php``: PHP runtime ini overrides (e.g., ``php.ini``) -- ``configuration/ssh``: optional SSH mount (useful for private repos, git over SSH or tooling) -- ``configuration/sops``: optional SOPS/Age keys + config (if you use the secrets workflow) +``NginxHosts`` / ``ApacheHosts`` + Generated HTTP vhost configuration consumed by Nginx/Apache. -Why this matters ----------------- +``SSLKeys`` / ``SSLRootCA`` + Runtime certificate/key material and the mkcert CA store. -- Keeping hosts stable keeps host and certificate generation stable. -- Persisting the Root CA avoids repeated trust resets and browser warnings. -- Persisting ``php.ini`` overrides keeps runtime behavior consistent between rebuilds. -- Persisting SOPS/Age configuration avoids re-creating keys and keeps secrets workflows predictable. +``FPMPools`` / ``FPMSocks`` + Generated PHP-FPM pool configuration and runtime sockets. + +``ComposerGlobal`` / ``GitConfig`` + Shared Composer/Git runtime state. + +``ToolsState`` + Durable Tools control-plane state under ``/etc/share/state``, including domain/runtime + metadata, monitor history, alert acknowledgements, and other Tools-owned state. + +Database volumes + ``PostgresStore``, ``MySQLStore``, ``MariaDBStore``, ``MongoDBStore``, + ``RedisStore``, and ``ElasticSearchStore``. + +Admin/observability volumes + ``RedisInsightStore``, ``CloudBeaverStore``, ``KibanaStore``, and ``FilebeatStore``. + +``EmailStore`` + Mailpit persistence. + +``LLMModels`` + Ollama model persistence when the selected AI provider is Ollama. + +``LLMFastFlowModels`` + FastFlow model persistence under ``/models`` when the selected AI provider is FastFlow. + +These named volumes are intentionally stable so developer data can survive container and +image replacement. + +Generated Vhost State +--------------------- + +Active Nginx/Apache vhosts do **not** live under host-side +``configuration/nginx``/``configuration/apache`` directories. + +They are persisted in ``NginxHosts`` / ``ApacheHosts`` and are available to Tools and +the web servers through their mounted paths. + +Domain listing, support traces, and support bundles all read this persisted state rather +than a stale host-side vhost directory. + +Accordingly: + +- ``lds domain ls`` reads persisted Nginx vhost state through ``server-tools``; +- ``lds support trace`` reads the named-volume vhost, with the running Nginx mount as a + fallback; +- support bundles copy generated Nginx/Apache vhost state through ``server-tools``. + +Host Configuration +------------------ + +``configuration/compose/`` + LocalDevStack-generated/runtime Compose fragments. Only YAML files are discovered. + +``configuration/php/`` + User-editable PHP configuration. + +``configuration/scheduler/cron-jobs/`` + Runner cron definitions. + +``configuration/scheduler/supervisor/`` + Runner Supervisor definitions. + +``configuration/sops/config/`` / ``configuration/sops/global/`` / ``configuration/sops/keys/`` + SOPS/Age configuration, global secret data, and sensitive key material. + +``configuration/ssh/`` + Optional SSH material mounted read-only into supported Tools/runtime flows. + +``configuration/ssl/`` + User-facing TLS exports from Tools. The public root CA is ``rootCA.pem``. Optional + password-protected user mTLS artifacts may also be exported here. + +``logs/`` + Host-visible service logs consumed by Runner log rotation and diagnostics. + +TLS Authority +------------- + +Runtime TLS state is owned by the SSLKeys / SSLRootCA volume pair: + +- ``SSLKeys`` for server/client certificate material; +- ``SSLRootCA`` for the mkcert CA store. + +Tools exports the public root certificate to:: + + configuration/ssl/rootCA.pem + +``lds certificate install`` uses that current path. Older installations using:: + + configuration/rootCA/rootCA.pem + +remain readable as a migration fallback. + +The private CA key is not intended as a public host export. + +Application Mount +----------------- + +``PROJECT_DIR`` controls the application bind mount. A sibling ``application/`` directory +is the default, but absolute or relative alternatives are supported. + +Local AI receives no application/project bind mount by default. + +Docker Socket Boundary +---------------------- + +``server-tools`` and ``runner`` intentionally mount:: + + /var/run/docker.sock + +because their supported workflows need Docker control. + +Persistent databases, admin clients, Nginx/Apache, ``llm-ollama``, and ``llm-fastflow`` do not receive the +socket by default. + +``lds run --sock`` is a separate explicit opt-in for an ad-hoc container. + +Permissions +----------- + +On Unix-like hosts, ``lds setup permissions`` uses group-writable setgid directories for +``configuration/`` and ``logs/`` rather than broad world-writable modes. + +Sensitive host directories are restricted: + +- ``configuration/ssh/`` directories: ``0700``; files: ``0600``; +- ``configuration/sops/keys/`` directories: ``0700``; files: ``0600``; +- exported P12/PFX/private-key-style files under ``configuration/ssl/``: ``0600``. + +Public certificate exports remain readable. + +Cleanup and Data Safety +----------------------- + +Normal stack shutdown does not delete named volumes:: + + lds down + +Explicit destructive volume removal requires confirmation:: + + lds down --volumes --yes + +Scoped cleanup is safer for routine maintenance:: + + lds clean --yes + +Adding ``--volumes`` attempts to remove LocalDevStack-labelled volumes that are not in +use:: + + lds clean --yes --volumes + +Host-wide Docker pruning requires the explicit ``--global`` flag and may affect unrelated +projects:: + + lds clean --global --yes + +Do not use destructive volume cleanup as part of a normal upgrade. diff --git a/docs/conf.py b/docs/conf.py index e46463dc..ea884f54 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,9 @@ def get_version() -> str: # Sphinx 8 root document root_doc = "index" +# Internal implementation plans are repository artifacts, not public Sphinx pages. +exclude_patterns = ["_build", "plans/**"] + # --- Syntax highlighting (PHP) ---------------------------------------------- from pygments.lexers.web import PhpLexer from sphinx.highlighting import lexers @@ -43,7 +46,6 @@ def get_version() -> str: "myst_parser", "sphinx.ext.todo", "sphinx.ext.autosectionlabel", - "sphinx.ext.intersphinx", "sphinx_copybutton", "sphinx_design", "sphinxcontrib.phpdomain", # Essential for PHP projects @@ -65,11 +67,6 @@ def get_version() -> str: # Autodoc/Napoleon are for Python, so they are omitted. autosectionlabel_prefix_document = True -# Intersphinx: Link to PHP manual -intersphinx_mapping = { - "php": ("https://www.php.net/manual/en/", None), -} - # extlinks shortcut for PHP manual extlinks = { "php": ("https://www.php.net/manual/en/%s.php", "%s"), diff --git a/docs/guides/ad-hoc-runner.rst b/docs/guides/ad-hoc-runner.rst new file mode 100644 index 00000000..ca03a36e --- /dev/null +++ b/docs/guides/ad-hoc-runner.rst @@ -0,0 +1,161 @@ +Ad-hoc Dockerfile Runner +======================== + +``lds run`` is a small host-side utility for building and running a Dockerfile from the +current directory without adding that project to the main LocalDevStack Compose graph. + +It is separate from generated PHP/Node domain runtimes. + +Basic Flow +---------- + +From a directory containing a Dockerfile:: + + cd /path/to/project + lds run + +The default action: + +1. derives a deterministic project slug from the directory name plus a short path hash; +2. uses ``:local`` as the default image tag; +3. builds the image only when that tag is not already present; +4. starts a labelled container; +5. mounts the current directory at ``/workspace``; +6. uses ``/workspace`` as the working directory; +7. keeps the container alive with a shell/sleep command by default. + +Open a shell:: + + lds run shell + +The shell command builds/starts first when required, then opens Bash when available or +falls back to ``sh``. + +Container Identity +------------------ + +Default container names look conceptually like:: + + lds-run-- + +The path hash prevents same-named projects in different directories from colliding. + +Override the name or image tag explicitly:: + + lds run --name my-local-container + lds run --tag my-image + lds run --tag my-image:dev + +When a custom tag has no colon, ``:local`` is appended. + +Build Control +------------- + +Skip the build step when the required image already exists:: + + lds run --no-build + +Force a rebuild by removing the managed container/image first:: + + lds run rm + lds run + +``lds run rm`` removes the matching managed container and its image. + +Container Command vs Keepalive +------------------------------ + +Default mode replaces the image command with a keepalive shell and disables the image +healthcheck so an unrelated healthcheck does not mark the sleeping development +container unhealthy. + +Run the image's normal entrypoint/command instead:: + + lds run --no-keepalive + +Published Ports +--------------- + +Publish one or more ports:: + + lds run --publish 8080:8080 + lds run -p 8080:8080 -p 9229:9229 + +Open the first published port in the host browser:: + + lds run open + +Select a published container port/path:: + + lds run open --port 8080 --path /health + +Use HTTPS when the exposed service expects it:: + + lds run open --port 8443 --https + +Extra Mounts +------------ + +The project directory is always mounted at ``/workspace``. + +Add another mount:: + + lds run --mount ./data:/data + +When the container path is omitted, LocalDevStack mounts the path below ``/mnt`` using +its basename. + +Relative host paths are resolved against the current run directory. + +Docker Socket Opt-in +-------------------- + +Grant the container the host Docker socket only when deliberately required:: + + lds run --sock + +This mounts:: + + /var/run/docker.sock:/var/run/docker.sock + +Docker socket access is equivalent to powerful host Docker control. Use ``--sock`` only +with trusted Dockerfiles and code. + +Host OS +------- + +LocalDevStack automatically detects ``linux``, ``macos``, or ``windows`` and exports it +as ``HOST_OS`` inside the ad-hoc container. + +Override it when required:: + + lds run --host-os linux + +Windows/Git Bash path conversion is handled explicitly so host paths and container +``/workspace`` paths are not confused by MSYS rewriting. + +Lifecycle Commands +------------------ + +List all ad-hoc containers managed by this feature:: + + lds run ps + +Follow logs:: + + lds run logs + +Stop the current-directory container:: + + lds run stop + +Remove its container and image:: + + lds run rm + +Open a shell:: + + lds run shell + +The ad-hoc runner is intentionally independent from ``lds clean`` and the main +LocalDevStack Compose project. diff --git a/docs/guides/databases-and-clients.rst b/docs/guides/databases-and-clients.rst new file mode 100644 index 00000000..f9ac335e --- /dev/null +++ b/docs/guides/databases-and-clients.rst @@ -0,0 +1,187 @@ +Databases and Admin Clients +=========================== + +Database services are profile-driven and communicate through Docker DNS names. Applications +inside LocalDevStack should use service names rather than container IP addresses. + +Service Map +----------- + +.. list-table:: + :header-rows: 1 + :widths: 18 18 24 24 + + * - Profile + - Service DNS + - Admin client + - Convenience URL + * - ``postgresql`` + - ``postgres`` + - CloudBeaver + - ``https://db.localhost`` + * - ``mysql`` + - ``mysql`` + - CloudBeaver + - ``https://db.localhost`` + * - ``mariadb`` + - ``mariadb`` + - CloudBeaver + - ``https://db.localhost`` + * - ``mongodb`` + - ``mongodb`` + - Mongo Express + - ``https://me.localhost`` + * - ``redis`` + - ``redis`` + - RedisInsight + - ``https://ri.localhost`` + * - ``elasticsearch`` + - ``elasticsearch`` + - Kibana + - ``https://kibana.localhost`` + +Enable services through:: + + lds setup profile + +or manage profiles manually:: + + lds profiles add postgresql redis + lds profiles remove redis + +Persistence +----------- + +Each primary datastore has a stable named volume: + +- PostgreSQL: ``PostgresStore``; +- MySQL: ``MySQLStore``; +- MariaDB: ``MariaDBStore``; +- MongoDB: ``MongoDBStore``; +- Redis: ``RedisStore``; +- Elasticsearch: ``ElasticSearchStore``. + +Admin/observability state is persisted separately where the upstream application needs +it. + +Connection Settings +------------------- + +The guided profile flow writes service settings to ``docker/.env``. + +Important keys include: + +PostgreSQL + ``POSTGRES_VERSION``, ``POSTGRES_USER``, ``POSTGRES_PASSWORD``, + ``POSTGRES_DATABASE``. + +MySQL + ``MYSQL_VERSION``, ``MYSQL_ROOT_PASSWORD``, ``MYSQL_USER``, ``MYSQL_PASSWORD``, + ``MYSQL_DATABASE``. + +MariaDB + ``MARIADB_VERSION``, ``MARIADB_ROOT_PASSWORD``, ``MARIADB_USER``, + ``MARIADB_PASSWORD``, ``MARIADB_DATABASE``. + +MongoDB + ``MONGODB_VERSION``, ``MONGODB_ROOT_USERNAME``, ``MONGODB_ROOT_PASSWORD``. + +Redis + ``REDIS_VERSION``. + +Elasticsearch + ``ELASTICSEARCH_VERSION``. The same version is used for Elasticsearch, Kibana, and + Filebeat so the Elastic stack stays aligned. + +The wizard defaults are intended for local development convenience. Change credentials +when a project or workstation policy requires stronger local isolation. + +CLI Wrappers +------------ + +LocalDevStack includes short wrappers so host commands can be routed into the relevant +service/runtime. + +PostgreSQL:: + + lds pg ... + lds psql ... + lds pg_dump ... + lds pg_restore ... + +MySQL:: + + lds my ... + lds mysql ... + lds mysqldump ... + +MariaDB:: + + lds maria ... + lds mariadb ... + lds mariadb-dump ... + +Redis:: + + lds redis ... + lds redis-cli ... + +MongoDB:: + + lds mongo ... + lds mongosh ... + lds mongoimport ... + lds mongoexport ... + +Elasticsearch:: + + lds es ... + lds elasticsearch ... + +The wrappers preserve the service-name based architecture and avoid depending on fixed +bridge addresses. + +Health and Dependencies +----------------------- + +Databases use local readiness probes. Related admin clients wait for their dependency +where the Compose contract declares a health condition. + +Check the whole stack with:: + + lds doctor + +Inspect a service directly with:: + + lds logs postgres + lds stack exec postgres + lds restart postgres + +Admin UIs +--------- + +Use:: + + lds urls + +to show only convenience endpoints whose related profile is enabled. + +Open them directly with:: + + lds open db + lds open redis + lds open mongo + lds open kibana + +Filebeat +-------- + +An advanced ``filebeat`` Compose profile is available for Elastic log ingestion. It is +not included in the normal guided catalog. + +Enable it deliberately alongside Elasticsearch, for example:: + + lds profiles add elasticsearch filebeat + +Filebeat reads the shared host log tree read-only and sends to the ``elasticsearch`` +service over Docker DNS. diff --git a/docs/guides/domain-setup.rst b/docs/guides/domain-setup.rst index 78467787..5faa7750 100644 --- a/docs/guides/domain-setup.rst +++ b/docs/guides/domain-setup.rst @@ -1,68 +1,179 @@ Domain Setup ============ -LocalDevStack creates domains and vhosts, when you run ``lds setup domain``. +LocalDevStack uses Tools for domain/vhost generation while the host ``lds`` CLI owns the +surrounding profile, Compose, and runtime orchestration. -How ``lds setup domain`` works ------------------------------- +Create a Domain +--------------- -1. Run the interactive wizard. -2. Enable the selected profiles. -3. Bring the stack up and reload HTTP. +Start the stack first so ``server-tools`` is available:: -Wizard flow (what the user answers) ------------------------------------ + lds up -``lds setup domain`` runs an interactive 8-step flow: +Then run the interactive wizard:: -1. Domain name -2. App type (PHP or NodeJs) -3. Runtime version (PHP Major.Minor, or Node major/tags) -4. Server type (PHP: Nginx or Apache; Node: Nginx forced + optional Node start command) -5. Protocol (HTTP only / HTTPS only / both + optional redirect) -6. Document root (relative path mapped under ``/app``) -7. Client max body size -8. Mutual TLS toggle (only available when HTTPS is enabled; this requires client side certificate) + lds setup domain -What it generates +or the canonical domain command:: + + lds domain add + +The wizard delegates to Tools ``mkhost`` and collects the details needed for the chosen +application type, including: + +1. domain name; +2. PHP, Node, or supported static/backend application type; +3. runtime version where applicable; +4. HTTP server path where applicable; +5. HTTP/HTTPS behavior; +6. document root; +7. request/body limits; +8. optional mutual TLS settings. + +After generation, LocalDevStack reads Tools state, adds any required generated +server/runtime profile, clears temporary mkhost state, and recreates the stack. + +Runtime Version Selection ------------------------- -Vhost configs -~~~~~~~~~~~~~~~~~~ +Runtime selection remains explicit and version-specific. + +For PHP, the selected version produces:: + + localdevstack-php: + +For Node, the selected version produces:: + + localdevstack-node: + +Both runtime families use Alpine variants. The version selector is intentionally not +replaced by the moving infrastructure-image policy. + +Generated State +--------------- + +Active vhosts are Docker-managed state: + +- Nginx vhosts persist in ``NginxHosts``; +- Apache vhosts persist in ``ApacheHosts``; +- PHP-FPM pool state persists in ``FPMPools``; +- PHP-FPM sockets use ``FPMSocks``; +- generated runtime Compose fragments are written under configuration/compose/. + +There is no active host-side ``configuration/nginx`` source of truth. + +List Domains +------------ + +List persisted Nginx domains:: + + lds domain ls + +Domain listing reads the NginxHosts named volume through ``server-tools``. + +Remove a Domain +--------------- + +Use:: + + lds domain rm + +or pass arguments supported by the underlying Tools removal flow:: + + lds domain rm + +LocalDevStack delegates removal to Tools ``rmhost``, removes any generated server profile +reported by that operation, resets temporary removal state, and recreates the stack. + +The legacy command group remains available:: + + lds host add + lds host rm + lds host list + +but ``domain`` is the canonical interface. + +Routing +------- + +LocalDevStack uses Docker DNS/service names instead of fixed bridge addresses. Generated +HTTP configuration routes to logical runtime service names or PHP-FPM sockets. + +Nginx is always the host-facing front door. Apache is always available as an alternate +backend for domains that choose that mode. + +The three logical networks remain ``Frontend``, ``Backend``, and ``DataStore`` while +Docker chooses their address ranges dynamically. + +TLS +--- + +When HTTPS is selected, Tools refreshes the shared LocalDevStack certificate set. + +The certificate SAN set includes at least:: + + localhost + *.localhost + 127.0.0.1 + ::1 + +and Tools can include generated domain/service names. + +The wildcard covers built-in convenience endpoints such as ``admin.localhost``, +``webmail.localhost``, and ``llm-ollama.localhost``. + +Working in a Domain Container +----------------------------- + +Resolve a domain to its application/runtime container and open a shell:: + + lds core project.localhost + +When no domain is supplied, ``lds core`` lists known domains and prompts on an +interactive terminal. + +A direct container name can also be supplied to ``lds core``. + +For a generic container shell/command flow use:: -Writes generated vhost files: + lds cli + lds cli -- Nginx vhost: - ``configuration/nginx/.conf`` +Diagnostics +----------- -- Apache vhost (only when Apache mode is selected): - ``configuration/apache/.conf`` +Validate the effective stack:: -TLS handling (HTTPS) -~~~~~~~~~~~~~~~~~~~~ + lds config validate -If you select HTTPS in the wizard, after writing the HTTPS config; -this generates/refreshes certificates for all known hosts. +Inspect a domain end to end:: -See: :doc:`tls-and-certificates` + lds support trace project.localhost -Node apps (optional) -~~~~~~~~~~~~~~~~~~~~ +The trace checks DNS, TLS, HTTP timing, generated Nginx upstream configuration, and +recent Nginx logs. -If you choose **NodeJs** app type: +Additional probes include:: -- It generates a Node compose fragment: + lds diag dns project.localhost + lds diag tls project.localhost + lds diag http https://project.localhost - ``docker/extras/.yaml`` +Convenience Commands +-------------------- -The token is derived from the domain (slugified). -This compose fragment defines a Node service (internal port is always ``3000``) and sets a profile like: +List active built-in URLs:: -- ``node_`` + lds urls -Tips ----- +Open a known UI/domain:: -- Prefer a consistent domain scheme (e.g., ``project.localhost``) so your routing stays predictable. -- After any vhost/cert changes, ``lds`` will run ``lds http reload`` automatically as part of setup; - you can also run it manually when you edit configs yourself. + lds open admin + lds open mail + lds open db + lds open redis + lds open mongo + lds open kibana + lds open ai + lds open project.localhost diff --git a/docs/guides/local-ai.rst b/docs/guides/local-ai.rst new file mode 100644 index 00000000..9c8c76c3 --- /dev/null +++ b/docs/guides/local-ai.rst @@ -0,0 +1,414 @@ +Local AI +======== + +LocalDevStack can enable one local LLM provider through the optional ``ai`` profile. + +Provider Selection +------------------ + +Exactly one provider service is active for a LocalDevStack runtime: + +.. code-block:: text + + supported AMD XDNA2 NPU -> llm-fastflow -> infocyph/llm-fastflow:latest + NVIDIA GPU -> llm-ollama -> infocyph/llm-ollama:latest + AMD ROCm GPU -> llm-ollama -> infocyph/llm-ollama:amd-latest + otherwise -> llm-ollama -> infocyph/llm-ollama:latest + +``llm-fastflow`` and ``llm-ollama`` are mutually exclusive. They are not designed to run +simultaneously for one stack. The selected service owns the common Docker-network alias +``llm`` and the normalized internal LLM port ``11434``. + +Enable and Inspect AI +--------------------- + +.. code-block:: bash + + lds profiles add ai + lds start + lds llm runtime + lds llm provider + lds images + lds doctor + +Provider-neutral clients should use: + +.. code-block:: text + + Docker network: http://llm:11434/v1 + HTTPS: https://llm.localhost/v1 + host loopback: http://127.0.0.1:11434/v1 + +Provider-specific diagnostic/native routes remain available: + +.. code-block:: text + + https://llm-ollama.localhost + https://llm-fastflow.localhost + +The inactive provider-specific route normally returns ``502`` because its service is absent. + +Runtime Detection +----------------- + +Automatic detection prefers: + +1. a FastFlow-supported XDNA2 NPU; +2. usable NVIDIA via ``nvidia-smi``; +3. AMD ROCm when both ``/dev/kfd`` and ``/dev/dri`` exist; +4. CPU. + +Override detection explicitly when needed: + +.. code-block:: bash + + lds llm runtime auto + lds llm runtime npu + lds llm runtime nvidia + lds llm runtime amd + lds llm runtime cpu + +``auto`` clears the explicit runtime and returns to host detection. + +FastFlow / NPU +-------------- + +FastFlow is selected only for ``npu``. The host must provide a supported XDNA2 NPU at +``/dev/accel/accel0`` with the compatible ``amdxdna`` host-driver/firmware contract. + +LocalDevStack configures: + +.. code-block:: text + + image: infocyph/llm-fastflow:latest + LLM_FASTFLOW_MODEL= + FLM_MODEL_PATH=/models + FLM_SERVE_PORT=11434 + FLM_HOST=0.0.0.0 + FLM_CORS=0 + FLM_DISABLE_UPDATE_CHECK=1 + +The container receives the NPU device plus unlimited memlock. It does not install the +kernel driver, require privileged mode, or receive the Docker socket. + +FastFlow model data persists in ``LLMFastFlowModels`` mounted at ``/models``. + +Ollama +------ + +Ollama owns CPU, NVIDIA and AMD ROCm paths: + +.. code-block:: text + + cpu -> infocyph/llm-ollama:latest + nvidia -> infocyph/llm-ollama:latest + amd -> infocyph/llm-ollama:amd-latest + +NVIDIA receives an ephemeral ``gpus: all`` Compose augmentation. AMD receives ephemeral +``/dev/kfd`` and ``/dev/dri`` mappings. On an AMD CPU with the AMD runtime, the derived +``LDS_AI_IGPU_ENABLE`` value may be ``1`` and is forwarded as ``OLLAMA_IGPU_ENABLE``. + +Ollama state persists in ``LLMModels`` mounted at ``/root/.ollama``. + +Model Defaults +-------------- + +Provider defaults are different: + +.. code-block:: text + + FastFlow / NPU -> qwen3.5:9b + Ollama -> qwen3:14b + +New setup leaves ``LDS_AI_MODEL`` blank so the selected provider default can apply. +An explicit ``LDS_AI_MODEL`` in ``docker/.env`` overrides whichever provider is active. +Because the providers may publish different model names, keep this blank when relying on +automatic runtime switching unless the override exists in both providers. + +Thinking Control +---------------- + +Thinking uses one provider-neutral LocalDevStack switch: + +.. code-block:: bash + + lds llm think # prints auto/on/off + lds llm think auto # provider/model default + lds llm think on + lds llm think off + +The persisted setting is ``LDS_AI_THINK``, which LocalDevStack maps to the common +provider variable ``LLM_THINK`` for both Ollama and FastFlow. Normal developer +commands inherit that setting. Strict structured-output paths such as ``json`` and +``lds graphify`` force thinking off so reasoning cannot displace the required JSON +payload. + +Provider CLI +------------ + +Always invoke provider commands through ``lds llm``: + +.. code-block:: bash + + lds llm models + lds llm pull + lds llm run + lds llm ask "Explain dependency injection briefly" + lds llm chat + lds llm prompt "Summarize this" + lds llm code "Implement this function" + lds llm review file.php + lds llm json "Return one JSON object" + lds llm api /v1/models + lds llm version + +Provider-specific low-level commands are guarded: + +.. code-block:: text + + Ollama-only: ps, show, unload, ollama + FastFlow-only: validate, check, flm + +LocalDevStack refuses those commands when the other provider is active. + +Tools Consumer +-------------- + +``server-tools`` uses only the common provider-neutral contract: + +.. code-block:: text + + LDS_AI_PROVIDER=llm + LDS_AI_URL=http://llm:11434 + LDS_AI_MODEL= + +Therefore ``lds ai`` commands do not need to know which provider owns ``llm``: + +.. code-block:: bash + + lds ai status + lds ai ask "Explain this error" + lds ai troubleshoot + lds ai review + lds ai repo-review + +Provider Options +---------------- + +Ollama-specific settings remain available through ``docker/.env``: + +.. code-block:: text + + LLM_OLLAMA_SYSTEM= + LLM_OLLAMA_INPUT_WARN_BYTES=1048576 + LLM_OLLAMA_INPUT_MAX_BYTES=0 + LLM_OLLAMA_ATTACHMENT_MAX_BYTES=16777216 + LLM_OLLAMA_ATTACHMENTS_MAX_BYTES=33554432 + LLM_OLLAMA_ATTACHMENT_MAX_COUNT=16 + LLM_OLLAMA_PDF_MAX_PAGES=24 + LLM_OLLAMA_PDF_DPI=120 + LLM_OLLAMA_ALLOW_LARGE_INPUT=0 + OLLAMA_NUM_PARALLEL=1 + OLLAMA_MAX_LOADED_MODELS=1 + OLLAMA_KEEP_ALIVE=5m + OLLAMA_NO_CLOUD=1 + +FastFlow developer-input limits may also be overridden: + +.. code-block:: text + + LLM_FASTFLOW_INPUT_WARN_BYTES=1048576 + LLM_FASTFLOW_INPUT_MAX_BYTES=0 + LLM_FASTFLOW_ATTACHMENT_MAX_BYTES=16777216 + LLM_FASTFLOW_ATTACHMENTS_MAX_BYTES=33554432 + LLM_FASTFLOW_ATTACHMENT_MAX_COUNT=16 + LLM_FASTFLOW_PDF_MAX_PAGES=24 + LLM_FASTFLOW_PDF_DPI=120 + LLM_FASTFLOW_ALLOW_LARGE_INPUT=0 + +Nginx +----- + +Nginx owns the fixed loopback publication: + +.. code-block:: text + + 127.0.0.1:11434 -> nginx:11434 -> llm:11434 + +The provider containers themselves do not publish host ports. ``LDS_AI_TIMEOUT`` defaults +to ``1800`` seconds and is forwarded as ``LLM_PROXY_TIMEOUT_SECONDS``. + +Compose Ownership +----------------- + +Both provider definitions live in ``docker/compose/companion.yaml``. Runtime-generated +profile selectors enable exactly one provider. There are no tracked ``ai.yaml``, +``ai-nvidia.yaml``, ``ai-amd.yaml`` or ``ai-host-port.yaml`` variants. + +Only Ollama NVIDIA/ROCm hardware augmentation is generated temporarily under +``docker/.runtime/``. The tracked Ollama image is +``infocyph/llm-ollama:latest``; the AMD override directly selects +``infocyph/llm-ollama:amd-latest``. +FastFlow's XDNA2 device/memlock contract is part of its tracked service definition. + +Graphify +-------- + +``lds graphify [path]`` uses the common LocalDevStack endpoint and selects the +Graphify backend from the active provider. + +For the built-in LocalDevStack endpoint, Graphify runs through an ephemeral +provider definition created only for that invocation. The provider definition itself +is never written into the target repository or ``~/.graphify/providers.json``. + +Local Graphify runs always use a localhost structured-output compatibility proxy. +The temporary provider points to ``127.0.0.1:/v1``, and the proxy +forwards requests to ``http://llm.localhost:11434/v1``. Community-label requests +pass through unchanged; semantic-extraction requests use the active provider's native +structured-output mechanism. + +FastFlow / NPU: + +.. code-block:: text + + backend=lds-fastflow + model= + extra_body={"think": false} + +Ollama / CPU, NVIDIA or ROCm: + +.. code-block:: text + + backend=lds-ollama + model= + reasoning_effort=none + +The Ollama provider definition also keeps explicit context headroom for Graphify's +local chunks. Both local providers default to ``--token-budget 4000 +--max-concurrency 1`` unless the caller supplied those flags. These limits and the +no-thinking request are separate protections: the former prevents local context/resource +pressure, while the latter keeps reasoning out of the structured response channel. + +Structured extraction is provider-specific: + +* FastFlow / Qwen3.5 uses native tool calling with a single ``submit_graph`` + function whose arguments follow Graphify's node/edge/hyperedge schema. The proxy + requests FastFlow in streaming mode and stops reading as soon as FastFlow emits + the completed ``tool_calls`` delta, then converts that call into the normal + non-stream assistant JSON content Graphify expects. This avoids waiting for model + EOS on FastFlow's non-stream path. FastFlow currently ignores OpenAI + ``response_format`` on its chat-completions path, so tool calling is the + supported structured channel. +* Ollama uses its OpenAI-compatible ``response_format.type=json_schema`` path with + the same Graphify schema and ``temperature=0``. Ollama maps that schema to its + native structured-output ``format`` field. + +A structurally valid all-empty graph remains valid and is passed back to Graphify +unchanged; Graphify then decides whether to retry it as a hollow extraction. + +Structured generations are deliberately bounded independently of Graphify's larger +general output allowance. LocalDevStack caps a structured extraction at 2048 output +tokens and defaults each structured request to a 120-second timeout +(``LDS_GRAPHIFY_STRUCTURED_TIMEOUT``). This is important for FastFlow because its +Qwen3.5 non-stream tool parser recognizes ```` only after generation +finishes. The LDS FastFlow adapter therefore consumes the streaming parser instead; +the 2048-token/120-second bounds remain the safety ceiling if no complete tool call +arrives. + +LocalDevStack deliberately performs exactly one provider-native structured request +per Graphify extraction attempt. It does not add its own structured retry or +free-form fallback chain. If the provider times out or returns an unusable structured +response, the proxy returns a bounded ``finish_reason=length`` signal so Graphify +can split the offending chunk through its existing adaptive-retry logic. + +For local providers, hidden retry amplification is bounded at both outer layers: +the OpenAI SDK retry count defaults to zero (``LDS_GRAPHIFY_SDK_RETRIES=0``) and +Graphify's adaptive retry depth defaults to one +(``LDS_GRAPHIFY_MAX_RETRY_DEPTH=1``). Explicit +``GRAPHIFY_MAX_RETRIES`` / ``GRAPHIFY_MAX_RETRY_DEPTH`` values still win. + +Detailed suspect-response logging is optional and does not control the compatibility +proxy. Enable it with: + +.. code-block:: bash + + LDS_GRAPHIFY_DIAGNOSTICS=1 lds graphify . + +When enabled, a bounded assistant-content preview is printed and the full suspect +assistant response is stored as JSON Lines in: + +.. code-block:: text + + /graphify-out/lds-graphify-diagnostics.jsonl + +The diagnostic record contains request controls such as model, ``think``, +``reasoning_effort``, finish reason, and token usage, but never stores the Graphify +prompt or source corpus. ``LDS_GRAPHIFY_DIAGNOSTIC_PREVIEW`` controls the terminal +preview size (minimum 256, default 4096). ``LDS_GRAPHIFY_DIAGNOSTIC_LOG`` overrides +the JSONL path. + +FastFlow Graphify still defaults to ``LDS_GRAPHIFY_THINK=off``. The current +FastFlow Qwen3.5 non-stream parser can leave ``...`` text inside +``message.content``, so thinking is intentionally kept off for Graphify's +structured extraction path. ``LDS_GRAPHIFY_THINK=on`` and +``LDS_GRAPHIFY_THINK=auto`` remain diagnostic overrides, not recommended defaults. + +Before extraction, LocalDevStack checks ``/v1/models`` and fails fast when the +selected model is absent. + +When ``/graphify-out/graph.json`` already exists, ``lds graphify`` keeps +using Graphify's lower-level ``extract`` pipeline, which automatically switches to +incremental mode: only changed code/docs/papers/images are re-extracted, deleted or +excluded sources are reconciled, and the result is merged into the existing graph. +This is intentionally preferred over the literal ``graphify update`` CLI command, +because current Graphify ``update`` refreshes code only and delegates semantic +document refreshes to the assistant update workflow. Pass ``--force`` only when a +full rebuild is intentionally required. + +Override the local chunk defaults with explicit Graphify flags, or set +``LDS_GRAPHIFY_TOKEN_BUDGET`` / ``LDS_GRAPHIFY_MAX_CONCURRENCY``. If a local +model reports ``Max length reached!``, reduce the token budget further, for example: + +.. code-block:: bash + + lds graphify . --token-budget 3000 --max-concurrency 1 + +Explicit ``OPENAI_BASE_URL`` (FastFlow path) or ``OLLAMA_BASE_URL`` (Ollama path) +remains caller-controlled and bypasses the local-provider preflight. Extraction still +uses ``--no-cluster`` followed by ``cluster-only`` so clustering occurs once. + +Trust Boundary +-------------- + +Provider images have: + +- no Docker socket; +- no project/repository bind mount by default; +- no automatic execution of model-generated shell commands, SQL or code; +- no silent cloud fallback in the common Tools AI client. + +Repository-aware analysis should normally flow through Tools or an explicit provider +workspace override. + +Platform Availability +--------------------- + +FastFlow is currently a ``linux/amd64`` XDNA2 runtime. LocalDevStack remains usable with +the ``ai`` profile disabled on unsupported platforms. + +Troubleshooting +--------------- + +.. code-block:: bash + + lds llm runtime + lds llm provider + lds doctor + lds logs llm + curl -fsS http://127.0.0.1:11434/v1/models | jq + +Use ``lds logs llm-fastflow`` or ``lds logs llm-ollama`` only when you explicitly +want the provider-specific service identity. + +If FastFlow was expected but not selected, verify ``/dev/accel/accel0`` and the host +``amdxdna`` driver before forcing ``lds llm runtime npu``. diff --git a/docs/guides/notifications.rst b/docs/guides/notifications.rst index 7e8214af..5f1d0137 100644 --- a/docs/guides/notifications.rst +++ b/docs/guides/notifications.rst @@ -1,322 +1,81 @@ Notifications ============= -LocalDevStack can optionally emit notifications from containers to the host (non-Windows). +LocalDevStack can forward notification events emitted inside the trusted Tools container +to the host desktop or terminal. -The idea is simple: +Host Watcher +------------ -- Your host runs a watcher that listens for notification events. -- Containers fire-and-forget messages using a tiny client binary (``docknotify``). -- The watcher turns those events into desktop notifications (toast / notify-send / etc.). +Start the watcher:: -Host usage (non-Windows) ------------------------- + lds notify watch -Start watching (recommended during development):: +or through the grouped support command:: - lds notify watch + lds support notify watch -Send a one-off test notification:: +By default the watcher follows the current project's running ``server-tools`` container. - lds notify test "T" "B" +An explicit container can be supplied for advanced/debug use:: -From inside containers (docknotify) ------------------------------------ + lds notify watch -Inside LocalDevStack containers, trigger a notification by calling ``docknotify``:: +Test the path:: - docknotify -t 2500 -u normal some_title some_body >/dev/null 2>&1 & + lds notify test "LocalDevStack" "Notification channel works" -Options: +Host Behavior +------------- -- ``-t``: timeout in milliseconds (example: ``2500``) -- ``-u``: urgency (example: ``low``, ``normal``, ``critical``) -- The final two arguments are: ``title`` and ``body`` +Linux / WSLg + Uses ``notify-send`` when available. -The redirection + ``&`` makes it fire-and-forget so it never blocks your request or job. +Windows/Git Bash or compatible WSL environment + Uses ``powershell.exe`` to create a Windows toast. -Common pattern --------------- +Other environments + Falls back to a timestamped terminal message when no supported desktop notifier is + available. -- You keep ``lds notify watch`` running on the host. -- Your apps/services inside containers call ``docknotify`` when something noteworthy happens - (errors, deploy events, background jobs, long tasks, etc.). +The watcher reconnects if the Docker log stream ends while the container remains +running. It exits when the watched container stops. -Message format --------------- +Container-side Events +--------------------- -Notifications are transmitted as a single-line payload (safe for log streaming and easy parsing). -Implementations typically use a tab-separated payload like: +Tools-side notification helpers emit a line beginning with:: -- token -- timeout -- urgency -- source -- title -- body + __HOST_NOTIFY__ -This is intentionally simple: it survives log streaming and is easy to parse reliably. +The host watcher consumes those log events and supports timeout, urgency, title, and +body fields. -PHP example: forward all PHP errors to notifications ----------------------------------------------------- +Normal urgency values are:: -Below is a minimal helper you can drop into any PHP project to log everything to a file and optionally -emit desktop notifications via ``docknotify`` (when running inside LocalDevStack containers). + low + normal + critical -.. code-block:: php +Application Helpers +------------------- - /dev/null 2>&1 & - $notifyFn = static function (string $title, string $body) use ($notify): void { - if (!$notify) { - return; - } +Applications should treat notification delivery as optional observability, not as a +critical execution dependency. - // Require docknotify in PATH - $bin = \trim((string)@\shell_exec('command -v docknotify 2>/dev/null')); - if ($bin === '') { - return; - } +Operational Notes +----------------- - // Keep short + safe; remove newlines/tabs to keep one-line protocol stable - $title = (string)(\preg_replace('/\s+/', ' ', $title) ?? 'PHP Error'); - $body = (string)(\preg_replace('/\s+/', ' ', $body) ?? ''); - - $title = \substr($title, 0, 80); - $body = \substr($body, 0, 220); - - // Escape args (no injection) - $t = \escapeshellarg($title); - $b = \escapeshellarg($body); - - // Send as "normal" urgency, 2500ms timeout; fire-and-forget - @\shell_exec($bin . ' -t 2500 -u normal ' . $t . ' ' . $b . ' >/dev/null 2>&1 &'); - }; - - $map = [ - E_ERROR => 'E_ERROR', - E_WARNING => 'E_WARNING', - E_PARSE => 'E_PARSE', - E_NOTICE => 'E_NOTICE', - E_CORE_ERROR => 'E_CORE_ERROR', - E_CORE_WARNING => 'E_CORE_WARNING', - E_COMPILE_ERROR => 'E_COMPILE_ERROR', - E_COMPILE_WARNING => 'E_COMPILE_WARNING', - E_USER_ERROR => 'E_USER_ERROR', - E_USER_WARNING => 'E_USER_WARNING', - E_USER_NOTICE => 'E_USER_NOTICE', - E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', - E_DEPRECATED => 'E_DEPRECATED', - E_USER_DEPRECATED => 'E_USER_DEPRECATED', - ]; - - // Log non-fatal errors (warnings/notices/deprecations, etc.) - \set_error_handler( - static function (int $severity, string $message, string $file, int $line) use ($logFile, $map, $notifyFn): bool { - // Respect @ suppression - if (!(error_reporting() & $severity)) { - return true; - } - - $label = $map[$severity] ?? ('E_' . (string)$severity); - $ts = \date('Y-m-d H:i:s'); - - @\file_put_contents( - $logFile, - $ts . ' [' . $label . '] ' . $message . ' in ' . $file . ':' . $line . PHP_EOL, - FILE_APPEND | LOCK_EX - ); - - $notifyFn($label, $message . ' (' . \basename($file) . ':' . $line . ')'); - - // We handled it; do not let PHP print/log elsewhere - return true; - } - ); - - // Log uncaught exceptions / TypeErrors, etc. - \set_exception_handler( - static function (\Throwable $e) use ($logFile, $notifyFn): void { - $ts = \date('Y-m-d H:i:s'); - $type = \get_class($e); - - $msg = $ts - . ' [UNCAUGHT ' . $type . '] ' - . $e->getMessage() - . ' in ' . $e->getFile() . ':' . $e->getLine() - . PHP_EOL - . $e->getTraceAsString() - . PHP_EOL; - - @\file_put_contents($logFile, $msg . PHP_EOL, FILE_APPEND | LOCK_EX); - - $notifyFn( - 'UNCAUGHT ' . $type, - $e->getMessage() . ' (' . \basename($e->getFile()) . ':' . $e->getLine() . ')' - ); - - exit(255); - } - ); - - // Log fatal errors (E_ERROR, E_PARSE, E_COMPILE_ERROR, etc.) - \register_shutdown_function( - static function () use ($logFile, $notifyFn, $map): void { - $err = \error_get_last(); - if ($err === null) { - return; - } - - $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; - $type = (int)($err['type'] ?? 0); - - if (!\in_array($type, $fatalTypes, true)) { - return; - } - - $label = $map[$type] ?? ('E_' . (string)$type); - $ts = \date('Y-m-d H:i:s'); - - $message = (string)($err['message'] ?? ''); - $file = (string)($err['file'] ?? ''); - $line = (int)($err['line'] ?? 0); - - @\file_put_contents( - $logFile, - $ts . ' [' . $label . '] ' . $message . ' in ' . $file . ':' . $line . PHP_EOL, - FILE_APPEND | LOCK_EX - ); - - $notifyFn($label, $message . ' (' . \basename($file) . ':' . $line . ')'); - } - ); - - // Ensure nothing is printed to screen by PHP itself - \ini_set('display_errors', '0'); - \ini_set('log_errors', '0'); - } - - // Usage: - $log = __DIR__ . '/php-upg-err-' . \date('Ymd') . '.log'; - registerAllErrorsToFile($log, true); - -Node.js example: send notifications using docknotify ----------------------------------------------------- - -LocalDevStack ships ``docknotify`` inside Node containers too, so Node apps can emit host notifications -without extra dependencies. - -Below is a small helper that: - -- checks ``docknotify`` exists -- strips newlines/tabs (keeps the one-line protocol stable) -- sends a fire-and-forget notification (non-blocking) - -.. code-block:: js - - // docknotify.js - const { spawnSync, spawn } = require("node:child_process"); - - // Cache existence check so we don't run it per request - const HAS_DOCKNOTIFY = (() => { - const r = spawnSync("sh", ["-lc", "command -v docknotify >/dev/null 2>&1"], { stdio: "ignore" }); - return r.status === 0; - })(); - - function clean(s, max) { - return String(s ?? "") - .replace(/[\t\r\n]+/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, max); - } - - function notify(title, body, { timeout = 2500, urgency = "normal" } = {}) { - if (!HAS_DOCKNOTIFY) return; - - const t = clean(title, 80) || "Node"; - const b = clean(body, 220); - - // Fire-and-forget: no stdout/stderr, detached, unref - const child = spawn( - "docknotify", - ["-t", String(timeout), "-u", urgency, t, b], - { stdio: "ignore", detached: true } - ); - - child.on("error", () => {}); - child.unref(); - } - - module.exports = { notify }; - -.. code-block:: js - - // example usage (Express) - const express = require("express"); - const { notify } = require("./docknotify"); - - const app = express(); - - process.on("unhandledRejection", (err) => { - notify("Unhandled Rejection", err?.stack || String(err)); - }); - - process.on("uncaughtException", (err) => { - notify("Uncaught Exception", err?.stack || String(err), { urgency: "critical" }); - // process.exit(1); - }); - - app.get("/", (req, res) => res.json({ ok: true })); - - // test route - app.get("/boom", () => { - throw new Error("Test error from /boom"); - }); - - // eslint-disable-next-line no-unused-vars - app.use((err, req, res, next) => { - notify("Express Error", `${err.message} (${req.method} ${req.originalUrl})`); - res.status(500).json({ error: "Internal Server Error" }); - }); - - const port = process.env.PORT || 3000; - app.listen(port, () => { - notify("Node Started", `Listening on :${port}`, { timeout: 1500, urgency: "low" }); - }); - -Quick test -~~~~~~~~~~ - -1. On host, start watcher:: - - lds notify watch - -2. Trigger the test route:: - - curl -sS http://your-domain.localhost/boom >/dev/null - -Practical workflow ------------------- - -1. On your host, keep this running in a terminal:: - - lds notify watch - -2. In your PHP/Node apps (inside containers), trigger ``docknotify`` on important events - (errors, failed jobs, timeouts, etc.). - -This gives you immediate feedback without tailing logs all day. +- The watcher requires the target container to be running. +- Docker remains a host-side requirement. +- Desktop delivery depends on the host session and notifier availability. +- Notification payloads travel through container logs; avoid placing credentials, + tokens, or other sensitive values in titles/bodies. +- Ctrl+C stops the watcher and returns the normal interrupted exit status. diff --git a/docs/guides/operations-and-support.rst b/docs/guides/operations-and-support.rst new file mode 100644 index 00000000..24146f5d --- /dev/null +++ b/docs/guides/operations-and-support.rst @@ -0,0 +1,249 @@ +Operations and Support +====================== + +This guide covers routine stack operations, upgrades, diagnostics, support bundles, and +cleanup boundaries. + +Stack Lifecycle +--------------- + +Start in the foreground/default Compose mode:: + + lds up + +Start detached:: + + lds start + +Stop/remove Compose containers while preserving named volumes:: + + lds down + +Restart the entire stack:: + + lds restart + +Restart only selected services:: + + lds restart nginx + lds restart nginx server-tools + +A service-specific restart uses Compose ``restart`` and does not perform the full +stop/start path. + +Status and Logs +--------------- + +:: + + lds status + lds ps + lds logs + lds logs nginx + lds logs nginx --follow + lds logs nginx --since 10m + lds logs nginx --grep project.localhost + +``lds status`` delegates the richer status view to Tools. ``lds ps`` uses Compose +directly. + +Execute a command in a service:: + + lds stack exec nginx nginx -t + +Open an interactive shell when no command is supplied:: + + lds stack exec nginx + +Stack Diff +---------- + +Compare desired Compose images with running container images:: + + lds stack diff + +Show the effective config alongside the diff:: + + lds stack diff --config + +Machine-readable diff output requires ``jq``:: + + lds stack diff --json + +Rebuild and Refresh +------------------- + +A normal restart does not intentionally pull newer moving tags. + +Refresh/recreate selected services with:: + + lds rebuild nginx + lds rebuild nginx runner + +With no arguments, ``lds rebuild`` presents an interactive service selector. +``lds rebuild all`` resolves all active Compose services. + +For locally built runtime services, normal Docker build cache is preserved while +``--pull`` refreshes the selected base. + +For published images, LocalDevStack removes the selected local image, pulls the resolved +service image, recreates it, and then reboots the effective stack. + +Legacy Network Migration +------------------------ + +Older LocalDevStack versions used fixed bridge subnets. + +``lds up`` and ``lds start`` automatically check the known legacy networks before +starting. Migration only proceeds when subnet and ownership checks prove that the +network belongs to the current LocalDevStack project. + +The migration uses Compose ``down --remove-orphans`` without ``-v`` and preserves named +volumes. + +The old command:: + + lds vpn-fix + +is deprecated and no longer changes host routes/subnets. + +Diagnostics +----------- + +Run the main non-destructive health check:: + + lds doctor + +Useful targeted probes:: + + lds diag dns project.localhost + lds diag net + lds diag tcp postgres 5432 + lds diag http https://project.localhost + lds diag tls project.localhost + +``lds sniff `` is an alias for the HTTP diagnostic path. + +Domain Trace +------------ + +Run an end-to-end domain trace:: + + lds support trace project.localhost + +The trace covers: + +- DNS; +- TLS certificate details; +- HTTP timing/status; +- persisted Nginx upstream inference; +- recent Nginx logs. + +Support Bundles +--------------- + +Create a shareable redacted ZIP in the current directory:: + + lds support bundle + +or choose an explicit path:: + + lds support bundle --redact ./localdevstack-support.zip + +The default bundle includes effective Compose configuration, Compose/container state, +networks, recent logs, generated Nginx/Apache vhosts where available, redacted env +files, and Tools-side network diagnostics. + +Redaction is best-effort and is designed to remove common password/secret/token/API-key +fields and secret-bearing connection strings. + +For the intentionally raw form:: + + lds support bundle --full + +``--full`` also includes scoped Docker inspect output and does not apply normal +redaction. Treat it as sensitive data. + +The host ``zip`` command is required for bundle creation. + +Events +------ + +Follow Docker events for the effective LocalDevStack Compose project:: + + lds events + +The default ``--since`` value is ``1h``. A custom value can be supplied:: + + lds events 10m + +Project Identity +---------------- + +The default Compose project identity is ``LocalDevStack``. + +An explicit ``COMPOSE_PROJECT_NAME`` override is respected by project-scoped +diagnostics, events, bundles, and cleanup. + +Cleanup +------- + +Scoped cleanup requires explicit confirmation:: + + lds clean --yes + +It targets stopped LocalDevStack project containers, unused labelled LocalDevStack +networks, and generated ``localdevstack-php:*`` / ``localdevstack-node:*`` images. + +Attempt LocalDevStack-labelled volume removal too:: + + lds clean --yes --volumes + +In-use volumes are left to Docker's normal safety rules. + +Host-wide Docker prune is deliberately separate:: + + lds clean --global --yes + +With ``--global``, unrelated stopped containers, unused networks/images, and build +cache can be removed. Add ``--volumes`` only when host-wide unused-volume pruning is +also intended. + +Destructive Compose Down +------------------------ + +Normal:: + + lds down + +preserves persistent data. + +This requires explicit confirmation and removes Compose volumes:: + + lds down --volumes --yes + +Do not use that destructive form for routine upgrades. + +Config Validation +----------------- + +:: + + lds config validate + +This validates the effective Compose graph and checks scheduler files for CRLF. If Runner +is already running, LocalDevStack also asks Supervisor to validate its mounted +configuration. + +Generated Compose fragments under ``configuration/compose/`` are listed when present. + +Tools UI +-------- + +Open the terminal Tools UI (lazydocker) inside the project control plane:: + + lds support ui + +or use the shortcut:: + + lds ui diff --git a/docs/guides/secrets-sops-age.rst b/docs/guides/secrets-sops-age.rst index bb137304..377b9edd 100644 --- a/docs/guides/secrets-sops-age.rst +++ b/docs/guides/secrets-sops-age.rst @@ -1,22 +1,74 @@ Secrets with SOPS and Age ========================= -LocalDevStack can integrate an encrypted secrets workflow using **SOPS** and **Age**. +LocalDevStack provides the host/storage wiring for a SOPS/Age-based encrypted secrets +workflow through the trusted Tools control plane. -The goal is simple: +The goal is to keep plaintext secrets out of Git while making deliberate local +decryption available to development workflows. -- Keep ``.env``-like secrets encrypted in Git -- Decrypt only when needed (locally) into runtime containers or build steps +Host Layout +----------- -Typical workflow (high level) ------------------------------ +LocalDevStack mounts these host paths into ``server-tools``: -1. Store secrets as ``*.enc.env`` (or similar) in a repo. -2. Keep Age private keys outside the repo (mounted into Tools container). -3. Use a helper (often called ``senv``) to decrypt into a target env file. +``configuration/sops/config/`` + SOPS configuration. -Safety notes ------------- +``configuration/sops/global/`` + Shared encrypted/global secret material. -- Prefer read-only mounts for secrets repos. -- Do not bake private keys into images. +``configuration/sops/keys/`` + Sensitive Age/key material. Unix setup permissions restrict this directory and its + files. + +An optional external secrets repository can be mounted through ``SOP_REPO`` and is +available to Tools under its SOPS/vhost integration path. + +CLI Access +---------- + +The LocalDevStack command:: + + lds secrets + +delegates directly to the Tools ``senv`` command inside the running ``server-tools`` +container. + +Use:: + + lds tools sh + +when you need to inspect the trusted Tools environment interactively before running a +manual SOPS/Age operation. + +Trust Boundary +-------------- + +SOPS/Age processing belongs in the trusted Tools control plane, not in ordinary +database, web, or AI provider containers. + +``server-tools`` already has powerful Docker access, so access to its shell and mounted +secret material should be treated as privileged workstation access. + +Safety Guidelines +----------------- + +- Keep Age private keys out of application repositories. +- Do not bake private keys into Docker images. +- Keep ``configuration/sops/keys/`` private. +- Prefer encrypted files in Git and decrypt only for the required local workflow. +- Review permissions after moving/copying key material. +- Do not place secret-bearing raw output into support bundles unless deliberately using + and protecting ``--full`` output. + +Unix Permissions +---------------- + +``lds setup permissions`` applies: + +- ``0700`` to directories under ``configuration/sops/keys/``; +- ``0600`` to files under ``configuration/sops/keys/``. + +This deliberately overrides the broader group-writable configuration-directory policy +for key material. diff --git a/docs/guides/tls-and-certificates.rst b/docs/guides/tls-and-certificates.rst index ffe9153b..1265ecc7 100644 --- a/docs/guides/tls-and-certificates.rst +++ b/docs/guides/tls-and-certificates.rst @@ -1,128 +1,157 @@ TLS and Certificates ==================== -LocalDevStack uses **mkcert-based local TLS** for development. - -At first run, it creates a **local Root CA** and issues development certificates. -After that, it scans your vhost configs and (re)generates certificates for all detected domains. - -Certificates are generated and persisted under the host-mounted ``configuration/`` tree so they survive rebuilds. - -Generated files (host) ----------------------- - -LocalDevStack persists TLS artifacts in this layout:: - - configuration/ - ├── rootCA - │ ├── rootCA-key.pem - │ └── rootCA.pem - └── ssl - ├── apache-client-key.pem - ├── apache-client.pem - ├── apache-server-key.pem - ├── apache-server.pem - ├── local-key.pem - ├── local.pem - ├── nginx-client-key.pem - ├── nginx-client.p12 - ├── nginx-client.pem - ├── nginx-proxy-key.pem - ├── nginx-proxy.pem - ├── nginx-server-key.pem - └── nginx-server.pem - -Notes: - -- ``configuration/rootCA/rootCA.pem`` is your local development CA certificate. -- The ``*-server*.pem`` pairs are used by Nginx/Apache for HTTPS. -- The ``*-client*.pem`` pairs are used when **mutual TLS** is enabled for a domain. -- ``nginx-client.p12`` is provided for convenient browser import when mutual TLS is enabled. - -Domain discovery ----------------- - -Certificate generation scans all ``*.conf`` files under the shared vhost directory (mounted from your host). -From those filenames/configs, LocalDevStack derives domain names and generates SAN certificates -covering all detected domains. - -This keeps certs aligned with your active vhost set: add/remove a domain, regenerate, done. - -Trusting the Root CA +LocalDevStack uses mkcert-based local TLS for development. Tools owns certificate +generation; the host ``lds`` CLI owns trust-store installation/removal. + +Runtime TLS State +----------------- + +The active runtime state is stored in Docker named volumes: + +``SSLRootCA`` + mkcert CA store exposed to trusted LocalDevStack consumers at + ``/etc/share/rootCA``. + +``SSLKeys`` + Generated server/client certificate material exposed at ``/etc/mkcert`` where + needed. + +Tools refreshes certificates through its ``certify`` command family. + +Public Host Export +------------------ + +Tools exports user-facing certificate artifacts to:: + + configuration/ssl/ + +The public root CA is:: + + configuration/ssl/rootCA.pem + +For upgrades, the legacy path remains a read fallback:: + + configuration/rootCA/rootCA.pem + +The public CA certificate is safe to install into the host trust store. The private CA +key is not intended as a public export. + +Optional password-protected user mTLS artifacts may also be exported under +``configuration/ssl/``. + +Certificate Coverage -------------------- -To trust your local CA on your host system, run:: +The generated SAN set includes at least:: - sudo lds certificate install + localhost + *.localhost + 127.0.0.1 + ::1 -This installs ``configuration/rootCA/rootCA.pem`` into your OS trust store (where supported). +Tools can also discover generated vhost/service domains. -Manual install is also possible: +The wildcard covers built-in endpoints such as: -- Import ``configuration/rootCA/rootCA.pem`` into your OS trust store using your system UI/tools. -- This is useful in locked-down environments where automated install is restricted. +- ``admin.localhost``; +- ``webmail.localhost``; +- ``db.localhost``; +- ``ri.localhost``; +- ``me.localhost``; +- ``kibana.localhost``; +- ``llm-ollama.localhost``. -Mutual TLS (Client certificates) --------------------------------- +Install the Root CA +------------------- -If you enable **mutual TLS** for any domain: +Linux:: -- You must install the client certificate in your browser. -- Recommended: import ``configuration/ssl/nginx-client.p12`` into the browser certificate store. + sudo lds certificate install -After importing, the browser will present the client certificate when accessing mTLS-protected domains. +The installer detects common Linux families and uses their normal trust-store location/ +refresh mechanism where available: -Uninstalling the Root CA ------------------------- +- Debian/Ubuntu-style ``update-ca-certificates``; +- RHEL/Fedora-style ``update-ca-trust``; +- Arch-style p11-kit/trust handling. -If you previously trusted the LocalDevStack Root CA and want to remove it from your system trust store, use:: +When ``certutil`` is available, the invoking user's NSS database is also updated on +supported Unix flows. - sudo lds certificate uninstall +Windows/Git Bash:: -This removes the installed CA file from the detected OS trust anchor location and then refreshes the system trust store -(best-effort). + lds.bat certificate install -Remove from all known locations (cleanup mode) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Windows imports the CA into ``CurrentUser\\Root`` through PowerShell. -If you changed distros, moved CA paths, or installed it manually in different locations, use:: +macOS + LocalDevStack can run through Docker Desktop, but automatic macOS Keychain import is + not currently implemented by the host installer. Trust + ``configuration/ssl/rootCA.pem`` manually in Keychain when required. - sudo lds certificate uninstall --all +Restart browsers that cache trust results after changing the CA. -This additionally scans common CA anchor locations and removes any leftover ``rootCA`` entries it finds, then refreshes -the trust store. +Uninstalling +------------ -Notes -~~~~~ +Linux:: -- Uninstall requires sudo/admin privileges. -- Trust store refresh is best-effort; on uncommon distributions you may need to refresh trust manually after removal. -- This only removes the *installed* OS trust anchor. It does not delete your generated CA files under - ``configuration/rootCA`` (those are part of your project persistence). + sudo lds certificate uninstall +Scan/remove all known LocalDevStack anchor locations too:: + + sudo lds certificate uninstall --all + +Windows/Git Bash:: + + lds.bat certificate uninstall + +The uninstall operation removes the host trust anchor. It does not delete the +``SSLRootCA`` / ``SSLKeys`` Docker volumes. + +Tools Certificate Commands +-------------------------- + +The Tools certificate workflow is exposed separately through:: + + lds cert status + lds cert regen all + lds cert diagnose project.localhost + +``lds certificate ...`` manages host trust; ``lds cert ...`` delegates certificate +generation/status/diagnostics to Tools. + +Mutual TLS +---------- + +When a domain enables mutual TLS, browser/user certificate material must be imported +separately. User-facing P12/PFX exports are intentionally opt-in and password-protected. + +Internal Nginx-to-Apache mTLS material remains runtime state and is not the same as a +user-facing browser certificate. + +Permissions +----------- + +``lds setup permissions`` keeps public CA exports readable while applying restrictive +permissions to exported private-key-style files (including P12/PFX/key artifacts). Troubleshooting --------------- -Browser still shows “Not Secure” -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -- Confirm the Root CA is trusted: +Check TLS readiness:: - - Run ``lds certificate install`` again, or - - Manually install ``configuration/rootCA/rootCA.pem`` into your OS trust store. + lds doctor -- Restart the browser after installing the CA (some browsers cache trust decisions). +Inspect a domain handshake:: -Certificate mismatch after changing domains -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + lds diag tls project.localhost -- If you renamed/removed domains, regenerate certs so SANs match the current vhost set. -- Ensure the vhost files under ``configuration/nginx`` / ``configuration/apache`` reflect the current domains. +Run the end-to-end trace:: -Mutual TLS enabled but browser doesn’t prompt / request fails -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + lds support trace project.localhost -- Import the client cert (recommended): ``configuration/ssl/nginx-client.p12``. -- Verify the client cert is imported into the *correct* browser profile. -- If you have multiple client certs, remove old ones and retry to avoid wrong-certificate selection. +If the public export is missing, ensure ``server-tools`` is running and certificate +generation has completed. The current export should appear at +``configuration/ssl/rootCA.pem``. diff --git a/docs/index.rst b/docs/index.rst index 37c7cc3b..f496b401 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,11 +1,12 @@ LocalDevStack Documentation -========================== +=========================== -LocalDevStack is a modular, Docker-based local development stack designed to replace traditional local bundles -(XAMPP/MAMP/LAMP) with a reproducible, profile-driven setup. +LocalDevStack is a Docker-based XAMPP alternative for PHP and Node.js local development. +It combines local domains, trusted TLS, selectable runtimes, databases and admin tools, +background jobs, developer utilities, diagnostics, and optional local AI behind the +``lds`` CLI. -It is built around a small orchestrator (the ``lds`` CLI + Compose profiles) and a set of purpose-built images -that work together (tools, HTTP, runner). +LocalDevStack is designed for trusted developer workstations, not production deployment. .. toctree:: :maxdepth: 2 @@ -26,6 +27,16 @@ that work together (tools, HTTP, runner). :caption: Guides guides/domain-setup + guides/databases-and-clients guides/tls-and-certificates + guides/local-ai + guides/operations-and-support + guides/ad-hoc-runner guides/secrets-sops-age guides/notifications + +.. toctree:: + :maxdepth: 2 + :caption: Reference + + reference/cli diff --git a/docs/plans/docker-ecosystem/00-master-bottom-up-plan.md b/docs/plans/docker-ecosystem/00-master-bottom-up-plan.md new file mode 100644 index 00000000..2f54aba4 --- /dev/null +++ b/docs/plans/docker-ecosystem/00-master-bottom-up-plan.md @@ -0,0 +1,269 @@ +# LocalDevStack Docker Ecosystem — Bottom-Up Development Plan + +## Status + +Planning branch: `plan/docker-ecosystem-bottom-up` + +Lower-layer work is now complete and published. The active phase is LocalDevStack product integration. + +Completed/published baseline as of 2026-09-18: + +- Shared foundations: Scriptomatic hardened on `main`, Toolset `2.0` +- `infocyph/runner:0.5` +- `infocyph/nginx:0.4.1` +- `infocyph/apache:0.4.2` +- `infocyph/tools:0.23.2` +- `infocyph/llm-ollama:0.03` / `amd-0.03` + +The authoritative active implementation plan is now `07-localdevstack-integration-plan.md`. Earlier 01–06 files remain historical planning records for the completed lower layers. + +## Product Definition + +LocalDevStack is a Docker-based local development platform for PHP and Node.js: a modern, modular XAMPP alternative with local domains/TLS, multiple PHP/Node runtimes, databases, admin UIs, mail, schedulers/workers, developer tools, and optional local AI. + +The system is intentionally split across repositories instead of becoming one monolithic image. + +## Repository Stack + +Bottom to top: + +1. `infocyph/Scriptomatic` — reusable bootstrap/entrypoint/setup scripts used while building developer/runtime images. +2. `infocyph/Toolset` — reusable standalone developer utilities consumed by images (`gitx`, `chromacat`, `sqlitex`, `netx`, etc.). +3. `infocyph/docker-runner` — background process infrastructure: Supervisor, cron, logrotate, helper execution. +4. `infocyph/docker-nginx` — primary HTTP/TLS edge and local routing. +5. `infocyph/docker-apache` — optional Apache backend for PHP/vhost compatibility. +6. `infocyph/docker-tools` — LocalDevStack control plane: domain/TLS/config generation, service/profile helpers, monitoring, admin panel, environment/secrets tooling. +7. `infocyph/docker-llm-ollama` — published optional local-AI capability; already follows the newer image publication model. +8. `infocyph/LocalDevStack` — product/orchestrator: `lds`, Compose topology, PHP/Node runtime Dockerfiles, generated configuration, user-facing workflow, platform integration. + +## Core Architectural Rules + +### Published infrastructure vs generated runtimes + +Published images remain independently versioned infrastructure: + +- `infocyph/runner` +- `infocyph/nginx` +- `infocyph/apache` +- `infocyph/tools` +- `infocyph/llm-ollama` + +PHP and Node remain locally generated runtime images because they are customized by selected runtime version, host UID/GID, extensions/packages, and project-level needs: + +- `localdevstack-php:` +- `localdevstack-node:` + +Do not convert PHP/Node into one-size-fits-all published images unless a later design proves that customization can be retained without increasing user complexity. + +### Image immutability contract + +For every published `docker-*` repository: + +- release event publishes `` and `latest`; +- release-version tags are immutable after first publication; +- scheduled rebuild checks out the latest published release source but refreshes `latest` only; +- source release and resulting image revision must be identifiable through OCI metadata/provenance; +- Docker Hub and GHCR should receive the same built digest for the same variant. + +`docker-llm-ollama` is the reference implementation for this contract. + +### Dependency reproducibility + +Do not fetch executable build dependencies from floating `main`, `master`, or `latest` URLs without an explicit reason. + +For Scriptomatic/Toolset files consumed during image builds: + +- prefer a release tag when a release lifecycle exists; +- otherwise pin an immutable commit SHA; +- expose the selected ref as an explicit build argument where useful; +- record the selected ref in image labels/build metadata; +- verify downloaded content when practical. + +Scheduled `latest` image rebuilds may consume updated base-image patches, but they must not mutate an already published version tag. + +### Network contract + +Service discovery should use Docker DNS/service names, not static container IPv4 addresses. + +The current `172.28.0.0/24`, `172.29.0.0/24`, and `172.30.0.0/24` fixed LocalDevStack subnets are orchestration details, not image contracts. No supporting `docker-*` repository currently requires those addresses. + +Migration target: + +- retain logical networks (`frontend`, `backend`, `datastore`); +- remove fixed `ipv4_address` declarations; +- remove hard-coded IPAM subnets/gateways after validation; +- use service names/aliases for all cross-container communication; +- reassess `lds vpn-fix` after static-subnet removal. + +### Docker socket contract + +`/var/run/docker.sock` is equivalent to powerful host Docker control. It must not be mounted merely for convenience. + +During LocalDevStack integration review: + +- inventory each Docker API operation used by `server-tools` and `runner`; +- decide whether both containers still require direct socket access; +- preserve required local-development functionality; +- document the trust boundary clearly; +- do not add a socket proxy unless it demonstrably reduces permissions without breaking the developer workflow. + +### Cross-platform contract + +Preserve Linux, macOS, WSL/Git Bash, and Windows Docker Desktop support where currently intended. + +`lds.bat` remains a Windows bridge into the Bash control plane. Do not replace it with a Linux-only installation model. + +## Execution Order + +### Phase 0 — Shared foundations — COMPLETE + +Plan: `01-shared-foundations-plan.md` + +Stabilize Scriptomatic and Toolset consumption before rebuilding dependent Docker images. The purpose is not to redesign those repositories wholesale; it is to establish immutable dependency references, validation, and reusable contracts required by the Docker stack. + +Exit gate: + +- every downstream image can select immutable Scriptomatic/Toolset revisions; +- required helper scripts have syntax/smoke validation; +- no downstream migration depends on floating helper content. + +### Phase 1 — Leaf infrastructure images — COMPLETE + +Completed releases: + +- Runner `0.5` +- Nginx `0.4.1` +- Apache `0.4.2` + +Historical plans: + +- `02-docker-runner-plan.md` +- `03-docker-nginx-plan.md` +- `04-docker-apache-plan.md` + +Goals: + +- modernize CI/publication; +- pin external helper inputs; +- validate scripts/configs before publication; +- keep each image narrowly focused; +- publish immutable release tags and scheduled `latest` only. + +Exit gate for each image: + +- PR validation green; +- actual image build green; +- health/smoke check green; +- release workflow follows immutable-version contract; +- LocalDevStack can consume the resulting release without behavior regression. + +### Phase 2 — Control plane — COMPLETE + +Plan: `05-docker-tools-plan.md` + +This control-plane phase is complete and published as `infocyph/tools:0.23.2`. + +Primary goals: + +- establish strong CI around shell tools/templates/admin panel; +- pin all downloaded dependencies; +- make machine-readable contracts explicit; +- reduce duplicated platform configuration with LocalDevStack; +- preserve domain/TLS/secrets/monitoring/admin workflows; +- avoid turning `docker-tools` into the owner of LocalDevStack orchestration policy. + +### Phase 3 — Local AI capability — COMPLETE + +Plan: `06-docker-llm-ollama-plan.md` + +`docker-llm-ollama` is published as `0.03` and its provider/runtime contract is complete for this program. + +Primary goals: + +- keep its newer release model intact; +- define how LocalDevStack optionally enables CPU/NVIDIA/AMD tags; +- persist `/root/.ollama`; +- optionally mount project workspace for repo-aware AI commands; +- allow Graphify or other clients to use its Ollama endpoint without coupling them into the image. + +### Phase 4 — Product/orchestrator — ACTIVE + +Plan: `07-localdevstack-integration-plan.md` + +Only after lower layers have stable contracts: + +- add comprehensive CI; +- modularize the large `lds` control plane without changing the public CLI unnecessarily; +- establish one canonical service/profile catalog; +- migrate static networking to Docker DNS; +- reconcile generated-state/storage documentation with actual named volumes/bind mounts; +- consume versioned infrastructure images; +- integrate optional `llm-ollama` cleanly; +- review Docker socket exposure; +- keep PHP/Node runtime generation flexible. + +## Cross-Repository Release Strategy + +Each infrastructure repository should independently release. LocalDevStack should consume explicit compatibility-tested versions rather than assuming every `latest` across the ecosystem changes safely together. + +During development, `latest` remains useful. For LocalDevStack releases, prefer explicit image versions in the release manifest/default environment so a LocalDevStack release can be reproduced. + +A later automated dependency update workflow may propose image version bumps after integration CI passes; do not couple repositories through automatic mutable `latest` behavior alone. + +## Shared CI Baseline + +Every `docker-*` repository should converge on an appropriate subset of: + +- shell syntax checks (`bash -n` / `sh -n` as appropriate); +- ShellCheck for maintained shell scripts; +- Dockerfile/build validation via real Buildx build; +- container smoke/health check; +- configuration validation (`nginx -t`, `httpd -t`, `supervisorctl`, etc.); +- Docker metadata/provenance; +- Buildx cache with repository/variant-safe scopes; +- concurrency protection for publication; +- timeout limits for stuck builds; +- SBOM and vulnerability visibility where practical; +- scheduled `latest` refresh without version-tag overwrite. + +Do not add CI that cannot exercise the image’s actual contract merely to increase check count. + +## Planning Rules for File-by-File Drafts + +Each repository plan below identifies: + +- existing files to modify; +- existing files to validate but intentionally keep; +- new files/workflows/tests to create; +- cross-repository prerequisites; +- acceptance criteria. + +Implementation should follow the listed order inside each repository unless a dependency discovered during coding requires adjustment. + +## Non-Goals + +This program is not intended to: + +- replace Docker Compose; +- collapse all services into one container; +- convert LocalDevStack into Kubernetes; +- publish customized PHP/Node combinations for every possible user selection; +- remove Apache merely because Nginx is the default edge; +- make local-development containers production-hardening equivalents; +- introduce a new programming language for the CLI solely for refactoring aesthetics; +- add AI requirements to users who do not enable the AI capability. + +## Definition of Completion + +The ecosystem work is complete when: + +1. Shared helper dependencies are reproducible and tested. +2. All published infrastructure images have modern immutable release workflows and meaningful CI. +3. `docker-tools` exposes stable machine-readable contracts instead of duplicating orchestration state where avoidable. +4. LocalDevStack has integration CI covering its major profile/runtime combinations. +5. Static IP dependence is removed unless a documented unavoidable case remains. +6. PHP and Node runtime generation remains version-flexible and user-customizable. +7. Local domains/TLS, mail, DB/admin clients, cron/supervisor, secrets, and diagnostics still work. +8. Optional local AI can be enabled through `infocyph/llm-ollama` without contaminating the default stack. +9. Documentation matches the actual storage/network/release behavior. +10. A clean install on supported host categories can reach a working PHP or Node local domain with TLS using the documented workflow. diff --git a/docs/plans/docker-ecosystem/01-shared-foundations-plan.md b/docs/plans/docker-ecosystem/01-shared-foundations-plan.md new file mode 100644 index 00000000..84398355 --- /dev/null +++ b/docs/plans/docker-ecosystem/01-shared-foundations-plan.md @@ -0,0 +1,146 @@ +# Shared Foundations Plan — Scriptomatic + Toolset + +## Purpose + +Define the Scriptomatic/Toolset dependency contract LocalDevStack should consume without redesigning the behavior of either upstream repository. + +## Accepted upstream contracts + +### Scriptomatic + +- canonical source: `main` +- optional reproducible pin: `SCRIPTOMATIC_REF=` +- no Scriptomatic tag/release lifecycle +- PHP/Node bootstrap keeps the existing public inputs and developer-container behavior from Scriptomatic `main` + +### Toolset + +- stable dependency: `TOOLSET_REF=2.0` +- use release assets/checksums rather than Toolset `main`/`master` + +## Current LocalDevStack migration + +The current PHP and Node Dockerfiles still bootstrap from: + +```text +https://raw.githubusercontent.com/infocyph/Scriptomatic/master/... +``` + +During the LocalDevStack implementation phase, replace that hard-coded `master` source with an explicit Scriptomatic ref: + +```dockerfile +ARG SCRIPTOMATIC_REF=main +ARG TOOLSET_REF=2.0 +``` + +Fetch `php-cli-setup.sh` / `node-cli-setup.sh` from the selected Scriptomatic ref and pass `SCRIPTOMATIC_REF` plus `TOOLSET_REF` into the setup process so sibling helpers use the same Scriptomatic ref and Toolset helpers use stable `2.0`. + +For a reproducible build, LocalDevStack may set `SCRIPTOMATIC_REF` to the accepted Scriptomatic commit SHA. + +## Preserve the existing Docker build inputs + +Do not rename the existing PHP/Node identity inputs. The setup scripts continue to consume the Docker build environment: + +```text +UID +GID +``` + +along with the existing package/runtime inputs. + +PHP: + +```text +LINUX_PKG +LINUX_PKG_VERSIONED +PHP_EXT +PHP_EXT_VERSIONED +MSMTP_FROM +``` + +Node: + +```text +LINUX_PKG +LINUX_PKG_VERSIONED +NODE_GLOBAL +NODE_GLOBAL_VERSIONED +NODE_LOG_DIR +``` + +Do not introduce `SCRIPTOMATIC_UID`, `SCRIPTOMATIC_GID`, Composer-version, PHP-extension-installer-version, npm-version, reproducibility-mode, sudo-mode or Oh-My-Bash-mode inputs merely for the shared-foundations migration. + +## PHP behavior LocalDevStack should expect + +Scriptomatic preserves the existing PHP development-image behavior: + +- Alpine official PHP/FPM conventions; +- Composer installed through `install-php-extensions @composer`; +- requested PHP extensions; +- passwordless sudo for the developer user; +- Oh My Bash with the existing `lambda` theme/plugin set; +- PHP/FPM, msmtp, Composer home, banner and aliases; +- non-root runtime through `php-entry`. + +Hardening is underneath that behavior: safer argv handling, bounded/private downloads, same-ref Scriptomatic helpers, Toolset `2.0` checksum verification, root-owned shared executables, idempotent config and content-aware root-CA refresh. + +## Node behavior LocalDevStack should expect + +Scriptomatic preserves the existing Node development-image behavior: + +- upstream UID-1000 user reuse/rename when applicable; +- passwordless sudo; +- Oh My Bash and aliases; +- user npm cache/global prefix; +- build-time `npm install -g npm@latest || npm install -g npm@next || true`; +- optional global package inputs; +- non-root runtime through `node-entry`. + +The Node entrypoint keeps its existing logging, automatic dependency installation/fallback, `NODE_CMD`, host/port and keepalive behavior. Hardening only fixes stale CA-state handling and the generic-dev double-execution bug. + +## Root CA + +The existing entrypoint input remains: + +```text +ROOTCA_PATH +``` + +System CA installation remains best-effort at the existing fixed destination. No new strictness/destination policy inputs are required from LocalDevStack. + +## Shared utilities + +- `alias-maker.sh`: keep the aliases/functions from Scriptomatic `main`. +- `banner.sh`: keep the centered INFOCYPH presentation, three-row description box, rotating credits and ChromaCat styles; fallback only when presentation capabilities are unavailable. +- `docknotify.sh`: keep `SERVER_TOOLS:9901`/best-effort behavior; protocol framing is corrected underneath. +- `owners.sh`: keep its original human output shape while using safe Git filename enumeration. + +## Certbot + +`certbot-hook.sh` keeps fixed `NGINX` / `APACHE` targets and reload-if-running behavior. Exact inspection and non-TTY exec are implementation fixes. + +`certbot-renew.sh` remains the existing infinite 12-hour renewal loop. LocalDevStack should not pass interval/jitter/failure-threshold configuration that Scriptomatic does not expose. + +## Mongo + +`mongo-replica.sh` retains its fixed topology: + +```text +rs0 +mongo-primary:27017 +mongo-secondary1:27017 +mongo-secondary2:27017 +``` + +The hardening is readiness/idempotency/conflict handling only. No new Mongo topology environment contract is required from LocalDevStack. + +## LocalDevStack implementation acceptance criteria + +1. No PHP/Node Dockerfile downloads Scriptomatic from `master`. +2. No LocalDevStack consumer downloads Toolset helpers from a mutable branch. +3. PHP/Node Dockerfiles expose `SCRIPTOMATIC_REF` and `TOOLSET_REF` while preserving existing `UID`, `GID` and package/runtime inputs. +4. PHP and Node developer-image behavior remains unchanged from the existing LocalDevStack experience. +5. PHP/Node runtime remains non-root and their entrypoints preserve the existing command semantics. +6. `docknotify` still interoperates with the LocalDevStack notification service. +7. Service-to-service names continue to use Docker DNS rather than static IP addresses. +8. Consumer CI builds representative PHP and Node images against the accepted Scriptomatic ref and Toolset `2.0`. diff --git a/docs/plans/docker-ecosystem/02-docker-runner-plan.md b/docs/plans/docker-ecosystem/02-docker-runner-plan.md new file mode 100644 index 00000000..c7b908f2 --- /dev/null +++ b/docs/plans/docker-ecosystem/02-docker-runner-plan.md @@ -0,0 +1,169 @@ +# docker-runner — File-by-File Development Plan + +## Role + +`infocyph/docker-runner` is the background execution image for LocalDevStack. It owns Supervisor, cron, log rotation, and small helper wrappers. It should remain intentionally narrow. + +## Invariants + +- Supervisor stays PID 1. +- Cron and logrotate continue to be supervised services. +- User-provided supervisor/cron definitions remain mountable from LocalDevStack. +- Log handling must remain resilient when some service log directories are absent. +- The image may need Docker CLI access, but Docker socket mounting is a LocalDevStack orchestration decision, not an image default. + +## Existing Files + +### `.github/workflows/docker.publish.yml` + +Replace the legacy publication workflow with the current ecosystem contract: + +- current GitHub Action majors matching `docker-llm-ollama` where compatible; +- release event: publish immutable `` + `latest`; +- scheduled event: resolve latest published release source but publish only `latest`; +- never overwrite a release-version tag on schedule; +- Buildx setup + cache; +- Docker Hub + GHCR from the same build digest; +- provenance attestation; +- add concurrency guard; +- add sensible `timeout-minutes`; +- preserve the existing every-two-weeks cadence unless we intentionally standardize schedules later. + +### New `.github/workflows/check.yml` + +Add PR/push validation: + +- `bash -n` for Bash scripts; +- ShellCheck; +- validate Supervisor configuration inside a built image; +- real Docker image build; +- start container and wait for health; +- verify `supervisorctl status` reports `cron` and `logrotate` RUNNING; +- verify stop signal shuts down cleanly. + +### `Dockerfile` + +Plan: + +- parameterize the Alpine base version instead of relying blindly on `alpine:latest`; +- add immutable Scriptomatic/Toolset refs for `banner.sh` and `chromacat`; +- replace floating `ADD raw.githubusercontent.com/.../master|main` with explicit ref-driven fetch/copy; +- keep package list minimal: bash, curl, CA, supervisor, docker-cli, logrotate, cronie, tzdata, presentation dependencies; +- verify whether `gawk`/other current packages are actually required before removing anything; +- preserve healthcheck against Supervisor; +- preserve `STOPSIGNAL SIGTERM`; +- add OCI revision/version metadata through workflow rather than hard-coded release values; +- keep root user if required for cron/logrotate/Docker access; do not force non-root and break the role. + +### `scripts/supervisord.conf` + +Plan: + +- keep `nodaemon=true` and UNIX control socket; +- validate included `/etc/supervisor/conf.d/*.conf` behavior when directory is empty; +- keep cron/logrotate stdout/stderr on container streams; +- add/verify `stopasgroup`/`killasgroup` only if child-process tests show signal leakage; +- do not add unrelated application workers to the base image. + +### `scripts/logrotate-worker.sh` + +Plan: + +- validate `LOGROTATE_INTERVAL` as a positive integer before sleeping; +- keep configurable state file; +- preserve `/etc/logrotate.conf` preference and per-file fallback; +- ensure one invalid optional logrotate fragment does not create an uncontrolled tight restart loop; +- expose useful failure logs through stderr/stdout; +- test a temporary log file rotation end-to-end. + +### `scripts/pexe.sh` + +Plan: + +- review argument quoting/TTY forwarding; +- keep helper single-purpose; +- ensure target process exit code is propagated; +- add shell smoke coverage. + +### `scripts/dexe.sh` + +Plan: + +- same quoting/exit-code review as `pexe.sh`; +- confirm Docker CLI/container-name assumptions are still required by LocalDevStack runner workflows; +- do not duplicate `lds exec` features into this script. + +### `loggables/daily` + +Plan: + +- validate with `logrotate -d`/debug mode in CI; +- confirm paths match LocalDevStack-mounted `/global/log` layout; +- preserve retention policy unless a bug is found. + +### `loggables/dailyold` + +Plan: + +- determine active use vs legacy compatibility; +- if still used, validate separately; +- if unused across LocalDevStack, deprecate first rather than silently delete. + +### `loggables/supervisord` + +Plan: + +- validate configured path and ownership against the current Supervisor logfile path; +- ensure rotation does not break the active file descriptor/process. + +### `README.md` + +Update after code is stable: + +- define the image as LocalDevStack background-process infrastructure; +- document mounted supervisor/cron/log paths; +- document required Docker socket only as an integration choice; +- document health behavior and environment variables; +- document immutable release tags + `latest` semantics. + +### `.dockerignore` + +Review against actual build context and keep only files required by Docker build. No cosmetic expansion. + +### `.gitignore` / `.gitattributes` + +Keep unless validation finds missing generated files/line-ending rules. + +### `LICENSE` + +No change. + +## New Tests + +Suggested `tests/` files: + +- `tests/shell-check.sh` — syntax entrypoint for local/CI use; +- `tests/supervisor-smoke.sh` — run built container, verify supervisor/cron/logrotate; +- `tests/logrotate-smoke.sh` — temporary log + forced rotation validation. + +Tests should be executable from CI and locally without LocalDevStack running. + +## Integration Follow-Up in LocalDevStack + +After release: + +- pin/raise `infocyph/runner` version in LocalDevStack; +- confirm mounted cron/supervisor definitions work; +- inventory why Runner receives Docker socket; +- remove socket mount later only if no required runner workflow uses it. + +## Acceptance Criteria + +1. ShellCheck/syntax green. +2. Real image build green. +3. Container reaches healthy state. +4. Supervisor sees cron/logrotate running. +5. Log rotation smoke test passes. +6. Release workflow cannot overwrite immutable version tags from schedule. +7. Scriptomatic/Toolset dependencies are immutable/ref-driven. +8. Existing LocalDevStack scheduler/supervisor behavior remains compatible. diff --git a/docs/plans/docker-ecosystem/03-docker-nginx-plan.md b/docs/plans/docker-ecosystem/03-docker-nginx-plan.md new file mode 100644 index 00000000..1105bc7d --- /dev/null +++ b/docs/plans/docker-ecosystem/03-docker-nginx-plan.md @@ -0,0 +1,151 @@ +# docker-nginx — File-by-File Development Plan + +## Role + +`infocyph/docker-nginx` is the LocalDevStack edge proxy. It terminates local TLS, serves generated vhosts, routes directly to PHP-FPM or to Apache/Node/service backends, and exposes the LocalDevStack convenience hosts such as admin/mail/database UIs. + +## Invariants + +- Nginx remains the primary listener on host HTTP/HTTPS ports. +- Generated vhost files remain hot-reloadable. +- PHP fastcgi, Apache proxy, Node proxy, WebSocket/SSE behavior must remain compatible. +- LocalDevStack certificates remain mounted rather than regenerated independently by Nginx. +- Docker DNS/service names are the routing contract; static container IPs are not required. + +## Existing Files + +### `.github/workflows/docker.publish.yml` + +Replace with modern publication flow: + +- current Action majors; +- release -> `` + `latest`; +- scheduled rebuild -> `latest` only; +- Buildx cache, provenance, concurrency and timeout; +- same digest pushed to Docker Hub and GHCR; +- never mutate release-version tags during scheduled rebuilds. + +### New `.github/workflows/check.yml` + +Validate: + +- `sh -n` for POSIX shell scripts; +- ShellCheck in `sh` mode; +- Docker image build; +- `nginx -t` on default image; +- entrypoint startup with temporary certificate/config fixtures; +- generated `locals.conf` syntax; +- bad-vhost quarantine/restore behavior; +- clean SIGTERM shutdown. + +### `Dockerfile` + +Plan: + +- parameterize/pin the upstream Nginx Alpine base line rather than uncontrolled `nginx:alpine` drift for release reproducibility; +- use immutable Scriptomatic/Toolset refs for banner/chromacat downloads; +- retain timezone, locale and minimal shell tooling required by entrypoint scripts; +- keep `fcgi-params.sh`, `proxy-params.sh`, `render-locals.sh`, `nginx-entrypoint.sh` copied from this repository; +- keep image-owned healthcheck based on `nginx -t`; +- let workflow supply OCI version/revision/created metadata; +- avoid installing development-only utilities into the edge image. + +### `scripts/fcgi-params.sh` + +Plan: + +- review every generated include path against current PHP vhost templates; +- keep idempotent file generation; +- validate output through a synthetic PHP vhost and `nginx -t`; +- ensure modern forwarded headers/fastcgi parameters are complete without leaking host-specific assumptions; +- preserve Unix-socket and TCP upstream compatibility if both are currently generated by tools. + +### `scripts/proxy-params.sh` + +Plan: + +- validate HTTP proxy, WebSocket, streaming/SSE, timeout and buffer include files; +- keep generated files deterministic/idempotent; +- review forwarded scheme/host/real-IP headers; +- test Node and Apache routes with `nginx -t` and a minimal upstream smoke container; +- avoid over-large default timeouts/buffers unless required for local dev uploads/debugging. + +### `scripts/render-locals.sh` + +Current role: builds convenience-host routing (`admin.localhost`, `webmail.localhost`, DB UIs, etc.). + +Plan: + +- preserve predefined LocalDevStack routes and additive `LOCALHOST_ROUTES` support; +- move predefined route data toward an explicit data block/config contract if LocalDevStack needs to own the service catalog later; +- keep user routes unable to override reserved system routes unless a future explicit override flag is added; +- validate host/upstream input strictly; +- keep Docker resolver `127.0.0.11` only where variable `proxy_pass` requires it; +- verify TLS config against current Nginx/OpenSSL defaults and remove legacy cipher material only after compatibility testing; +- preserve SSE `/api/tail` streaming behavior used by admin/log tooling; +- test generated file when some optional upstream services are not running (config must remain valid even if upstream DNS resolves only at request time). + +### `scripts/nginx-entrypoint.sh` + +Plan: + +- preserve timezone setup, local-route rendering and pre-start validation; +- review the auto-disable-invalid-vhost feature carefully because it mutates mounted config names; +- keep invalid vhost isolation if it materially improves the dev experience, but add deterministic tests and clear logs; +- prevent endless disable/restore oscillation for permanently invalid configuration; +- ensure background auto-restore loop terminates with the main Nginx process/container; +- validate numeric inputs (`MAX_DISABLE_ATTEMPTS`, restore interval); +- preserve final `exec "$@"` signal semantics; +- consider moving state about disabled configs outside the shared config directory only if current mutation creates cross-container/user confusion. + +### `.dockerignore` + +Review minimal build context; retain only Dockerfile/scripts/workflow-independent runtime inputs. + +### `.gitignore` / `.gitattributes` + +Keep unless generated test fixtures require ignores. Preserve LF shell scripts. + +### `LICENSE` + +No change. + +### New `README.md` (if absent) or repository documentation + +The current tree has no README. Add one after behavior stabilizes, covering: + +- image purpose and LocalDevStack relationship; +- expected mounts (`/etc/nginx/conf.d`, cert/root CA, PHP-FPM sockets, logs); +- environment variables controlling invalid-config restore/local routes; +- published tags and release semantics; +- standalone smoke example without presenting this image as a complete LocalDevStack replacement. + +## New Tests / Fixtures + +Create a small `tests/` tree: + +- `tests/nginx-config-smoke.sh`; +- `tests/render-locals-smoke.sh`; +- `tests/invalid-vhost-smoke.sh`; +- `tests/fixtures/certs/` generated during CI, not committed private material; +- `tests/fixtures/conf.d/` with valid/invalid minimal vhosts. + +## Integration Follow-Up in LocalDevStack + +After a compatible release: + +- pin/raise `infocyph/nginx` version; +- validate PHP direct-FPM, PHP-via-Apache, Node, Mailpit, CloudBeaver, RedisInsight, Mongo Express, Kibana and admin-panel routes; +- remove static Nginx IPv4 assignment when LocalDevStack networking migration executes; +- confirm `lds http reload`, domain create/delete and TLS flows still work. + +## Acceptance Criteria + +1. Shell validation green. +2. Real image build green. +3. `nginx -t` green with default and representative generated configs. +4. Node/WebSocket/SSE and PHP/Apache routing smoke tests pass. +5. Invalid-vhost quarantine/restore cannot loop uncontrollably. +6. Release tags remain immutable; schedule refreshes only `latest`. +7. External helper refs are immutable. +8. LocalDevStack can route all supported local service classes by Docker DNS without fixed IP dependencies. diff --git a/docs/plans/docker-ecosystem/04-docker-apache-plan.md b/docs/plans/docker-ecosystem/04-docker-apache-plan.md new file mode 100644 index 00000000..3de97887 --- /dev/null +++ b/docs/plans/docker-ecosystem/04-docker-apache-plan.md @@ -0,0 +1,138 @@ +# docker-apache — File-by-File Development Plan + +## Role + +`infocyph/docker-apache` is the optional Apache backend used when a LocalDevStack project needs Apache semantics or compatibility while Nginx remains the public edge. + +## Invariants + +- Apache is optional, not a second mandatory edge. +- Nginx may reverse-proxy to Apache over Docker DNS. +- Generated vhosts remain externally mounted. +- FastCGI/PHP-FPM compatibility remains available. +- Local TLS compatibility may be retained for internal/direct Apache use, but host-facing TLS ownership remains with LocalDevStack’s edge design. + +## Existing Files + +### `.github/workflows/docker.publish.yml` + +Replace the legacy workflow with the ecosystem release contract: + +- current GitHub Action majors; +- release event publishes immutable `` + `latest`; +- schedule rebuilds latest release source and publishes `latest` only; +- Buildx cache; +- Docker Hub + GHCR from same digest; +- provenance; +- concurrency guard and timeout; +- scheduled builds must never overwrite release-version tags. + +### New `.github/workflows/check.yml` + +Validate: + +- shell syntax and ShellCheck; +- real Docker build; +- `httpd -t` after image construction; +- container startup and healthcheck; +- mounted vhost loading; +- representative reverse-proxy/FastCGI config if feasible with a small fixture; +- clean signal shutdown. + +### `Dockerfile` + +Plan: + +- parameterize upstream `httpd` Alpine base line for controlled updates; +- replace mutable Scriptomatic/Toolset downloads with immutable/ref-driven dependencies; +- retain only packages needed by update/entrypoint/healthcheck and local TLS/FastCGI support; +- preserve `apache-mod-fcgid`, curl/CA, shell/locale requirements where still used; +- keep `WORKDIR /app` and vhost directory contract; +- let CI/workflow provide dynamic OCI revision/version metadata; +- do not make Apache own LocalDevStack certificate generation; +- verify whether exposing 443 internally is still useful; keep until routing tests prove it can be removed safely. + +### `scripts/update_httpd.sh` + +Current role: normalizes upstream `httpd.conf`, enables proxy/FastCGI/rewrite/SSL/HTTP2/headers/deflate and adds the mounted vhost include. + +Plan: + +- keep operation deterministic/idempotent; +- replace brittle line-edit logic only where tests demonstrate risk; +- validate every enabled module exists in the selected upstream image; +- ensure repeated image-build execution would not duplicate config lines; +- validate resulting `httpd.conf` with `httpd -t` during build/CI; +- review whether both `Listen 80` and `Listen 443` are required for current Nginx-to-Apache modes; +- keep `IncludeOptional conf/vhosts/*.conf` as the generated-vhost contract; +- remove self-deletion (`rm -f -- "$0"`) only if retaining the helper at runtime is useful for diagnostics; otherwise document that it is a build-only helper. + +### `scripts/entrypoint.sh` + +Plan: + +- preserve minimal runtime setup and final `exec` semantics; +- validate mounted vhost permissions/readability before launch where useful; +- fail with clear output when core config is invalid instead of looping; +- avoid runtime mutation of user-generated vhosts unless explicitly required. + +### `scripts/healthcheck.sh` + +Plan: + +- verify health check tests the actual Apache process/config rather than a route that depends on an optional project; +- prefer `httpd -t` plus local process/listener validation; +- keep healthcheck fast and independent of Nginx or PHP project availability. + +### `README.md` + +Update after implementation: + +- describe Apache as an optional LocalDevStack backend; +- document mounted vhost path and expected upstream PHP-FPM relationship; +- document ports/network use without implying users should bind Apache directly on host by default; +- document tag/release semantics and healthcheck. + +### `.dockerignore` + +Keep build context minimal and verify scripts are included. + +### `.gitignore` / `.gitattributes` + +Keep unless test/generated artifacts require updates. Preserve LF shell files. + +### `LICENSE` + +No change. + +## New Tests / Fixtures + +Suggested: + +- `tests/apache-config-smoke.sh`; +- `tests/vhost-smoke.sh`; +- `tests/fixtures/vhosts/basic.conf`; +- optional PHP-FPM mock/upstream fixture for proxy/FastCGI validation. + +Do not require a full LocalDevStack environment for repository-level CI. + +## Integration Follow-Up in LocalDevStack + +After release: + +- pin/raise `infocyph/apache` version; +- validate Nginx -> Apache HTTP and HTTPS/internal paths still used by generated templates; +- validate `.htaccess`/rewrite use cases that justify Apache mode; +- remove Apache fixed IPv4 assignment with the broader DNS networking migration; +- ensure Apache is enabled only by the relevant generated profile/domain configuration. + +## Acceptance Criteria + +1. Shell validation green. +2. Image builds successfully. +3. `httpd -t` green after generated base configuration. +4. Mounted representative vhost loads successfully. +5. Healthcheck is project-independent. +6. Release-version tags are immutable and schedule updates `latest` only. +7. Scriptomatic/Toolset inputs are immutable/ref-driven. +8. LocalDevStack Apache-mode domains continue working behind Nginx via Docker DNS. diff --git a/docs/plans/docker-ecosystem/05-docker-tools-plan.md b/docs/plans/docker-ecosystem/05-docker-tools-plan.md new file mode 100644 index 00000000..be100f79 --- /dev/null +++ b/docs/plans/docker-ecosystem/05-docker-tools-plan.md @@ -0,0 +1,414 @@ +# docker-tools — File-by-File Development Plan + +## Role + +`infocyph/docker-tools` is the LocalDevStack control-plane image. It owns domain/vhost generation, local certificate generation, profile/runtime selection helpers, secrets/environment tooling, monitoring, diagnostics, admin UI, reusable templates and the bridge between user intent and generated LocalDevStack configuration. + +This is the largest supporting image and should be changed only after Scriptomatic/Toolset contracts plus Runner/Nginx/Apache image contracts are stable. + +## Architectural Boundary + +`docker-tools` should implement control-plane mechanisms. LocalDevStack should remain the owner of product orchestration policy. + +Target split: + +- Tools: render/validate/generate/inspect. +- LocalDevStack: decide which services/profiles/images are enabled and persist the canonical product configuration. + +Avoid a second independent copy of service defaults inside Tools when LocalDevStack can provide a machine-readable catalog. + +## Existing Top-Level Files + +### `.github/workflows/docker.publish.yml` + +Replace legacy publishing with the modern contract: + +- current Action majors; +- release -> immutable `` + `latest`; +- schedule -> latest published release source, `latest` tag only; +- Buildx cache; +- provenance; +- concurrency guard and timeout; +- same digest to Docker Hub/GHCR; +- no scheduled overwrite of version tags. + +### New `.github/workflows/check.yml` + +Add layered validation rather than one monolithic smoke command: + +1. shell syntax/ShellCheck; +2. PHP syntax for admin-panel PHP files; +3. template fixture rendering; +4. existing `senv-smoke.sh`; +5. Docker image build; +6. container startup/health/control-plane smoke; +7. machine-readable command output validation (`--json` contracts); +8. admin panel endpoint smoke. + +### `Dockerfile` + +Plan: + +- parameterize Alpine base version; +- replace mutable Scriptomatic/Toolset `ADD` sources with immutable/ref-driven fetches; +- pin/verify mkcert, lazydocker and other downloaded executable sources where practical; +- review runtime-version discovery from endoflife.date: keep generated snapshot useful, but make build failure behavior intentional if external API is unavailable; +- preserve tools actually needed by shell commands/admin panel; +- remove duplicate packages only after command-to-package inventory; +- keep Docker CLI/Compose because control-plane commands currently inspect/manage stack containers; +- keep PHP runtime because admin panel is PHP-based; +- generate/store OCI source/version/revision metadata; +- add an image healthcheck covering the primary daemon/admin endpoint if one is stable; +- document Docker-socket trust expectation without attempting to solve host orchestration inside the Dockerfile. + +### `README.md` + +Rewrite only after behavior stabilizes. Document: + +- control-plane responsibility; +- command inventory grouped by domain/TLS/env/monitoring/admin/runtime generation; +- required mounts and Docker socket implications; +- machine-readable interfaces consumed by LocalDevStack; +- release/tag policy; +- standalone limitations vs full LocalDevStack. + +### `.dockerignore` + +Verify admin assets/templates/tests required during build are not accidentally excluded. Keep build context minimal. + +### `.gitignore` / `.gitattributes` + +Preserve LF shell policy and add generated test artifacts only if needed. + +### `LICENSE` + +No change. + +## Shell Command Files + +### `scripts/shells/entrypoint.sh` + +- define one clear primary process lifecycle; +- start notifier/admin services deterministically; +- propagate signals and child failures correctly; +- validate writable/mounted state directories before starting; +- avoid silently creating product configuration that should be owned by LocalDevStack. + +### `scripts/shells/certify.sh` + +- preserve mkcert/local CA workflow; +- make inputs/outputs explicit and idempotent; +- validate SAN/domain lists; +- keep generated CA/server cert paths compatible with Nginx/Apache/Mailpit/PHP trust mounts; +- add smoke generation + verification with `openssl`. + +### `scripts/shells/mkhost.sh` + +This is a critical large script. + +- freeze current CLI/output contracts with fixtures before refactor; +- separate parsing/state/render orchestration internally only after tests exist; +- consume a canonical runtime/service catalog supplied by LocalDevStack instead of hard-coded duplicate product defaults where possible; +- validate project/document-root paths; +- generate PHP/Apache/Nginx/Node artifacts atomically; +- keep `--JSON`/state output stable for `lds`; +- make repeated creation idempotent; +- add PHP direct-FPM, PHP+Apache and Node fixture tests. + +### `scripts/shells/rmhost.sh` + +- mirror `mkhost` state semantics; +- delete only artifacts owned by the selected host; +- preserve unrelated shared config; +- make deletion idempotent; +- test create -> remove -> recreate lifecycle. + +### `scripts/shells/domain-which.sh` + +- keep read-only diagnostics; +- validate all supported generated config locations; +- machine-readable output where already supported must remain stable; +- no mutation side effects. + +### `scripts/shells/env-store.sh` + +- formalize JSON file schema/version; +- atomic writes + locking where concurrent admin/CLI access can occur; +- explicit error on malformed state instead of silent data loss; +- stable `get/set/unset/get-json/set-json` contracts; +- unit-style smoke tests with temporary files. + +### `scripts/shells/profile-chooser.sh` + +Current issue: duplicates LocalDevStack database profile/default definitions. + +Target: + +- accept a service catalog path/environment input; +- keep only generic selection/state mechanics in Tools; +- provide fallback built-in catalog only if standalone compatibility requires it; +- keep JSON/profiles/services/env outputs stable during migration; +- LocalDevStack becomes canonical source for profile names/default environment values. + +### `scripts/shells/composer-setup.sh` + +- pin Composer installer/source validation; +- verify checksum/signature flow if not already present; +- keep build-only responsibility narrow. + +### `scripts/shells/git-default.sh` + +- preserve mounted global Git config behavior; +- avoid overwriting explicit user config unexpectedly; +- support missing name/email cleanly; +- test idempotency. + +### `scripts/shells/init-php-dirs.sh` + +- keep directory initialization idempotent; +- validate permission model against PHP runtime UID/GID strategy; +- avoid world-writable defaults unless required by cross-UID volume behavior and documented. + +### `scripts/shells/senv.sh` + +- keep SOPS/Age operations explicit; +- preserve current smoke test; +- add malformed/missing-key behavior tests; +- never print decrypted secret material in diagnostics by default. + +### `scripts/shells/es-policy.sh` + +- validate Elasticsearch version/API compatibility; +- keep bootstrap idempotent; +- do not make Elasticsearch profile startup depend on Kibana availability unless necessary. + +### `scripts/shells/notifierd.sh` + +- treat notifier as optional support service; +- failures must not take down unrelated control-plane functions; +- validate FIFO/TCP/token behavior and clean shutdown. + +### `scripts/shells/notify.sh` + +- graceful no-listener behavior; +- strict input escaping; +- preserve existing host-notification protocol. + +### `scripts/shells/status.sh` + +- make service status read-only; +- prefer Docker labels/service names over static container IPs; +- stable human + machine-readable output if exposed. + +### Monitoring scripts + +Files: + +- `monitor-flows.sh` +- `monitor-runtime.sh` +- `monitor-tls.sh` +- `monitor-db.sh` +- `monitor-volumes.sh` +- `monitor-queue.sh` +- `monitor-slo.sh` +- `monitor-log-heatmap.sh` +- `monitor-drift.sh` +- `monitor-alerts.sh` + +For every file: + +- classify required Docker/socket/log mounts; +- keep operation read-only unless the command explicitly advertises remediation; +- add timeout/error handling around Docker/network calls; +- return predictable exit status for automation/admin panel; +- support missing optional services without false critical errors; +- emit structured output for admin-panel consumption where practical; +- add fixture/smoke coverage per monitor category. + +## Docker Runtime Templates + +### `scripts/docker-templates/php.compose.yaml` + +- preserve local image build (`localdevstack-php:` + `pull_policy: never`); +- keep UID/GID/version/extensions/packages customization; +- fix any default GID inconsistency (`root` vs numeric) during implementation after validation; +- consume canonical paths/env from LocalDevStack; +- remove static IP assumptions; +- ensure healthcheck reflects PHP-FPM readiness; +- keep shared composer/Git/CA/FPM mounts. + +### `scripts/docker-templates/node.compose.yaml` + +- preserve local image build (`localdevstack-node:`); +- keep UID/GID/version/package/global-package customization; +- keep Node app command/port/host generation explicit; +- remove static IP assumptions; +- validate healthcheck for apps that intentionally have delayed startup; +- keep Git/CA/SSH/project mounts. + +## FPM Templates + +Files: + +- `scripts/fpm-templates/local-dynamic-warm.conf.tpl` +- `scripts/fpm-templates/local-ondemand.conf.tpl` +- `scripts/fpm-templates/local-static-high.conf.tpl` + +Plan for each: + +- render with fixture values; +- validate with `php-fpm -t` in representative PHP versions; +- document intended workload profile; +- keep local-dev defaults bounded to avoid excessive idle processes; +- ensure socket/user/group permissions match generated runtime containers and Nginx/Apache readers. + +## HTTP Templates + +Directories under `scripts/http-templates/` are contract-critical generated artifacts. + +For every template file: + +- render from a fixture replacing all placeholders; +- reject unresolved `{{...}}` tokens; +- Nginx templates must pass `nginx -t` in the target image; +- Apache templates must pass `httpd -t` in the target image; +- use Docker service/container DNS names rather than fixed IPs; +- preserve PHP direct-FPM, Apache proxy, Node proxy and fixed-IP/external-proxy modes only where still intentional; +- centralize common timeout/header snippets in image-level includes instead of duplicating large blocks across templates; +- keep TLS paths aligned with `certify.sh` output. + +Known critical families include Node Nginx HTTP/HTTPS, PHP Nginx direct/proxy HTTP/HTTPS, Apache vhosts and fixed/external proxy templates. + +## Admin Panel + +### `scripts/admin-panel/app/bootstrap.php` + +- centralize environment/path/container-name configuration; +- add safe wrappers for shell/Docker execution; +- enforce output escaping helpers and timeouts; +- keep no-framework deployment unless complexity justifies otherwise. + +### `scripts/admin-panel/app/index.php` and `scripts/admin-panel/index.php` + +- keep routing/front-controller behavior minimal; +- explicit 404/invalid page handling; +- no arbitrary file inclusion from request input. + +### Layout files + +- `_layout_top.php` +- `_layout_bottom.php` + +Plan: + +- centralize navigation/asset loading; +- keep all user/container data escaped; +- expose product/image version info for diagnostics. + +### Operational pages + +Files: + +- `automation_cron.php` +- `automation_manager.php` +- `automation_supervisor.php` +- `dashboard.php` +- `db_health.php` +- `docker_logs.php` +- `drift_monitor.php` +- `host_manager.php` +- `live_stats.php` +- `logs.php` +- `queue_health.php` +- `slo_view.php` +- `tls_monitor.php` +- `volume_monitor.php` + +For every page: + +- separate data collection/action execution from HTML rendering where practical; +- add command timeout/error handling; +- validate/allowlist any container/service/path/action parameters; +- escape all output; +- require explicit confirmation/POST semantics for mutating actions; +- keep read-only monitoring pages non-mutating; +- align with the corresponding shell monitor/helper as the backend contract rather than duplicating logic in PHP; +- add PHP syntax checks and HTTP smoke tests. + +### `scripts/admin-panel/public/css/core.css` / `panel.css` + +- no redesign during hardening; +- remove duplication only when tied to admin panel maintainability; +- preserve offline assets. + +### `scripts/admin-panel/public/js/core.js` / `panel.js` + +- identify whether `core.js` is vendored/minified third-party content vs project source; +- do not hand-edit generated/vendor bundles without a source/update path; +- validate SSE/log-tail reconnect behavior; +- sanitize DOM insertion of runtime/container data. + +### `scripts/admin-panel/public/vendor/**` + +- record upstream package/version/license; +- keep vendored assets offline-capable; +- update only through an explicit dependency update step, not ad hoc edits. + +### `scripts/admin-panel/README.md` + +Update after contracts stabilize with page/backend architecture and standalone diagnostic instructions. + +## Tests + +### Existing `scripts/tests/senv-smoke.sh` + +Keep and run in CI. + +### New tests + +Add focused tests instead of one giant integration script: + +- env-store schema/atomicity; +- profile-chooser with injected catalog; +- mkhost/rmhost lifecycle fixtures; +- certificate generation/inspection; +- PHP/Node Compose template rendering; +- FPM template rendering + `php-fpm -t`; +- HTTP template rendering + Nginx/Apache config validation; +- monitor command no-service/degraded-service behavior; +- admin-panel PHP syntax + endpoint smoke; +- entrypoint/notifier startup. + +## Canonical Service Catalog Migration + +Create a machine-readable catalog contract owned by LocalDevStack, likely JSON/YAML in the LocalDevStack repository. + +`docker-tools` should read the mounted catalog for: + +- profile slug; +- service display name; +- default image/version variable; +- setup environment prompts/defaults; +- optional admin/convenience route metadata where appropriate. + +Migration order: + +1. Tools supports external catalog while retaining current fallback. +2. LocalDevStack adds canonical catalog and mounts it. +3. `lds` and Tools both consume it. +4. Remove duplicated hard-coded catalog only after compatibility tests pass. + +## Acceptance Criteria + +1. All maintained shell files pass syntax/ShellCheck policy. +2. Admin PHP files pass syntax and smoke tests. +3. Image builds and starts successfully. +4. External Scriptomatic/Toolset/helper downloads are immutable/ref-driven. +5. PHP/Node Compose templates render valid Compose. +6. FPM/HTTP templates render without unresolved placeholders and validate in target runtimes. +7. mkhost/rmhost create/delete lifecycle passes fixture tests. +8. cert generation and TLS inspection pass. +9. monitor/admin paths handle missing optional services correctly. +10. scheduled publication updates only `latest`; release tags remain immutable. +11. Tools can consume a LocalDevStack-supplied canonical service catalog. +12. No control-plane feature requires LocalDevStack fixed container IPs. diff --git a/docs/plans/docker-ecosystem/06-docker-llm-ollama-plan.md b/docs/plans/docker-ecosystem/06-docker-llm-ollama-plan.md new file mode 100644 index 00000000..4bc18d72 --- /dev/null +++ b/docs/plans/docker-ecosystem/06-docker-llm-ollama-plan.md @@ -0,0 +1,224 @@ +# docker-llm-ollama — File-by-File Compatibility + Integration Plan + +## Role + +`infocyph/docker-llm-ollama` is the optional local-AI service for the LocalDevStack ecosystem. It is already published and already uses the newer single-repository tag model: + +- standard CPU/NVIDIA: `latest`, `` +- AMD ROCm: `amd-latest`, `amd-` + +This plan is intentionally conservative. The image should remain independently useful outside LocalDevStack. + +## Invariants + +- LocalDevStack integration must remain optional. +- The default model remains replaceable by the user. +- `/root/.ollama` must be persisted by the consumer/orchestrator. +- CLI commands bundled in the image remain available; users do not install/remove the CLI separately. +- CPU/NVIDIA and AMD ROCm remain separate tag families within the same registry repository. +- LocalDevStack must not rebuild `docker-llm-ollama` locally. +- Graphify or other AI clients consume the Ollama endpoint; they are not baked into this image merely because they can use it. + +## Existing Files + +### `.github/workflows/docker.publish.yml` + +Treat as the reference workflow for the older Docker repos. + +Plan: + +- keep single-repository standard/AMD tag families; +- keep release tags immutable; +- keep scheduled builds refreshing moving `latest`/`amd-latest` tags only; +- retain separate cache scopes/variant builds; +- retain Docker Hub + GHCR publication and attestations; +- only change if a later ecosystem-wide workflow improvement (SBOM/scanning/action major) is adopted consistently. + +### `.github/workflows/cli.check.yml` + +Keep as the CLI/Compose validation baseline. + +Potential additions only if needed by integration: + +- verify image-bundled CLI layout; +- verify persistent `/root/.ollama` contract; +- verify repo-aware command behavior against `/workspace` mount; +- verify no LocalDevStack-specific dependency enters the image. + +### `Dockerfile` + +Plan: + +- keep Ollama-based build and baked default model architecture; +- keep build-time model pull separated from runtime cloud-disable policy; +- preserve fixed in-image CLI installation; +- preserve healthcheck; +- do not add LocalDevStack-specific orchestration scripts; +- if ecosystem image metadata conventions are standardized, align labels without changing runtime behavior. + +### `.env.example` + +Keep standalone variables for image users. + +LocalDevStack should define its own integration variables rather than requiring users to copy this file. + +### `compose.yml` + +Keep as standalone CPU/default example. + +Do not alter it merely to match LocalDevStack’s profile layout. + +### `examples/compose/cpu.yml` + +Keep published-image/persistent-volume CPU example. + +### `examples/compose/nvidia.yml` + +Keep published-image/persistent-volume NVIDIA example and GPU request semantics. + +### `examples/compose/amd.yml` + +Keep `amd-latest`/ROCm example and `/dev/kfd` + `/dev/dri` device requirements. + +### `scripts/llm-ollama` + +Keep as fixed dispatcher installed in the image. + +Integration requirement: + +- commands invoked through `docker exec` must work regardless of LocalDevStack container name chosen by profile; +- no host installer/uninstaller lifecycle. + +### `scripts/lib/core.sh` + +- keep model/API/config helpers generic; +- support environment overrides LocalDevStack can pass; +- no knowledge of LocalDevStack networks/profile names. + +### `scripts/lib/ollama.sh` + +- preserve internal Ollama CLI/API helpers; +- ensure endpoint assumptions work inside the same container. + +### `scripts/lib/commit.sh` + +- preserve Git workspace/safe-directory handling for mounted repositories; +- keep staged-diff behavior self-contained. + +### Command files + +Files include: + +- `commands/ai-commit.sh` +- `api.sh` +- `ask.sh` +- `chat.sh` +- `code.sh` +- `help.sh` +- `json.sh` +- `logs.sh` +- `models.sh` +- `ollama.sh` +- `prompt.sh` +- `ps.sh` +- `pull.sh` +- `restart.sh` +- `review.sh` +- `rm.sh` +- `run.sh` +- `show.sh` +- `start.sh` +- `status.sh` +- `stop.sh` +- `unload.sh` +- `version.sh` + +Plan for all command files: + +- keep image-local responsibilities clear; +- commands that manage models/prompts/API remain first-class; +- any start/stop/restart/log/status command must reflect the actual in-container Ollama process model and not imply Docker host lifecycle control; +- preserve exit codes suitable for `docker exec`; +- keep `ai-commit` able to use a mounted `/workspace` Git repository; +- keep stdin fallback where already supported; +- do not add LocalDevStack wrappers into this repository. + +### `scripts/prompts/ai-commit.txt` + +- keep bundled/self-contained prompt; +- version prompt changes with image releases; +- LocalDevStack must not override it by default. + +### `README.md` + +Keep standalone documentation authoritative for direct image users. + +Add LocalDevStack mention only after integration exists, and only as a short interoperability note/link. + +### `.dockerignore`, `.gitignore`, `.gitattributes`, `LICENSE` + +No LocalDevStack-driven change expected. + +## LocalDevStack Integration Contract + +This lower-layer plan is implemented through the authoritative LocalDevStack integration +plan in `07-localdevstack-integration-plan.md`. + +LocalDevStack consumes the published provider through the optional `ai` profile. It does +not rebuild `docker-llm-ollama` locally and does not consume this repository's standalone +Compose files. + +Current LocalDevStack controls: + +- `LDS_AI_MODEL` selects the shared Tools/provider default model and is forwarded as + `LLM_OLLAMA_MODEL`; +- `LDS_AI_RUNTIME` selects `cpu`, `nvidia`, or `amd` when explicitly configured; +- `LDS_LLM_ARCH` is derived from the effective runtime (`latest` for CPU/NVIDIA, + `amd-latest` for AMD/ROCm); +- provider input/PDF/Ollama tuning values are forwarded from LocalDevStack + `docker/.env`; +- host access is owned by LocalDevStack Nginx, not by the provider container. + +Compose ownership: + +- one tracked `llm-ollama` service in `docker/compose/companion.yaml`; +- no tracked `ai.yaml`, `ai-nvidia.yaml`, `ai-amd.yaml`, or + `ai-host-port.yaml`; +- NVIDIA and AMD/ROCm additions are generated temporarily under + `docker/.runtime/` by `lds`; +- Nginx publishes the fixed loopback-only native endpoint on host port `11434`. + +Persistence: + +- LocalDevStack's `LLMModels` named volume mounts at `/root/.ollama`; +- user-pulled models survive container recreation/upgrades. + +Workspace: + +- LocalDevStack intentionally does not mount a project/repository into `llm-ollama` by + default; +- repository-aware analysis normally uses the Tools consumer layer; +- provider stdin flows remain available without weakening the default trust boundary. + +Networking: + +- internal consumers use `http://llm-ollama:11434`; +- Nginx exposes `https://llm-ollama.localhost`; +- Nginx also exposes `http://llm-ollama.localhost:11434` through a fixed + loopback-only host bind; +- the provider container itself has no published host port. + +User/provider commands are invoked through `lds llm ...`, not a bare +`docker compose exec` from the LocalDevStack repository root. + +## Acceptance Criteria + +1. Existing published `docker-llm-ollama` behavior remains standalone and stable. +2. LocalDevStack can enable it without building locally. +3. Standard and AMD tags are selectable. +4. Named volume persists pulled models across container recreation. +5. Other LocalDevStack containers can reach Ollama by service DNS. +6. Host clients can reach it through Nginx at the fixed loopback-only native Ollama endpoint. +7. `lds llm ...` delegates to the bundled provider CLI through LocalDevStack's Compose wrapper. +8. No project/repository bind mount is required for normal provider operation. +9. No Graphify/LocalDevStack-specific package is added to the image solely for integration. diff --git a/docs/plans/docker-ecosystem/07-localdevstack-integration-plan.md b/docs/plans/docker-ecosystem/07-localdevstack-integration-plan.md new file mode 100644 index 00000000..0deaa487 --- /dev/null +++ b/docs/plans/docker-ecosystem/07-localdevstack-integration-plan.md @@ -0,0 +1,2356 @@ +# LocalDevStack — Final Integration, Hardening & Productization Plan + +## Status + +Planning branch: `plan/docker-ecosystem-bottom-up` + +Product repository: `infocyph/LocalDevStack` + +Baseline: + +- LocalDevStack `main`: `008c3266313d89dbc4e8243e0c1515447fdceaa9` +- Product role: Docker-based XAMPP/MAMP/LAMP alternative for PHP + Node.js development +- Primary CLI: `lds` +- Windows bridge: `lds.bat` +- Default HTTP/TLS edge: Nginx +- Optional compatibility backend: Apache +- PHP and Node runtimes: locally generated/customized images +- Infrastructure images: published, independently versioned images +- Local AI: optional, provider-backed, never required for the default stack + +All lower-layer ecosystem work is complete and published. This file is now the authoritative implementation plan for the LocalDevStack phase and supersedes the earlier exploratory version of this plan. + +## Published compatibility baseline + +LocalDevStack implementation must begin against this tested ecosystem set: + +| Layer | Contract | +| --- | --- | +| Scriptomatic | hardened `main`; downstreams may use `main` or an explicit full SHA | +| Toolset | stable `2.0` release/installer contract | +| Runner | `infocyph/runner:0.5` | +| Nginx | `infocyph/nginx:0.4.1` | +| Apache | `infocyph/apache:0.4.2` | +| Tools | `infocyph/tools:0.23.2` | +| LLM standard | `infocyph/llm-ollama:latest` | +| LLM AMD | `infocyph/llm-ollama:amd-latest` | + +The implemented image policy now follows the ecosystem moving aliases for LocalDevStack +infrastructure. Standard LLM uses `latest`; AMD/ROCm uses `amd-latest`. Release +reproducibility is provided by each image repository's immutable release tags, provenance +and compatibility gates rather than by duplicating pinned image versions in +LocalDevStack configuration. + +--- + +# 1. Product definition + +LocalDevStack is not a generic production orchestrator. + +It is a local developer workstation product intended to provide the convenience of XAMPP/MAMP/LAMP while retaining Docker isolation and modern PHP/Node workflows. + +The product should make these workflows easy: + +1. install/initialize once; +2. choose services and runtimes; +3. create a local domain; +4. get trusted local HTTPS; +5. run PHP/Composer and Node/npm tooling; +6. use databases and their admin clients; +7. use local mail; +8. run cron/Supervisor workers; +9. inspect status/logs/health; +10. optionally enable local AI without changing the core stack. + +Docker remains the implementation mechanism, not the user experience. + +--- + +# 2. Final architecture + +Target architecture: + +```text +Host + │ + ├── lds / lds.bat + │ │ + │ ├── setup / profiles / domains / certificates + │ ├── runtime generation + │ ├── service wrappers + │ └── Compose orchestration + │ + └── Docker + │ + ├── nginx 0.4.1 + │ ├── project.localhost -> PHP / Apache / Node + │ ├── admin.localhost -> server-tools:9911 + │ ├── webmail.localhost -> mailpit:8025 + │ ├── db/ri/me/kibana convenience routes + │ └── llm-ollama.localhost -> llm-ollama:11434 + │ + ├── apache 0.4.2 (optional backend) + ├── PHP runtimes (local builds) + ├── Node runtimes (local builds) + ├── server-tools 0.23.2 + │ ├── host/domain/TLS/config control plane + │ ├── monitoring/admin panel + │ ├── askai / aiops / gitx AI consumer paths + │ └── http://llm-ollama:11434 + │ + ├── runner 0.5 + │ └── Supervisor / cron / logrotate / sibling exec + │ + ├── mailpit + ├── databases and admin clients + │ + └── llm-ollama current stable (optional) + ├── qwen3:14b baked default + ├── persistent /root/.ollama + └── Ollama API :11434 +``` + +Service-to-service AI traffic must use: + +```text +http://llm-ollama:11434 +``` + +Host/user-facing AI traffic must use: + +```text +https://llm-ollama.localhost +``` + +Do not route Tools -> LLM traffic through Nginx. + +--- + +# 3. Product invariants + +These are hard constraints for the LocalDevStack phase. + +## 3.1 CLI and platform + +- `lds` remains the primary user-facing CLI. +- `lds.bat` remains the Windows/Git-Bash bridge. +- Existing public commands/aliases should remain compatible unless a command is proven obsolete. +- Refactoring the implementation must not force users to learn Docker Compose internals. +- Linux, macOS, WSL/Git Bash and Windows Docker Desktop behavior must remain intentionally supported where the current product already targets them. + +## 3.2 Runtime model + +- PHP remains locally generated from official `php:-fpm-alpine`. +- Node remains locally generated from official `node:-alpine`. +- User-selected packages/extensions/globals remain supported. +- Host UID/GID alignment remains supported. +- Project source remains bind-mounted; it is never baked into infrastructure images. + +## 3.3 HTTP model + +- Nginx remains the only default host-facing HTTP/TLS edge. +- Apache remains optional. +- Local certificates remain generated/owned by LocalDevStack/Tools, not Nginx or Apache. +- Project routing and infrastructure routing must use Docker DNS/service names. +- Static container IPs are not product contracts. + +## 3.4 State model + +The following must survive container recreation: + +- database data; +- Redis data where persistence is enabled; +- Mailpit data; +- LocalDevStack generated/shared state; +- Composer global state where currently persisted; +- Git config state where currently persisted; +- AI model state under `/root/.ollama`. + +Deleting volumes must remain an explicit destructive action. + +## 3.5 AI model + +- AI is optional. +- `docker-tools` is an AI consumer, never an Ollama runtime. +- `docker-llm-ollama` is the only LocalDevStack Ollama/model runtime. +- LocalDevStack must remain fully usable when `llm-ollama` is absent. +- No automatic execution of model-generated shell, SQL or code is introduced. +- No external/cloud AI fallback is added by LocalDevStack. +- No Docker socket is mounted into `llm-ollama`. +- No repository/workspace is mounted into `llm-ollama` by default. + +## 3.6 Single-stack compatibility + +The current product uses fixed container names and globally named volumes. + +For this release: + +- preserve those names unless a concrete bug requires change; +- do not combine the networking migration with a container/volume naming migration; +- document that the current architecture is optimized for one LocalDevStack installation per Docker engine. + +Multi-stack namespacing may be a later project. It must not risk existing user data in this integration release. + +--- + +# 4. Implementation strategy + +The LocalDevStack work should be implemented bottom-up inside this repository. + +Do not begin by splitting the 100+ KB `lds` file. + +First establish CI and compatibility characterization, then change orchestration, then modularize code. + +Each implementation batch has three phases: + +1. **Implement** — make the bounded change. +2. **Validate** — static/unit/Compose/runtime checks. +3. **Integrate** — exercise the change through `lds` and the published images. + +Recommended order: + +- Batch 1 — CI + characterization +- Batch 2 — release/image defaults +- Batch 3 — networking/DNS migration +- Batch 4 — optional AI integration +- Batch 5 — profiles/catalog/runtime defaults +- Batch 6 — PHP/Node build modernization +- Batch 7 — `lds` modularization + wrapper cleanup +- Batch 8 — config/log/socket hardening +- Batch 9 — documentation/QoL/release gate + +A batch should leave the branch usable before moving to the next one. + +--- + +# 5. Batch 1 — CI and behavior characterization + +## 5.1 New `.github/workflows/check.yml` + +This is the first implementation task. + +Required jobs: + +### Static shell validation + +Validate: + +- `lds`; +- executable files under `bin/`; +- new `lib/*.sh` modules once introduced; +- maintained shell fixtures. + +Use: + +- `bash -n`; +- ShellCheck with explicit, documented suppressions only. + +Do not globally ignore broad ShellCheck classes to make CI green. + +### Batch/Windows bridge validation + +At minimum: + +- parse/static-check `lds.bat` expectations; +- validate paths containing spaces; +- validate Git-for-Windows Bash discovery assumptions; +- validate caller working-directory preservation. + +A Windows runner should be added for bridge-specific smoke tests when practical. + +Do not duplicate the entire Linux Docker integration suite on Windows. + +### Compose validation + +Run `docker compose config` for representative matrices: + +- core only; +- core + Apache; +- each DB profile; +- Elasticsearch + Kibana/Filebeat; +- AI CPU; +- AI NVIDIA override config syntax; +- AI AMD override config syntax; +- generated PHP runtime; +- generated Node runtime. + +Compose validation must catch missing variables, duplicate keys, invalid profiles and invalid override merges. + +### CLI characterization + +Add non-destructive tests for: + +- `lds help`; +- `lds setup` routing; +- profile parsing; +- dotenv reads/writes; +- compose-file resolution; +- runtime selection; +- aliases; +- status/doctor/config commands; +- domain argument validation; +- unknown-command handling; +- commands that should work without Docker where applicable. + +### Published-image core integration + +Use the exact compatibility baseline: + +```text +infocyph/tools:0.23.2 +infocyph/runner:0.5 +infocyph/nginx:0.4.1 +infocyph/apache:0.4.2 +``` + +Validate: + +- Tools reaches healthy state using its own healthcheck; +- Runner reaches healthy state; +- Nginx validates and starts; +- Apache validates and starts when enabled; +- `admin.localhost` routes to Tools; +- core containers resolve each other through Docker DNS; +- clean SIGTERM/Compose down behavior. + +### AI integration smoke without model download + +Do not download a 3B model on every LocalDevStack PR. + +Use a lightweight fake Ollama-compatible service named `llm-ollama` for the normal PR test. + +Validate: + +- Tools `askai --status` reaches `http://llm-ollama:11434`; +- Tools `aiops provider` works; +- Nginx `llm-ollama.localhost` reaches the fake service; +- streamed response is not buffered incorrectly; +- AI absence leaves core services healthy. + +A manual/release-gate job may optionally exercise the real published `infocyph/llm-ollama:latest`, because that image already has its own model-bearing runtime gate. + +## 5.2 New `tests/` + +Suggested layout: + +```text +tests/ + lib/ + assertions.sh + fixtures.sh + static.sh + cli-contract.sh + env-contract.sh + compose-contract.sh + networking-contract.sh + runtime-php-contract.sh + runtime-node-contract.sh + ai-contract.sh + release-gate.sh + fixtures/ + env/ + compose/ + projects/ + fake-ollama/ +``` + +Keep fixtures disposable. + +Never commit real certificates, private keys, SOPS keys or user secrets. + +--- + +# 6. Batch 2 — Release defaults and image policy + +## 6.1 Tracked `docker/release.env` + +The implemented release manifest contains only defaults that genuinely vary independently +of the fixed product Compose definitions. Current tracked value: + +```text +SCRIPTOMATIC_REF=main +``` + +Tools, Runner, Nginx and Apache are declared directly as their moving `:latest` product +images. The tracked Ollama service is declared directly as +`infocyph/llm-ollama:latest`. AMD/ROCm uses a temporary runtime override that directly +selects `infocyph/llm-ollama:amd-latest`; there is no separate LLM image-tag selector. + +Do not add `LDS_TOOLS_IMAGE`, `LDS_RUNNER_IMAGE`, `LDS_NGINX_IMAGE`, +`LDS_APACHE_IMAGE`, `LDS_LLM_IMAGE`, or `LDS_LLM_AMD_IMAGE` indirection. + +Rules: + +- `docker/release.env` is release-owned; +- user LocalDevStack overrides belong in `docker/.env`; +- command-scoped shell values retain the highest interpolation precedence; +- do not copy release defaults into a user file on every update; +- upgrades should not overwrite user values; +- precedence must remain deterministic and covered by tests. + +## 6.2 Compose image references + +Replace hard-coded: + +```text +infocyph/tools:latest +infocyph/runner:latest +infocyph/nginx:latest +infocyph/apache:latest +``` + +with: + +```yaml +image: ${LDS_TOOLS_IMAGE:-infocyph/tools:0.23.2} +image: ${LDS_RUNNER_IMAGE:-infocyph/runner:0.5} +image: ${LDS_NGINX_IMAGE:-infocyph/nginx:0.4.1} +image: ${LDS_APACHE_IMAGE:-infocyph/apache:0.4.2} +``` + +Do the equivalent for `llm-ollama`. + +## 6.3 Update command / future dependency bumps + +Do not silently mutate release defaults at runtime. + +A future QoL command may report newer releases, for example: + +```text +lds update check +``` + +but it should not rewrite compatibility pins without explicit user action. + +A future CI workflow may test proposed dependency bumps and open a PR, but that is not required to complete this phase. + +--- + +# 7. Batch 3 — Remove static-IP architecture + +This is the largest orchestration cleanup before AI/profile work. + +## 7.1 `docker/compose/main.yaml` + +Keep logical networks: + +- `frontend`; +- `backend`; +- `datastore`. + +Remove: + +- hard-coded `172.28.0.0/24`; +- hard-coded `172.29.0.0/24`; +- hard-coded `172.30.0.0/24`; +- explicit gateway declarations. + +Target: + +```yaml +networks: + frontend: + driver: bridge + backend: + driver: bridge + datastore: + driver: bridge +``` + +Preserve current labels where they are useful for LocalDevStack discovery. + +Do not rename networks in the same migration unless necessary. + +## 7.2 `docker/compose/companion.yaml` + +Remove all `ipv4_address` entries. + +Preserve service DNS names: + +- `server-tools`; +- `runner`; +- `mailpit`. + +Retain the network membership actually required by each service. + +Do not attach services to every network merely for convenience. + +## 7.3 `docker/compose/http.yaml` + +Remove fixed Nginx/Apache addresses. + +Nginx must route by service name only. + +Preserve: + +- host `80/443` bindings; +- vhost volume; +- cert/root-CA mounts; +- FPM socket volume; +- logs; +- `host.docker.internal:host-gateway` only if a real supported path still requires it. + +Normalize Apache restart behavior to `unless-stopped` unless a tested reason requires `always`. + +## 7.4 `docker/compose/db.yaml` + +Remove every datastore `ipv4_address`. + +Keep hostnames/service names as the application contract: + +- `redis`; +- `postgres`; +- `mysql`; +- `mongodb`; +- `mariadb`; +- `elasticsearch`. + +## 7.5 `docker/compose/db-client.yaml` + +Remove fixed addresses on both `frontend` and `datastore`. + +Admin clients must use service DNS. + +## 7.6 `lds vpn-fix` + +Characterize the current behavior before removal. + +If the command only exists to work around collisions created by LocalDevStack's fixed subnets: + +- deprecate it in this release; +- keep a compatibility message for one release if useful; +- remove related routing mutation code after tests prove dynamic Docker networks solve the original problem. + +If some independent VPN behavior is still useful, rename/re-scope the command to that actual function rather than preserving obsolete subnet assumptions. + +## 7.7 Networking acceptance tests + +Validate all of these without fixed IPs: + +- Nginx -> Tools; +- Nginx -> Mailpit; +- Nginx -> DB UIs; +- Nginx -> Apache; +- Nginx -> Node app; +- Nginx -> llm-ollama; +- Tools -> DB/service diagnostics; +- Tools -> llm-ollama; +- Runner -> PHP/Node sibling execution; +- DB clients -> databases; +- Filebeat -> Elasticsearch; +- generated project vhosts. + +--- + +# 8. Batch 4 — Optional local AI integration + +AI should become a first-class optional LocalDevStack capability while remaining absent from the default stack. + +## 8.1 Single `llm-ollama` service in `docker/compose/companion.yaml` + +Base service: + +```yaml +services: + llm-ollama: + container_name: LLM_OLLAMA + image: infocyph/llm-ollama:latest + restart: unless-stopped + profiles: [ai] + volumes: + - lds_llm:/root/.ollama + networks: + - frontend + - backend +``` + +Important rules: + +- service key must be exactly `llm-ollama`; +- preserve the existing fixed container name `LLM_OLLAMA` for single-stack compatibility; +- route internally by the Compose service/hostname `llm-ollama`, not by the fixed container name; +- do not set a fixed IP; +- do not mount Docker socket; +- do not expose `11434` to all interfaces; +- do not add a host port in the base companion service; +- do not add an automatic repository/workspace mount; +- do not add cloud fallback. + +Why both networks: + +- Nginx must reach `llm-ollama:11434` for `https://llm-ollama.localhost`; +- Tools must reach `llm-ollama:11434` directly for AI consumer commands. + +## 8.2 `docker/compose/main.yaml` volume + +Add: + +```yaml +lds_llm: + name: LLMModels +``` + +or a similarly consistent LocalDevStack volume name. + +Mount it to: + +```text +/root/.ollama +``` + +The volume is authoritative runtime model state. + +A fresh volume receives the image-baked `qwen3:14b`. + +Existing populated volumes must never be silently replaced/reset during upgrades. + +## 8.3 CPU / NVIDIA / AMD runtime selection + +Use conservative capability detection to choose the initial runtime, while keeping explicit user override authoritative. + +Provide explicit runtime modes: + +```text +cpu +nvidia +amd +``` + +Do not create tracked runtime-variant Compose files. Keep one service in +`docker/compose/companion.yaml` and let `lds` generate a temporary +`docker/.runtime/ai.*` fragment for the current Compose invocation only. + +NVIDIA: + +- keep the tracked `infocyph/llm-ollama:latest` image; +- add `gpus: all` in the temporary fragment. + +AMD: + +- set `image: infocyph/llm-ollama:amd-latest` directly in the temporary fragment; +- expose `/dev/kfd` and `/dev/dri` in the temporary fragment. + +`LDS_AI_RUNTIME` is the explicit runtime selector when configured. There is no +`LDS_LLM_ARCH` setting or user-facing image-version choice. Detection rules are: +usable `nvidia-smi` -> NVIDIA; +both `/dev/kfd` and `/dev/dri` -> AMD/ROCm; otherwise CPU. An AMD CPU alone never +selects the AMD/ROCm image. `lds llm runtime ...` remains the explicit override. + +## 8.4 Native host API + +LocalDevStack exposes Ollama only through Nginx. The provider container itself remains +internal and has no host port mapping. + +Nginx owns both user-facing paths: + +```text +https://llm-ollama.localhost +http://llm-ollama.localhost:11434 +``` + +The native port is fixed and loopback-only on the host: + +```text +127.0.0.1:11434 -> nginx:11434 -> llm-ollama:11434 +``` + +Never bind the native Ollama route to `0.0.0.0:11434`. + +## 8.5 Nginx integration + +The Nginx image owns the dedicated TLS and native Ollama proxy routes: + +```text +llm-ollama.localhost:443 -> llm-ollama:11434 +nginx:11434 -> llm-ollama:11434 +``` + +Both routes use lazy Docker DNS resolution and streaming proxy behavior. LocalDevStack +publishes Nginx's native listener as `127.0.0.1:11434:11434`. + +LocalDevStack must validate: + +- certificate coverage for `llm-ollama.localhost`; +- HTTP -> HTTPS redirect; +- `/api/tags`; +- `/api/generate`; +- `/api/chat`; +- OpenAI-compatible `/v1/...`; +- stream passthrough; +- expected 502/unavailable behavior when AI profile is disabled, without affecting Nginx startup. + +## 8.6 Tools integration + +Pass the published Tools AI contract into `server-tools`: + +```text +LDS_AI_ENABLED=auto +LDS_AI_PROVIDER=ollama +LDS_AI_URL=http://llm-ollama:11434 +LDS_AI_MODEL=qwen3:14b +``` + +Allow user overrides. + +Also pass through supported advanced limits only when the user sets them: + +- `LDS_AI_CONNECT_TIMEOUT=2`; +- `LDS_AI_PREFLIGHT_TIMEOUT=5`; +- `LDS_AI_TIMEOUT=1800`; +- `LDS_AI_AVAILABILITY_TTL=5`; +- `LDS_AI_MAX_CONTEXT_BYTES=524288`; +- `LDS_AI_MAX_REQUEST_BYTES=1048576`; +- `LDS_AI_MAX_RESPONSE_BYTES=2097152`. + +Keep connection and preflight bounds short, but give generation/analysis a 30-minute +default. Forward `LDS_AI_TIMEOUT` to the Nginx container as +`LLM_PROXY_TIMEOUT_SECONDS` so the dedicated `llm-ollama.localhost` route has the same +long-running request budget. Do not reintroduce a shorter independent UI/process timeout +for Admin AI analysis. + +Why LocalDevStack should default `LDS_AI_MODEL=qwen3:14b`: + +- `llm-ollama:latest` ships that model; +- the 14B Qwen3 default provides materially stronger instruction/structured-output behavior than the previous 3B model while remaining practical on modern 32 GB unified-memory developer hosts; +- Tools intentionally reports ambiguity when multiple models are installed and no model is selected; +- users may pull more models without breaking Tools AI workflows. + +Users can change `LDS_AI_MODEL` explicitly. LocalDevStack forwards it to the provider +as `LLM_OLLAMA_MODEL` so Tools and `lds llm` share the same default model. + +Provider settings accepted through `docker/.env` and forwarded to `llm-ollama` are: + +- `LLM_OLLAMA_SYSTEM`; +- `LLM_OLLAMA_INPUT_WARN_BYTES`; +- `LLM_OLLAMA_INPUT_MAX_BYTES`; +- `LLM_OLLAMA_ATTACHMENT_MAX_BYTES`; +- `LLM_OLLAMA_ATTACHMENTS_MAX_BYTES`; +- `LLM_OLLAMA_ATTACHMENT_MAX_COUNT`; +- `LLM_OLLAMA_PDF_MAX_PAGES`; +- `LLM_OLLAMA_PDF_DPI`; +- `LLM_OLLAMA_ALLOW_LARGE_INPUT`; +- `OLLAMA_NUM_PARALLEL`; +- `OLLAMA_MAX_LOADED_MODELS`; +- `OLLAMA_KEEP_ALIVE`; +- `OLLAMA_NO_CLOUD`. + +These are provider/runtime controls and are distinct from the Tools consumer +`LDS_AI_*` timeout/context limits. + +Do not make Tools pull/remove models. + +## 8.7 AI CLI UX + +Add a thin LocalDevStack surface without duplicating provider implementations. + +Recommended commands: + +```text +lds ai status +lds ai ask ... +lds ai explain ... +lds ai troubleshoot ... +lds ai review ... +lds ai repo-review ... +lds ai graphify ... +``` + +Map these to the published Tools commands: + +- `askai`; +- `aiops`. + +Recommended provider/model-management pass-through: + +```text +lds llm models +lds llm ps +lds llm show [model] +lds llm pull +lds llm rm +lds llm unload [model] +lds llm run ... +lds llm chat [model] +lds llm prompt ... +lds llm code ... +lds llm review ... +lds llm json ... +lds llm ai-commit ... +``` + +These should delegate to the bundled `llm-ollama` CLI inside the provider container. + +LocalDevStack must not reimplement Ollama/model logic. + +## 8.8 Workspace/repository access + +Default: no project mount into `llm-ollama`. + +Tools already has the LocalDevStack project mounted at `/app` and its AI layer applies size/sensitivity/redaction guards. + +For direct `llm-ollama` repository-aware commands: + +- prefer stdin/file transfer where practical; +- optionally provide a separate read-only workspace override; +- default the workspace mount to read-only; +- require explicit opt-in for writable workspace access. + +Never automatically mount arbitrary host repositories. + +## 8.9 Graphify + +Do not install Graphify into either provider image or Tools solely for this integration. + +Supported patterns: + +- `lds graphify [path] [extract-options...]` -> host Graphify workflow against the + common loopback-published `llm` endpoint, using the effective active-provider model + and `LDS_AI_TIMEOUT` by default; +- FastFlow/NPU -> Graphify `openai` backend with `OPENAI_BASE_URL` / + `OPENAI_MODEL` / `OPENAI_API_KEY`; +- Ollama/CPU/NVIDIA/ROCm -> Graphify `ollama` backend with `OLLAMA_BASE_URL` / + `OLLAMA_MODEL` / `OLLAMA_API_KEY`; +- explicit external provider URLs remain caller-controlled through the matching + backend-specific environment variables; +- Tools `aiops graphify --file ` -> analyze an explicitly supplied Graphify output file. + +FastFlow defaults to `--token-budget 4000 --max-concurrency 1` unless the caller +supplies explicit values. These defaults keep Qwen3.5 9B semantic extraction below the +practical local context ceiling that can otherwise produce `Max length reached!`. +`LDS_GRAPHIFY_TOKEN_BUDGET` and `LDS_GRAPHIFY_MAX_CONCURRENCY` override the +LocalDevStack defaults. + +The `lds graphify` workflow always runs `extract --no-cluster` followed by +`cluster-only ` with the same provider-appropriate Graphify backend, so the +requested two-phase flow clusters once rather than re-clustering immediately after the +extraction command's default clustering pass. Graphify remains a host/external consumer. + +## 8.10 AI admin panel + +Do not create a second AI web UI in LocalDevStack. + +Use the AI capability already shipped in `tools:0.23.2` and exposed through: + +```text +https://admin.localhost +``` + +LocalDevStack's responsibility is to provide correct provider env/networking. + +--- + +# 9. Batch 5 — Profiles, defaults and service metadata + +## 9.1 Current duplication + +Today profile/service defaults exist in more than one place: + +- LocalDevStack `lds`; +- Tools `profile-chooser`. + +Because `tools:0.23.2` is already a published lower-layer contract, LocalDevStack must not require another Tools release to complete this phase. + +## 9.2 LocalDevStack canonical host-side catalog + +Add a host-side product catalog such as: + +```text +docker/catalog/services.json +``` + +Use it for new/rewritten LocalDevStack profile logic. + +Schema should support: + +- profile key; +- display name; +- service key; +- image/version env key; +- default values; +- setup prompts; +- optional admin client; +- persistence volume; +- convenience URL; +- category; +- whether the service is optional/default; +- AI runtime metadata where appropriate. + +Initial entries: + +- PostgreSQL; +- MySQL; +- MariaDB; +- MongoDB; +- Redis; +- Elasticsearch; +- AI. + +Do not place user secrets in the catalog. + +## 9.3 Compatibility with Tools profile chooser + +Do not block LocalDevStack release on making Tools consume the catalog. + +For this release: + +- LocalDevStack host setup uses its own catalog; +- Tools `profile-chooser` remains a compatible standalone Tools capability; +- add a contract test that detects material drift between LocalDevStack defaults and Tools defaults where they overlap. + +A future Tools release may add external-catalog support. That is a later cleanup, not a prerequisite. + +## 9.4 AI profile setup + +Extend `lds setup profile` to include optional AI. + +Prompt only relevant AI settings: + +- enable AI yes/no; +- detected runtime is shown, not prompted by default; +- preferred model default `qwen3:14b`; +- optional direct localhost port yes/no. + +Do not ask users for low-level timeout/byte-limit settings during normal setup. + +Advanced variables remain manual env overrides. + +--- + +# 10. Batch 6 — PHP and Node runtime build modernization + +## 10.1 `docker/dockerfiles/php.Dockerfile` + +Preserve: + +```dockerfile +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-fpm-alpine +``` + +Replace stale Scriptomatic `master` download. + +Introduce: + +```dockerfile +ARG SCRIPTOMATIC_REF=main +``` + +Fetch: + +```text +https://raw.githubusercontent.com/infocyph/Scriptomatic/${SCRIPTOMATIC_REF}/bash/php-cli-setup.sh +``` + +Pass the same `SCRIPTOMATIC_REF` into the bootstrap so sibling Scriptomatic helpers come from the same revision. + +Requirements: + +- bounded curl retries/timeouts; +- verify non-empty script; +- `bash -n` before execution; +- retain UID/GID; +- retain package/extension args; +- retain PHP profile key; +- retain non-root developer user; +- retain FPM entrypoint; +- retain Composer-home separation. + +Do not add a direct Toolset download to this Dockerfile. Scriptomatic owns the current Toolset stable installer contract. + +Default may remain `SCRIPTOMATIC_REF=main`. + +A LocalDevStack release or user may override it with a full SHA when exact reproducibility is needed. + +## 10.2 `docker/dockerfiles/node.Dockerfile` + +Apply the equivalent Scriptomatic contract: + +```dockerfile +ARG SCRIPTOMATIC_REF=main +``` + +Preserve: + +- selectable Node version; +- UID/GID; +- Linux packages; +- global Node packages; +- non-root user; +- npm/Corepack behavior; +- Node entrypoint. + +## 10.3 Runtime image identity + +Keep: + +```text +localdevstack-php: +localdevstack-node: +``` + +Do not publish the combinatorial PHP/Node runtime matrix. + +## 10.4 Build caching + +Avoid rebuilding unchanged local runtime images unnecessarily. + +The runtime-image identity/hash should account for inputs that affect the image: + +- runtime version; +- Scriptomatic ref; +- UID/GID; +- selected extensions/packages/globals; +- relevant Dockerfile revision. + +A later optimization may use labels or a deterministic configuration hash. + +Do not let caching return a runtime built with different extension/package inputs. + +--- + +# 11. Batch 7 — Modularize `lds` safely + +The current `lds` file is over 100 KB. + +Refactor only after Batches 1–6 have tests. + +## 11.1 Target layout + +```text +lds +lib/ + core.sh + platform.sh + env.sh + compose.sh + profiles.sh + catalog.sh + runtime.sh + hosts.sh + certificates.sh + services.sh + ai.sh + diagnostics.sh + maintenance.sh +``` + +Suggested ownership: + +### `lds` + +Only: + +- bootstrap; +- global option parsing; +- command dispatch; +- help/version entrypoints. + +### `lib/core.sh` + +- output/error helpers; +- command requirements; +- common argument helpers; +- temporary-file helpers. + +### `lib/platform.sh` + +- OS/WSL detection; +- Docker Desktop detection where needed; +- path conversion helpers; +- browser/open helpers. + +### `lib/env.sh` + +- release env; +- user env; +- dotenv parse/write; +- precedence; +- safe quoting. + +Never `source` arbitrary dotenv files as shell code. + +### `lib/compose.sh` + +- canonical Compose file list; +- profile resolution; +- optional overrides; +- Compose command wrapper; +- service status/health helpers. + +### `lib/catalog.sh` + +- validate/read `services.json`; +- list available services; +- expose setup fields/defaults. + +### `lib/profiles.sh` + +- setup menu; +- selected profile persistence; +- AI runtime selection; +- profile/env reconciliation. + +### `lib/runtime.sh` + +- PHP/Node version resolution; +- generated Compose fragments; +- local image naming/build/rebuild. + +### `lib/hosts.sh` + +- domain create/delete; +- vhost generation calls; +- reload validation. + +### `lib/certificates.sh` + +- CA creation/trust; +- certificate refresh; +- platform-specific trust store. + +### `lib/services.sh` + +- start/stop/restart/status/logs/open; +- convenience service URL mapping. + +### `lib/ai.sh` + +Only LocalDevStack orchestration/delegation: + +- profile enabled?; +- compose override selection; +- `lds ai` -> Tools; +- `lds llm` -> llm-ollama; +- model/runtime status. + +No provider implementation. + +### `lib/diagnostics.sh` + +- doctor; +- config; +- health; +- environment diagnostics. + +### `lib/maintenance.sh` + +- clean; +- disk; +- events; +- rebuild; +- safe destructive confirmations. + +## 11.2 Refactor rules + +- no module performs work just because it is sourced; +- avoid hidden mutation of global state; +- keep function-local variables local; +- do not replace clear shell with framework-like abstractions; +- preserve exit codes where wrappers rely on them; +- keep `set -euo pipefail` behavior deliberate; +- characterization tests must pass after every extraction; +- one subsystem at a time. + +--- + +# 12. Batch 7 — `bin/` wrapper cleanup + +## 12.1 `bin/tool-runner` + +Treat as the common execution primitive. + +Validate: + +- TTY forwarding; +- stdin forwarding; +- exit-code propagation; +- path conversion; +- service-running errors; +- working-directory selection. + +## 12.2 `bin/php` + +Preserve: + +- explicit PHP version selection; +- default/highest configured runtime; +- normal CLI; +- current serve behavior where useful; +- mounted project; +- non-root execution. + +Add tests for spaces in paths and Git safe-directory behavior. + +## 12.3 `bin/composer` + +Reduce duplicated PHP runtime selection where practical. + +Preserve versioned Composer homes and project mount behavior. + +## 12.4 DB wrappers + +Files: + +- `bin/pg`; +- `bin/my`; +- `bin/maria`; +- `bin/mongo`; +- `bin/redis-cli`; +- `bin/es`. + +Requirements: + +- use Compose/service names, not IPs; +- no assumptions about static subnets; +- credentials from environment/state; +- safe argument/file quoting; +- accurate exit codes; +- temporary dump/restore integration tests. + +## 12.5 New AI wrappers + +Prefer adding: + +```text +bin/ai +bin/llm +``` + +or equivalent internal helpers invoked by `lds`. + +Keep them thin. + +They must not duplicate `askai`, `aiops` or `llm-ollama` logic. + +--- + +# 13. Batch 8 — Compose/service hardening + +## 13.1 `docker/compose/companion.yaml` + +### `server-tools` + +Use: + +```text +${LDS_TOOLS_IMAGE:-infocyph/tools:0.23.2} +``` + +Preserve: + +- project mount; +- SSL/root CA; +- generated Nginx/Apache/FPM/Composer state; +- Git config; +- scheduler state; +- SOPS state; +- optional SSH mount; +- logs; +- Docker socket. + +Add AI environment from Section 8. + +Use the Tools-owned healthcheck instead of inventing an external health command. + +### `runner` + +Use: + +```text +${LDS_RUNNER_IMAGE:-infocyph/runner:0.5} +``` + +Preserve scheduler/log mounts. + +Runner still has legitimate Docker access because its `pexe`/`dexe` and mounted jobs can execute commands in sibling containers. + +Do not remove the socket until those use cases are intentionally redesigned. + +### `mailpit` + +Keep persistence and TLS. + +Validate that its certificate paths are ready before requiring STARTTLS. + +## 13.2 `docker/compose/http.yaml` + +### Nginx + +Use: + +```text +${LDS_NGINX_IMAGE:-infocyph/nginx:0.4.1} +``` + +Preserve `80/443`, generated vhosts, certs, FPM sockets and logs. + +### Apache + +Use: + +```text +${LDS_APACHE_IMAGE:-infocyph/apache:0.4.2} +``` + +Keep optional. + +Use `unless-stopped` unless testing proves `always` is required. + +## 13.3 `docker/compose/db.yaml` + +### Redis + +Keep persistent `/data`. + +Review whether `redis/redis-stack-server:latest` remains desirable as a default or should use a compatibility-tested major/tag. + +Do not change data format in the same release without migration guidance. + +### PostgreSQL + +Reconcile variable naming. + +Current service sets: + +```text +POSTGRES_DB=${POSTGRES_DATABASE:-postgres} +``` + +but the healthcheck references `POSTGRES_DB` through Compose interpolation rather than the resulting container env. + +Use one canonical LocalDevStack variable and test it. + +Validate the selected official Postgres image's current data directory contract before changing the volume mount. + +### MySQL/MariaDB/MongoDB + +Keep explicit local-dev credential variables. + +Review moving `latest` defaults separately from the infrastructure-image migration. + +Do not unexpectedly major-upgrade a user's database by changing defaults without documentation. + +### Elasticsearch + +Keep Elasticsearch/Kibana versions aligned. + +Validate Filebeat compatibility with the same stack version. + +## 13.4 `docker/compose/db-client.yaml` + +Preserve: + +- RedisInsight; +- CloudBeaver; +- Mongo Express; +- Kibana; +- Filebeat. + +Improve startup dependencies only when meaningful: + +- prefer service health/retry behavior; +- avoid dependency chains that deadlock optional profiles. + +--- + +# 14. Docker socket trust boundary + +Both Tools and Runner currently require powerful Docker access for real LocalDevStack functionality. + +Do not remove the socket merely to make a security checklist look better. + +Document the actual boundary: + +```text +/var/run/docker.sock == effective host Docker control +``` + +## Tools reasons + +Tools/admin functionality includes container/service inspection and management. + +## Runner reasons + +Runner helpers and user scheduler definitions may execute into sibling containers. + +## Plan + +1. inventory exact Docker commands used; +2. classify read/write/destructive operations; +3. retain required socket mounts; +4. ensure `llm-ollama` never receives the socket; +5. do not mount socket into ordinary databases/admin clients; +6. document that LocalDevStack is trusted local developer infrastructure. + +A socket proxy is not required unless a future design demonstrates a useful permission reduction without breaking the product. + +--- + +# 15. Docker config files + +## `docker/conf/filebeat.yml` + +Validate: + +- current Elasticsearch/Filebeat version alignment; +- log paths; +- service DNS endpoint; +- no static IP reference. + +## `docker/conf/openssl.cnf` + +Validate current OpenSSL compatibility. + +Do not weaken TLS globally just to support old clients unless a supported LocalDevStack flow requires it. + +## `docker/conf/pg_hba.conf` + +Remove any fixed-subnet assumptions. + +Keep local Docker-network auth appropriately scoped. + +## `docker/conf/postgresql.conf` + +Currently optional/commented. + +Decide one of: + +- intentionally supported and tested; or +- clearly documented as inactive reference config. + +Do not leave ambiguous pseudo-active config. + +## `docker/conf/www-php.conf` + +Validate generated FPM pool/socket integration. + +## `docker/conf/www.conf` + +Determine whether it is actively consumed. + +If unused, mark/deprecate/remove only after search and runtime tests. + +--- + +# 16. Generated/user-owned configuration + +## `configuration/compose/` + +Continue to hold generated user/project Compose fragments. + +Add safe stale-artifact detection. + +Never blindly delete files not known to LocalDevStack. + +## `configuration/php/` + +Preserve user-edited PHP configuration. + +Updates must not overwrite user customizations. + +## `configuration/scheduler/cron-jobs/` + +Validate generated files before Runner consumes them. + +Account for Windows CRLF. + +## `configuration/scheduler/supervisor/` + +Validate Supervisor syntax before stack restart where possible. + +## SOPS directories + +Paths: + +- `configuration/sops/config`; +- `configuration/sops/global`; +- `configuration/sops/keys`. + +Keep ignored/sensitive. + +Ensure setup permissions remain restrictive. + +## `configuration/ssh/` + +Keep optional and read-only. + +Never bake keys into images. + +## `configuration/ssl/` + +Reconcile actual host-visible state with named certificate volumes. + +Documentation must identify which paths are authoritative. + +--- + +# 17. Logs and rotation + +Keep host-visible logs because they are useful in a workstation stack. + +Validate compatibility with `runner:0.5` logrotate paths. + +Review directory permissions. + +Avoid broad `777` changes when a narrower cross-platform permission model works. + +If permissive permissions are still required for Windows/macOS/Linux interoperability, document the local-development rationale. + +Add a smoke that: + +1. writes a test log; +2. Runner sees it; +3. rotation succeeds; +4. application continues writing. + +--- + +# 18. Environment and precedence contract + +Define one documented precedence order. + +Recommended: + +1. built-in product fallback; +2. tracked `docker/release.env`; +3. user `docker/.env`; +4. command-scoped explicit environment overrides. + +Never shell-source untrusted dotenv content. + +Separate classes of settings: + +## Product/release + +- infrastructure image refs; +- default feature compatibility versions. + +## User stack + +- selected profiles; +- DB credentials; +- ports; +- timezone; +- project directory; +- runtime selections. + +## AI + +- enabled/profile; +- runtime variant; +- model; +- optional host port; +- advanced Tools limits. + +## Generated runtime + +- PHP packages/extensions; +- Node globals/packages; +- UID/GID; +- generated project profiles. + +`lds config` should be able to show effective non-secret configuration and redact secrets. + +--- + +# 19. Cross-platform requirements + +## Windows / Git Bash + +Preserve `lds.bat`. + +Tests must cover: + +- Git executable discovery; +- Git Bash discovery; +- spaces in repo/project paths; +- `cygpath` conversion; +- Docker Desktop unavailable/running errors; +- working-directory preservation; +- CRLF-sensitive generated files. + +## WSL + +Avoid assuming Docker socket path/platform behavior that conflicts with Docker Desktop integration. + +## macOS + +Account for bind-mount UID behavior and browser trust-store commands. + +## Linux + +Preserve UID/GID mapping and native Docker behavior. + +Avoid root-owned host project files after normal `lds` commands. + +--- + +# 20. QoL improvements that belong in this phase + +Implement only after core compatibility is stable. + +## `lds status` + +One concise product view: + +- core services; +- selected profiles; +- health; +- domains; +- URLs; +- AI enabled/provider status. + +## `lds urls` + +Print known convenience URLs: + +```text +https://admin.localhost +https://webmail.localhost +https://db.localhost +https://ri.localhost +https://me.localhost +https://kibana.localhost +https://llm-ollama.localhost # when AI enabled +``` + +Only show profile-dependent URLs when relevant. + +## `lds open ` + +Open a known local service in the host browser using existing platform helpers. + +## `lds doctor` + +Check: + +- Docker; +- Compose; +- expected networks; +- volume access; +- certificate state; +- port conflicts; +- image availability; +- selected service health; +- DNS/service resolution; +- optional AI provider status. + +Doctor should diagnose, not mutate, unless the user explicitly chooses a fix action. + +## `lds images` + +Show effective infrastructure image compatibility versions. + +This is useful when troubleshooting a mixed/overridden stack. + +--- + +# 21. Documentation rewrite + +LocalDevStack documentation should now describe the product users actually have. + +## `README.md` + +Lead with: + +> Docker-based XAMPP alternative for PHP and Node.js local development. + +First screen should explain: + +- local domains; +- HTTPS; +- PHP/Node versions; +- databases; +- admin tools; +- mail; +- background workers; +- optional local AI. + +Keep internal architecture below quickstart. + +## `docs/concepts/architecture.rst` + +Update responsibility map with the exact image split and optional AI provider. + +## `docs/concepts/profiles-and-env.rst` + +Document: + +- release env vs user env; +- profile selection; +- runtime variant; +- AI profile; +- override precedence. + +## `docs/concepts/storage-layout.rst` + +Clearly separate: + +- Docker named volumes; +- host configuration; +- host logs; +- project bind mounts; +- secrets; +- AI model volume. + +## `docs/quickstart.rst` + +Target beginner flow: + +```text +lds setup init +lds setup permissions +lds setup profile +lds setup domain +lds up +``` + +Use the actual final command names after implementation. + +## `docs/guides/domain-setup.rst` + +Remove static-IP mental model. + +Explain Docker DNS routing. + +## `docs/guides/tls-and-certificates.rst` + +Ensure `llm-ollama.localhost` and convenience-host certificate behavior is covered. + +## New `docs/guides/local-ai.rst` + +Cover: + +- enabling AI; +- CPU/NVIDIA/AMD; +- `https://llm-ollama.localhost`; +- `lds ai`; +- `lds llm`; +- model persistence; +- selecting a different model; +- direct host port opt-in; +- privacy boundaries; +- optional workspace access; +- Graphify connection. + +## Existing SOPS/notification docs + +Revalidate against `tools:0.23.2`. + +--- + +# 22. Migration/backward compatibility + +The first integrated LocalDevStack release must handle existing installations deliberately. + +## Existing databases/volumes + +Do not rename volumes in this release. + +## Existing Nginx vhost volume + +Older volumes may contain upstream `default.conf` artifacts. + +Use the Nginx 0.4.1 documented cleanup/migration behavior and test an upgraded volume. + +## Existing fixed networks + +Compose recreation may replace old fixed networks. + +Document that containers may be recreated while named-volume data remains. + +Do not run destructive `docker compose down -v` during migration. + +## Existing `docker/.env` + +Preserve user values. + +New release defaults must not overwrite it. + +## Existing generated runtime images + +Detect/rebuild only when relevant inputs changed. + +## Existing users without AI + +Their stack should not pull `llm-ollama`, create the model volume or consume GPU resources unless AI is selected. + +--- + +# 23. Release-readiness matrix + +A LocalDevStack release candidate is not ready until these pass. + +## Core + +- clean install; +- existing-install upgrade; +- `lds help`; +- setup init; +- setup permissions; +- setup profiles; +- domain create/delete; +- trusted HTTPS; +- Nginx core routing; +- Tools admin; +- Mailpit; +- Runner. + +## PHP + +At least: + +- one current PHP runtime build; +- Composer; +- FPM through Nginx; +- FPM through Apache path if supported; +- custom extension/package fixture. + +## Node + +At least: + +- one current Node runtime build; +- npm/npx; +- Node proxy; +- WebSocket/HMR fixture. + +## Databases + +Smoke: + +- PostgreSQL; +- MySQL; +- MariaDB; +- MongoDB; +- Redis; +- Elasticsearch. + +Include admin clients where practical. + +## AI + +With fake provider on normal CI: + +- Tools provider; +- `askai`; +- `aiops`; +- Nginx LLM route; +- streaming. + +With real provider on manual/release gate when feasible: + +- `infocyph/llm-ollama:latest`; +- baked `qwen3:14b`; +- persistent model volume; +- Tools generation; +- Nginx `llm-ollama.localhost`. + +## Platforms + +At minimum: + +- Linux full integration; +- Windows bridge/path validation; +- explicit documentation/manual verification for macOS/WSL if CI environment does not support full Docker Desktop tests. + +--- + +# 24. Must-ship vs follow-up + +## Must ship + +- product CI; +- explicit published infrastructure versions; +- static-IP removal; +- core Compose validation; +- AI profile/provider integration; +- persistent LLM model volume; +- Tools AI env wiring; +- PHP/Node Scriptomatic `main` migration; +- safe `lds` modularization of touched areas; +- DB health/env correctness fixes discovered by CI; +- updated docs; +- release/upgrade smoke. + +## Follow-up allowed + +These do not block the LocalDevStack integration release unless implementation reveals a direct dependency: + +- multi-instance container/volume namespacing; +- automatic dependency-update PRs; +- Docker socket proxy; +- Graphify installation; +- browser AI UI beyond Tools admin panel; +- conservative GPU/runtime detection with explicit override; +- automatic model downloads beyond the baked model; +- production-hardening changes unrelated to local development; +- rewriting the CLI in another language. + +--- + +# 25. Definition of completion + +This LocalDevStack phase is complete when all of the following are true: + +1. LocalDevStack consumes the published compatibility matrix by explicit default. +2. No core LocalDevStack service requires a hard-coded `172.28/29/30` address. +3. `lds` still presents the existing public workflow while internals are better separated and tested. +4. PHP and Node remain locally customizable runtime builds. +5. Scriptomatic consumption no longer uses stale `master` references. +6. Tools, Runner, Nginx and Apache integrate using their published health/runtime contracts. +7. Local domains/TLS work through service-name routing. +8. Databases and admin clients work through Docker DNS. +9. Mailpit remains persistent and TLS-capable. +10. Runner cron/Supervisor/logrotate workflows still work. +11. AI can be omitted completely with no degradation to the default product. +12. When AI is enabled, exactly one provider is active: FastFlow for supported XDNA2 NPU, otherwise Ollama. +13. The active provider persists its own model store and owns the common `llm:11434` identity; `https://llm.localhost` and loopback-only `http://127.0.0.1:11434` work through Nginx. +14. Tools `askai` / `aiops`, Graphify, and the provider CLIs use the common OpenAI-compatible LLM route; provider-specific native routes remain diagnostic/low-level only. +15. No LocalDevStack component embeds a second model runtime or starts both `llm-fastflow` and `llm-ollama` for one stack. +16. No AI component auto-executes model-generated commands. +17. Existing user volumes and env overrides survive upgrade. +18. Docker socket exposure is documented and limited to components that actually require it. +19. Product docs match the implemented storage, network, version and AI behavior. +20. A clean supported workstation can go from clone/setup to a working HTTPS PHP or Node local domain using the documented flow. + +--- + +# 26. First implementation checkpoint + +Before any broad refactor, the first implementation PR/batch should contain only: + +1. permanent LocalDevStack CI foundation; +2. tracked compatibility image defaults; +3. Compose references switched from infrastructure `:latest` to those defaults; +4. characterization tests around current `lds`; +5. no static-IP removal yet; +6. no `lds` modularization yet. + +Once that is green, proceed to the networking migration. + +This gives every later change a reliable regression boundary. + + +--- + +# Appendix — User-directed Alpine-first moving-tag policy override (2026-09-18) + +This section is an explicit product-direction override and supersedes earlier image-pinning recommendations in this plan wherever they conflict. + +## Image default policy + +Prefer the moving Alpine variant when the same image family publishes/supports one; otherwise use its normal moving latest tag. + +LocalDevStack release defaults: + +```text +LDS_TOOLS_IMAGE=infocyph/tools:latest +LDS_RUNNER_IMAGE=infocyph/runner:latest +LDS_NGINX_IMAGE=infocyph/nginx:latest +LDS_APACHE_IMAGE=infocyph/apache:latest +``` + +Other runtime defaults follow the same rule. PostgreSQL uses `postgres:alpine`; MySQL, MariaDB, MongoDB, Redis Stack/Redis Insight, CloudBeaver, Mongo Express and Mailpit use their normal moving latest tags because the selected image family does not provide a suitable moving Alpine alias for this stack. + +## Elastic current-release exception + +The Elastic image set used by LocalDevStack does not expose a usable moving `latest` +alias. Elasticsearch, Kibana and Filebeat therefore share one +`ELASTICSEARCH_VERSION` selector and default to the newest stable release verified by +the compatibility gate. + +Current default: + +```text +9.5.4 +``` + +Advance this single selector when Elastic publishes a newer stable release; all three +services and their contract tests must move together. + +## Override precedence + +User values in `docker/.env` and command-scoped shell environment still override these defaults. + +The Alpine-first moving-tag policy changes default image selection only; it does not weaken: + +- profile isolation; +- AI trust boundaries; +- dynamic networking; +- persistent-volume behavior; +- loopback-only optional Ollama host exposure; +- compatibility/contract testing. + + +## Runtime version-selection invariant + +The Alpine-first moving-image policy does **not** replace the existing PHP/Node runtime version selector. + +LocalDevStack must preserve the published Tools runtime-selection contract: + +- `mkhost` reads `/etc/share/runtime-versions.json`; +- PHP runtime selection remains version-specific; +- Node runtime selection remains version/tag-specific; +- the selected PHP value remains the `PHP_VERSION` build arg and `localdevstack-php:` image identity; +- the selected Node value remains the `NODE_VERSION` build arg and `localdevstack-node:` image identity; +- selected PHP and Node bases continue to use their Alpine variants; +- moving Alpine/latest defaults apply only when the user has not selected/persisted a more specific version; +- explicit user-selected versions always win. + +Batch 6 must not collapse these selectors into a single global PHP or Node version. + + +--- + +# Implementation completion record — 2026-09-18 + +All planned LocalDevStack integration batches are implemented on branch `plan/docker-ecosystem-bottom-up`. + +## Completed batches + +1. **Batch 1 — CI + characterization** + - permanent CI foundation; + - CLI/env/runtime/network characterization; + - Windows bridge coverage; + - published-image checks. + +2. **Batch 2 — image/default policy** + - tracked `docker/release.env`; + - deterministic fallback/release/user/shell precedence; + - user-directed Alpine-first moving image policy; + - explicit Elastic aligned-version exception. + +3. **Batch 3 — networking/DNS migration** + - static `172.28/29/30` addresses removed; + - Docker DNS/service-name routing; + - deterministic legacy-network migration; + - `vpn-fix` deprecated. + +4. **Batch 4 — optional AI integration** + - optional `ai` profile; + - mutually-exclusive FastFlow/Ollama provider services behind common `llm:11434`; + - persistent `LLMModels` and `LLMFastFlowModels`; + - XDNA2 NPU / NVIDIA / AMD ROCm / CPU runtime modes with automatic detection; + - `https://llm.localhost` plus provider-specific diagnostic routes; + - Nginx-owned loopback-only common native API port; + - provider-neutral Tools consumer wiring; + - `lds ai` / `lds llm` separation; + - common OpenAI-compatible fake-provider integration test. + +5. **Batch 5 — profiles/catalog/runtime defaults** + - canonical host service catalog; + - profile setup driven from catalog metadata; + - AI setup fields; + - published Tools profile-drift checks; + - interactive runtime version selection preserved. + +6. **Batch 6 — PHP/Node runtime modernization** + - Scriptomatic `main` / full-SHA contract; + - bounded download + non-empty/syntax validation; + - selected `PHP_VERSION` / `NODE_VERSION` retained inside build stage; + - Alpine runtime bases retained; + - rebuild cache preserved with `--pull`. + +7. **Batch 7 — CLI modularization + wrapper cleanup** + - `lds` split into focused `lib/*.sh` modules; + - Windows wrapper Docker preflight boundary corrected; + - wrapper/runtime/database contracts added; + - fixed-IP assumptions removed from wrappers. + +8. **Batch 8 — Compose/service hardening** + - health-gated service dependencies; + - credential-free database readiness checks; + - Docker socket limited to Tools/Runner; + - inactive PostgreSQL tuning config made explicit; + - unused legacy FPM config removed; + - Runner health/logrotate contract validated; + - public TLS export bridge corrected to `configuration/ssl/rootCA.pem` with legacy fallback. + +9. **Batch 9 — permissions/QoL/docs/release gate** + - broad `chmod -R 777` removed; + - SSH/SOPS key directories hardened; + - `lds urls`, `lds images`, redacted grouped `lds config`, and diagnostic-only `lds doctor`; + - Sphinx user documentation rewritten for the implemented architecture; + - dedicated local-AI guide; + - docs contract + warning-as-error Sphinx CI; + - release-gate script contains all Linux/runtime/AI/published-image contracts. + +## Final policy clarifications + +- Prefer a moving Alpine variant when an image family provides a suitable one; otherwise use its normal moving latest alias. +- PostgreSQL defaults to `postgres:alpine`. +- Tools, Runner, Nginx and Apache consume their published `:latest` aliases. +- Exactly one LLM provider is active: XDNA2 NPU uses `infocyph/llm-fastflow:latest`; CPU/NVIDIA use `infocyph/llm-ollama:latest`; AMD/ROCm uses `infocyph/llm-ollama:amd-latest`. +- Elasticsearch, Kibana and Filebeat share the `ELASTICSEARCH_VERSION` selector and currently default to stable `9.5.4`, because the required Elastic image set has no usable moving `latest` alias. +- PHP/Node runtime selection remains user-driven and version-specific. +- Existing named volumes and container names remain intentionally stable for this release. + +## Non-blocking follow-ups retained from the plan + +These remain future work rather than release blockers: + +- multi-instance container/volume namespacing; +- automatic dependency-update PRs; +- Docker socket proxy if it can reduce privilege without breaking supported workflows; +- Graphify installation/management; +- additional browser AI UI; +- automatic model downloads beyond the provider defaults. + + +--- + +# Post-implementation cross-image audit — 2026-09-18 + +Audit window: approximately **2026-09-16 13:53 Asia/Dhaka through 2026-09-18**. + +Compared LocalDevStack against the current related releases/main contracts: + +- Tools **0.25** +- Runner **0.5** +- Nginx **0.6** +- Apache **0.4.2** +- LLM-FastFlow **0.01.2** +- LLM-Ollama **0.05** +- Toolset **2.0** +- Scriptomatic current `main` + +The final published/check runs for those release heads are successful. + +## Feature-parity result + +No legacy LocalDevStack command/service/storage feature was removed: + +- all **60** old public/support `lds` functions still exist after modularization; +- all **9** old `bin/*` wrappers remain; +- all **16** old Compose services remain; the current graph has **18** services after adding the two mutually-exclusive LLM provider definitions; +- all **19** old named volumes remain; the current graph has **22** named volumes after adding the two provider stores and Tools durable state. + +New runtime additions are additive: + +- `llm-ollama` and `llm-fastflow` provider definitions (mutually exclusive at runtime); +- `LLMModels` and `LLMFastFlowModels`; +- `ToolsState`; +- AI/QoL/diagnostic commands. + +## Cross-image issues found and corrected + +### Tools durable state + +Tools 0.23.2 owns mutable state under `/etc/share/state` for host-manager/env-store, +profile/runtime state, monitor history and alert acknowledgement state. + +LocalDevStack now persists that directory through the global named volume: + +```text +ToolsState -> /etc/share/state +``` + +This prevents Tools control-plane state from disappearing when `server-tools` is recreated. + +### Root CA export bridge + +Tools exports the public CA to: + +```text +configuration/ssl/rootCA.pem +``` + +Unix and Windows LocalDevStack certificate-install paths now use that current export, +with `configuration/rootCA/rootCA.pem` retained only as a legacy read fallback. + +### TLS export permissions + +Tools deliberately exports opt-in user P12 material with mode `0600`. +`lds setup permissions` now preserves restrictive modes for P12/PFX/key artifacts +instead of widening all files under `configuration/ssl` to group-readable mode. + +### Tools profile visibility + +`COMPOSE_PROFILES` is now passed into `server-tools` so the current Tools status/Admin +diagnostic layer can report LocalDevStack's active profile selection. + +### Moving-image drift + +Because LocalDevStack intentionally follows moving image aliases, the compatibility +workflow now runs weekly even when LocalDevStack source has not changed. + +A scheduled/manual runtime-build smoke also builds the current selected PHP and Node +versions from Tools' runtime catalog against Scriptomatic `main` and verifies the +runtime plus Toolset helper surface. + +## Apache compatibility decision + +Apache 0.4.2 is architecturally an optional backend, and the LocalDevStack CLI already +tracks `APACHE_ACTIVE` / `APACHE_DELETE` state. + +However, the new Tools Admin Panel Host Manager can create/edit Apache hosts directly +and currently does not control LocalDevStack's host-side `COMPOSE_PROFILES` lifecycle. + +Therefore Apache remains an always-created compatibility service in this release. +Making the container profile-only before adding a proper Admin Panel↔LocalDevStack +profile bridge would regress Admin Panel-created Apache hosts. + +This is a non-blocking follow-up, not a release defect. + +## LLM provider capability boundaries + +The published FastFlow and Ollama provider contracts used by this integration are +**linux/amd64** AI runtimes. LocalDevStack remains usable with the `ai` profile disabled +on unsupported platforms. + +LocalDevStack intentionally does not mount a repository/workspace into either provider +by default. Direct model/API/chat/stdin workflows are supported through the active +provider, while repository-aware analysis remains available through the Tools consumer +layer (`lds ai review`, `repo-review`) and explicit provider workspace features when a +user intentionally enables them. + +Exactly one provider runs for a stack. FastFlow owns supported XDNA2 NPU execution; +Ollama owns NVIDIA, AMD ROCm and CPU execution. + +## Lower-layer compatibility notes + +- Tools' own feature-parity contract preserves its pre-hardening CLI/Admin surface. +- Tools' template ABI explicitly targets Nginx 0.4.1, Apache 0.4.2 and Runner 0.5. +- Runner 0.5 has a LocalDevStack-shaped integration smoke. +- Nginx routes use lazy Docker DNS and do not require optional profile services to exist + at Nginx startup. +- Scriptomatic still installs the same PHP/Node runtime Toolset helper surface + (`gitx` + `chromacat`); acquisition changed to the checksum-verified latest-stable + Toolset release installer. +- The PHP template's historical `GID:-root` fallback predates this audit window. + Supported LocalDevStack setup writes a numeric UID/GID before runtime generation, so + it is not a current LocalDevStack release blocker. + +## Readiness conclusion + +After the corrections above and the final 2026-09-20 provider integration, there is no +identified legacy feature loss or current cross-image release blocker. Tools 0.25, +Nginx 0.6, FastFlow 0.01.2 and Ollama 0.05 are validated by the current compatibility +gate. The remaining items are explicit optional/future capability work rather than +regressions. + + +--- + +# Historical interim simplification — fixed infrastructure images + +This section records the pre-FastFlow simplification that established fixed infrastructure +images and ephemeral GPU augmentation. Its Ollama-only provider statements are superseded +by the **Final AI provider architecture override — 2026-09-20** below. + +## Fixed infrastructure images + +Tools, Runner, Nginx, and Apache have no LocalDevStack runtime image variants. Their image names are therefore declared directly in Compose: + +```text +infocyph/tools:latest +infocyph/runner:latest +infocyph/nginx:latest +infocyph/apache:latest +``` + +Do not add `LDS_TOOLS_IMAGE`, `LDS_RUNNER_IMAGE`, `LDS_NGINX_IMAGE`, or `LDS_APACHE_IMAGE` indirection. `docker/release.env` is reserved for release/build defaults that genuinely vary, currently including `SCRIPTOMATIC_REF`. + +## Provider-service evolution + +The earlier implementation temporarily tracked only `llm-ollama` in +`docker/compose/companion.yaml`, with CPU/NVIDIA mapped to `latest` and AMD/ROCm to +`amd-latest`. That intermediate state established the generated NVIDIA/ROCm hardware +augmentation and Nginx-owned loopback publication. + +The final implementation now tracks both provider definitions in the same companion file +and enables exactly one through generated profile selection: + +```text +npu -> llm-fastflow / infocyph/llm-fastflow:latest +nvidia -> llm-ollama / infocyph/llm-ollama:latest +amd -> llm-ollama / infocyph/llm-ollama:amd-latest +cpu -> llm-ollama / infocyph/llm-ollama:latest +``` + +NVIDIA/ROCm hardware augmentation remains ephemeral; FastFlow's XDNA2 +`/dev/accel/accel0` + memlock contract is part of its tracked service definition. +Nginx continues to own loopback-only `127.0.0.1:11434`, now proxying the common +`llm:11434` alias. + +--- + +# Final AI provider architecture override — 2026-09-20 + +This section supersedes every earlier Ollama-only or single-provider-service statement +in this plan where they conflict with the final LocalDevStack implementation. + +## Provider selection + +LocalDevStack tracks two provider service definitions in `docker/compose/companion.yaml`, +but enables exactly one of them for the `ai` profile: + +```text +supported XDNA2 NPU -> llm-fastflow -> infocyph/llm-fastflow:latest +NVIDIA GPU -> llm-ollama -> infocyph/llm-ollama:latest +AMD ROCm GPU -> llm-ollama -> infocyph/llm-ollama:amd-latest +CPU fallback -> llm-ollama -> infocyph/llm-ollama:latest +``` + +`llm-fastflow` and `llm-ollama` are mutually exclusive. They must not be active at the +same time for one LocalDevStack runtime. + +## Common LLM identity + +The selected provider owns the common Docker network alias and normalized internal port: + +```text +llm:11434 +``` + +Provider-neutral consumers use: + +```text +LDS_AI_PROVIDER=llm +LDS_AI_URL=http://llm:11434 +https://llm.localhost/v1 +http://127.0.0.1:11434/v1 +``` + +Provider-specific `llm-ollama.localhost` and `llm-fastflow.localhost` routes are +diagnostic/native identities only. Nginx owns the loopback publication and proxies it +to the common `llm` alias. + +## Model defaults + +The default model follows the runtime: + +```text +FastFlow / NPU -> qwen3.5:9b +Ollama -> qwen3:14b +``` + +Setup leaves `LDS_AI_MODEL` blank by default so the correct provider default can apply. +An explicit user value remains authoritative for the active provider. + +## Runtime selection + +Automatic detection order is XDNA2 NPU, NVIDIA, AMD ROCm, CPU. The explicit selector is: + +```text +lds llm runtime auto|npu|nvidia|amd|cpu +``` + +FastFlow's XDNA2 device/memlock contract is tracked in its service definition. NVIDIA +and ROCm Ollama hardware settings remain temporary Compose augmentation. + +## Persistence + +```text +LLMModels -> Ollama /root/.ollama +LLMFastFlowModels -> FastFlow /models +``` + +Neither provider receives a Docker socket or project/repository bind mount by default. + +## Completion impact + +The release-readiness AI matrix must validate both provider selections, the common +OpenAI-compatible `/v1` route, published `llm-fastflow:latest`, published Ollama images, +and the rule that only one provider service is present in the effective Compose graph. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2d4d294e..60022cde 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -1,137 +1,168 @@ Getting Started =============== -Lets start with simple basics first without too many fuzz. LocalDevStack is a modular Docker-based -local dev stack orchestrated by the ``lds`` CLI and powered by Compose profiles. +LocalDevStack is a Docker-based XAMPP alternative for PHP and Node.js local development. +The ``lds`` CLI manages Compose profiles, local domains, TLS, runtime builds, databases, +admin tools, background jobs, diagnostics, and optional local AI. Prerequisites ------------- -Install Docker on your system first. If you already have Docker installed, you can skip this step. +Install Docker first. -- Recommended: Docker Engine (Linux) for best performance and lowest overhead. -- If Docker Engine is not supported on your OS, use Docker Desktop (Windows/macOS; can also be used on Linux). +Linux + Docker Engine is preferred. Docker Desktop is also usable. -Quick Start ------------ +Windows + Docker Desktop plus Git Bash. ``lds.bat`` bridges Windows invocation into the Bash CLI. -1. Make ``lds`` executable and apply permissions +macOS + Docker Desktop. - Linux/macOS:: +Docker is always a host-side requirement. Some developer utilities such as ``jq``, +``yq``, ``rg``, ``fd``, ``tree``, and ``shellcheck`` can be proxied through a running +``server-tools`` container when they are not installed on the host. - chmod +x ./lds - sudo ./lds setup permissions +Recommended Layout +------------------ - Notes: +A common layout is:: - - On Linux, the permissions step is recommended to avoid common volume/UID permission issues. - - On Windows, you typically run the wrapper (example: ``lds.bat``) and may need to add the project root directory - to your Environment PATH if you want ``lds`` usable from any directory. + project-root/ + ├─ application/ + │ ├─ site1/ + │ ├─ site2/ + │ └─ ... + └─ LocalDevStack/ -2. Start the stack:: +The default project mount points at the sibling ``application/`` directory. Set +``PROJECT_DIR`` in ``docker/.env`` when the application directory lives elsewhere. - lds start +First-Time Setup +---------------- -3. Add a domain (generates vhosts and updates stack selection):: +Clone the repository:: - lds setup domain + git clone https://github.com/infocyph/LocalDevStack.git + cd LocalDevStack -4. (Optional) Generate and trust TLS certificates +On Linux/macOS, prepare permissions and the ``lds`` symlink:: - If you enabled HTTPS vhosts and want browser trust, use the TLS workflow described in: + chmod +x ./lds + sudo ./lds setup permissions - - :doc:`guides/tls-and-certificates` +Initialize workstation defaults:: -Directory Structure -------------------- + ./lds setup init -Recommended project layout -~~~~~~~~~~~~~~~~~~~~~~~~~ +Choose optional service profiles:: -By default, keep your projects in a sibling directory (simple and predictable):: + ./lds setup profile - project-root/ - ├─ application/ - │ ├─ site1/ - │ ├─ site2/ - │ └─ ... - └─ LocalDevStack/ (this repository) +The selector covers PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Elasticsearch, and Local +AI. Re-running it replaces those catalog-managed choices while preserving generated +domain/runtime profiles. + +Start the stack:: -This layout is flexible. If you want a different projects folder, set ``PROJECT_DIR`` in your env. + ./lds up -Example:: +The core stack includes ``server-tools``, Runner, Mailpit, Nginx, and Apache. Apache is +always available so a domain can choose it as its backend when needed. - PROJECT_DIR=../path/to/your/projects # supports relative/absolute path (recommended to use absolute path for less confusion) +Create the First Domain +----------------------- -LocalDevStack layout -~~~~~~~~~~~~~~~~~~~~ +The domain wizard requires the control plane to be running:: -Where things live:: + ./lds setup domain - LocalDevStack/ - ├─ bin/ # optional helper binaries / shims (Don't touch) - ├─ configuration/ # These are created and persisted according to the process you follow (generated automatically) - │ ├─ apache/ # Generated apache vhost configs (if you use apache mode) - │ ├─ nginx/ # Generated nginx vhost configs (primary entry in most setups) - │ ├─ php/ # php.ini and php overrides (The php.ini you see here can be modified) - │ ├─ ssh/ # ssh keys (optional; useful for git over ssh inside containers) - │ ├─ ssl/ # generated TLS certs (can be used for trusting systemwide) - │ └─ rootCA/ # local CA store (The generated root certificate) - ├─ docker/ # internal stack definition (Don't touch) - │ ├─ compose/ # main.yaml + service fragments (http/php/db/tools/... etc) - │ ├─ conf/ # container configuration templates/snippets - │ ├─ data/ # persistent service data (db volumes etc.) - │ └─ logs/ # container logs (if your stack writes here) - ├─ .env # Docker only env (generated by tools, Don't touch) - ├─ lds # main CLI (Linux/macOS) (Don't touch) - └─ lds.bat # Windows wrapper (Don't touch) +The wizard asks for application type, runtime version and routing details. PHP and Node +version selection remains explicit. -Run the server (the easy way) ------------------------------ +List generated domains and validate the graph:: + + lds domain ls + lds config validate + +Trusted HTTPS +------------- + +Once Tools has exported the LocalDevStack root CA, install it on the host. + +Linux:: + + sudo ./lds certificate install + +Windows/Git Bash:: + + lds.bat certificate install + +Windows installs into ``CurrentUser\\Root``. Linux handles supported distro trust stores +and can also update the invoking user's NSS database when ``certutil`` is available. + +macOS users may need to trust ``configuration/ssl/rootCA.pem`` manually in Keychain; +automatic Keychain import is not currently part of the host installer. + +Useful First Checks +------------------- -1. Create or update env files (minimum: profiles + project dir). +:: - Typical locations used by this stack: + lds doctor + lds urls + lds images + lds config show + lds config validate + lds status + lds ps - - ``docker/.env`` (stack settings / profiles) - - ``.env`` (project-level env, exposed to your projects) +``lds config show`` is redacted by default. Use ``--raw`` only when unredacted output is +deliberately required. -2. Start the stack:: +Updating an Existing Installation +--------------------------------- - lds start +On ``lds up`` or ``lds start``, known historical fixed LocalDevStack networks are safely +migrated to dynamic bridge networks when ownership can be proven. Named volumes are +preserved. -3. Add domains via the wizard (recommended):: +After an upgrade:: - lds setup domain + lds doctor + lds config validate -Usage ------ +Refresh/recreate a selected service with:: -Common commands:: + lds rebuild nginx - lds start - lds stop - lds reload - lds restart - lds rebuild +or all resolved services with:: -HTTP utilities:: + lds rebuild all - lds http reload +A normal ``lds restart`` does not intentionally pull newer moving image tags. -Shells:: +Safety Notes +------------ - lds core +``lds support bundle`` is redacted by default; ``--full`` is intentionally raw. -Notes: +``lds clean --yes`` is LocalDevStack-scoped. ``lds clean --global --yes`` performs +host-wide Docker pruning and can affect unrelated projects. -- ``lds core `` is intended to open the right runtime container shell for that domain. +``lds down --volumes --yes`` removes persistent Compose data and should not be part of a +normal update. -Next steps +Next Steps ---------- -- Domain and vhosts: :doc:`guides/domain-setup` -- Local TLS (mkcert + certify): :doc:`guides/tls-and-certificates` -- Node apps behind Nginx: :doc:`guides/node-apps` -- Encrypted secrets (SOPS + Age): :doc:`guides/secrets-sops-age` -- Notifications: :doc:`guides/notifications` +- Architecture: :doc:`concepts/architecture` +- Profiles/environment: :doc:`concepts/profiles-and-env` +- Storage: :doc:`concepts/storage-layout` +- Domains: :doc:`guides/domain-setup` +- Databases/clients: :doc:`guides/databases-and-clients` +- TLS: :doc:`guides/tls-and-certificates` +- Local AI: :doc:`guides/local-ai` +- Operations/support: :doc:`guides/operations-and-support` +- Ad-hoc runner: :doc:`guides/ad-hoc-runner` +- CLI reference: :doc:`reference/cli` diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst new file mode 100644 index 00000000..efbf1ff0 --- /dev/null +++ b/docs/reference/cli.rst @@ -0,0 +1,383 @@ +CLI Reference +============= + +``lds`` is the canonical LocalDevStack host CLI. ``lds.bat`` bridges Windows/Git Bash +invocation into the same command surface. + +Global Options +-------------- + +``-v`` / ``--verbose`` + Enable verbose command/error output. + +``-q`` / ``--quiet`` + Suppress non-error LocalDevStack output where supported. + +``--reload-extras`` + Force a rescan of ``configuration/compose/*.yaml`` / ``*.yml`` before the command. + +``-h`` / ``--help`` + Show help. + +The machine-copyable Markdown command summary is:: + + lds help --markdown + +Stack +----- + +:: + + lds stack up + lds stack start + lds stack down [--volumes --yes] + lds stack restart [service...] + lds stack status [status-args...] + lds stack ps + lds stack logs [service] [--follow] [--since ] [--grep ] + lds stack exec [command...] + lds stack events [since] + lds stack clean --yes [--volumes] [--global] + lds stack diff [--config] [--json] + lds stack config + lds stack http reload + +Common top-level aliases are:: + + lds up + lds start + lds down + lds stop + lds restart + lds reboot + lds status + lds ps + lds logs + lds exec + lds events + lds clean + lds config + +Domains +------- + +:: + + lds domain add + lds domain rm [args...] + lds domain ls + +Legacy aliases:: + + lds host add + lds host rm + lds host list + +Setup +----- + +:: + + lds setup init + lds setup permissions + lds setup domain + lds setup profile + lds setup profiles + +Profiles +-------- + +:: + + lds profiles list + lds profiles add + lds profiles remove + +Configuration +------------- + +:: + + lds config show [--json] [--raw] + lds config services + lds config profiles + lds config env-used + lds config validate + lds images + lds urls + +``config show`` is redacted by default. + +Certificates +------------ + +Tools certificate operations:: + + lds cert status [domain|all] + lds cert regen [domain|all] [--yes] + lds cert diagnose + +Host trust-store operations:: + + lds certificate install + lds certificate uninstall [--all] + +Diagnostics +----------- + +:: + + lds doctor + lds diag dns + lds diag net + lds diag tcp + lds diag http [curl-args...] + lds diag tls + lds sniff [curl-args...] + +``sniff`` is the HTTP diagnostic shortcut. + +Support +------- + +:: + + lds support open + lds support trace + lds support bundle [--redact|--full] [output.zip] + lds support notify ... + lds support ui + +Shortcuts:: + + lds open ... + lds bundle ... + lds notify ... + lds ui + +Tools Control Plane +------------------- + +:: + + lds tools sh + lds tools exec "" + lds tools file + +Open a generic container shell or run a command:: + + lds cli + lds cli + +Resolve a domain/container to its application shell:: + + lds core [domain|container] + +Secrets +------- + +:: + + lds secrets + +This delegates to Tools ``senv``. + +AI Consumer +----------- + +:: + + lds ai status + lds ai ask ... + lds ai explain ... + lds ai troubleshoot ... + lds ai review ... + lds ai repo-review ... + lds ai graphify ... + +``status`` maps to the Tools provider-status flow. + +Host Graphify Workflow +---------------------- + +:: + + lds graphify + lds graphify ./your-project + lds graphify ./your-project --mode deep --token-budget 4000 --max-concurrency 1 + +This command runs the host ``graphify`` CLI against the common LocalDevStack LLM route. +It performs ``extract --backend ollama --no-cluster`` followed by +``cluster-only --backend ollama`` so clustering happens once. Graphify's +backend is currently named ``ollama`` even though LocalDevStack presents a provider-neutral +OpenAI-compatible ``/v1`` endpoint. + +By default it derives: + +- ``OLLAMA_BASE_URL=http://llm.localhost:11434/v1`` through Nginx; +- ``OLLAMA_MODEL`` from the effective provider model; +- ``GRAPHIFY_API_TIMEOUT`` from ``LDS_AI_TIMEOUT``. + +Before extraction, LocalDevStack checks ``/v1/models`` and fails immediately when the +selected model is unavailable. An explicitly supplied ``OLLAMA_BASE_URL`` bypasses that +local-provider preflight. + +LLM Provider +------------ + +:: + + lds llm provider + lds llm runtime + lds llm models + lds llm pull ... + lds llm rm ... + lds llm run ... + lds llm ask ... + lds llm chat ... + lds llm prompt ... + lds llm code ... + lds llm review ... + lds llm json ... + lds llm ai-commit ... + lds llm api ... + lds llm version + lds llm help + +These commands execute the active provider CLI through LocalDevStack's Compose wrapper. +Exactly one provider is active: FastFlow for XDNA2 NPU, otherwise Ollama. + +Provider-specific low-level commands are guarded: + +.. code-block:: text + + Ollama-only: ps, show, unload, ollama + FastFlow-only: validate, check, flm + +Runtime selection:: + + lds llm runtime auto + lds llm runtime npu + lds llm runtime nvidia + lds llm runtime amd + lds llm runtime cpu + +The common Docker/API identity is ``llm:11434`` and the user-facing route is +``https://llm.localhost``. Nginx publishes the common native API loopback-only at +``http://127.0.0.1:11434``. + +Generic service operations also accept ``llm`` and resolve it to the active provider:: + + lds logs llm + lds restart llm + lds exec llm ... + lds rebuild llm + +Provider defaults are ``qwen3.5:9b`` for FastFlow/NPU and ``qwen3:14b`` for Ollama. +Leaving ``LDS_AI_MODEL`` blank allows the runtime-specific default to apply. +Rebuild +------- + +:: + + lds rebuild + lds rebuild all + lds rebuild + +No-argument rebuild uses an interactive selector. + +Ad-hoc Dockerfile Runner +------------------------ + +:: + + lds run + lds run shell + lds run ps + lds run logs + lds run stop + lds run rm + lds run open + +Useful flags include:: + + --name + --tag + --no-build + --no-keepalive + --sock + --host-os + --publish + -p + --mount + --port + --path + --http + --https + +See :doc:`../guides/ad-hoc-runner`. + +Runtime and Client Wrappers +--------------------------- + +PHP/Node:: + + lds php ... + lds composer ... + lds node ... + lds npm ... + lds npx ... + +PostgreSQL:: + + lds pg ... + lds psql ... + lds pg_dump ... + lds pg_restore ... + +MySQL:: + + lds my ... + lds mysql ... + lds mysqldump ... + +MariaDB:: + + lds maria ... + lds mariadb ... + lds mariadb-dump ... + +Redis:: + + lds redis ... + lds redis-cli ... + +MongoDB:: + + lds mongo ... + lds mongodb ... + lds mongosh ... + lds mongoimport ... + lds mongoexport ... + +Elasticsearch:: + + lds es ... + lds elastic ... + lds elasticsearch ... + +Unknown Command Fallback +------------------------ + +If a command is not implemented by ``lds`` itself, LocalDevStack delegates it to +``bin/tool-runner``. This preserves the existing Tools/Toolset command extension surface. + +Deprecated +---------- + +:: + + lds vpn-fix + +The command now explains that fixed-subnet manipulation is obsolete because networking +is dynamically assigned. diff --git a/lds b/lds index 545070c1..9b7ef6bb 100755 --- a/lds +++ b/lds @@ -162,9 +162,10 @@ lds_tools_cmd() { return 127 fi - local -a flags=() - [[ -t 0 ]] && flags+=(-i) - [[ -t 1 ]] && flags+=(-t) + # Keep stdin attached even for pipes/here-strings. Only allocate a TTY when + # both sides are interactive; proxied jq/rg/etc. must behave like host tools. + local -a flags=(-i) + [[ -t 0 && -t 1 ]] && flags+=(-t) "$(bin_path docker)" exec "${flags[@]}" "$ctr" "$cmd" "$@" return $? fi @@ -303,2792 +304,206 @@ DIR="$(dirname -- "$(_realpath "$0")")" CFG="$DIR/docker" ENV_MAIN="$DIR/.env" ENV_DOCKER="$CFG/.env" +ENV_RELEASE="$CFG/release.env" COMPOSE_FILE="$CFG/compose/main.yaml" EXTRAS_DIR="$DIR/configuration/compose" -if [[ "${1:-}" == "--__win_workdir" ]]; then - export WORKDIR_WIN="${2:-}" - shift 2 -fi - -COLOR() { printf '[%sm' "$1"; } -############################################################################### -# Colors + UI (higher contrast; aligned with mkhost.sh) -############################################################################### -# Color control: -# - If stdout isn't a TTY, disable colors by default. -# - If NO_COLOR is set, disable colors. -# - Set LDS_FORCE_COLOR=1 to force colors. -_is_tty() { [[ -t 1 ]]; } - -_use_color=1 -if [[ "${LDS_FORCE_COLOR:-0}" != "1" ]]; then - if [[ -n "${NO_COLOR:-}" ]] || ! _is_tty; then - _use_color=0 - fi -fi - -if ((_use_color)); then - BOLD=$'\033[1m' - DIM=$'\033[2m' - RED=$'\033[1;31m' - GREEN=$'\033[1;32m' - CYAN=$'\033[1;36m' - YELLOW=$'\033[1;33m' - BLUE=$'\033[1;34m' - MAGENTA=$'\033[1;35m' - NC=$'\033[0m' -else - BOLD='' DIM='' RED='' GREEN='' CYAN='' YELLOW='' BLUE='' MAGENTA='' NC='' -fi - -# Output control: -# - --quiet suppresses non-error output -QUIET=0 - -say() { ((QUIET)) || printf '%b\n' "$*"; } -ok() { ((QUIET)) || printf '%b\n' "${GREEN}$*${NC}"; } -warn() { ((QUIET)) || printf '%b\n' "${YELLOW}$*${NC}"; } -err() { printf '%b\n' "${RED}$*${NC}" >&2; } - -# Default behavior: QUIET -VERBOSE=0 - -#─────────────────────────────────────────────────────────────────────────────── -# 0a. GLOBAL ERROR HANDLER -#─────────────────────────────────────────────────────────────────────────────── -command_not_found_handle() { - local unknown="$1" - [[ $unknown == cmd_* ]] && unknown=${unknown#cmd_} - printf "\n%bError:%b Unknown command '%b'\n\n" "$RED" "$NC" "$unknown" - cmd_help - exit 1 -} - -trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR -on_error() { - local code="$1" line="$2" cmd="$3" - local fn="${FUNCNAME[1]:-main}" - local src="${BASH_SOURCE[1]:-$0}" - - printf "\n%bError:%b %s:%s in %s() (exit %d)\n" "$RED" "$NC" "$src" "$line" "$fn" "$code" >&2 - printf "%bCommand:%b %s\n" "$RED" "$NC" "$cmd" >&2 - - if ((VERBOSE)); then - printf "%bStack:%b\n" "$DIM" "$NC" >&2 - local i=1 - while caller "$i" >/dev/null 2>&1; do - caller "$i" >&2 - i=$((i + 1)) - done - fi - printf "\n" >&2 - exit "$code" -} - -############################################################################### -# 1a. DOCKER COMPOSE WRAPPER -############################################################################### - -# ── compose extras (docker/extras/*.y{a,}ml) ──────────────────────────────── -__EXTRAS_LOADED=0 -declare -a __EXTRA_FILES=() - -load_extras() { - # Set LDS_EXTRAS_RELOAD=1 (or global --reload-extras) to re-scan templates every call. - if [[ "${LDS_EXTRAS_RELOAD:-0}" == "1" ]]; then - __EXTRAS_LOADED=0 - fi - - ((__EXTRAS_LOADED)) && return 0 - __EXTRAS_LOADED=1 - - [[ -d "$EXTRAS_DIR" ]] || return 0 - - mapfile -t __EXTRA_FILES < <( - find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null | sort | sed '/^[[:space:]]*$/d' - ) -} - -docker_compose() { - load_extras - # Create required runtime files only when docker/compose operations are invoked. - ((EUID == 0)) || ensure_files_exist "/docker/.env" "/configuration/php/php.ini" "/.env" - if [[ -z "${__LDS_DC_BIN:-}" ]]; then - if docker compose version >/dev/null 2>&1; then - __LDS_DC_BIN=(docker compose) - else - __LDS_DC_BIN=(docker-compose) - fi - fi - - # Build -f list (stable order; later overrides earlier) - local -a extra_f=() f - for f in "${__EXTRA_FILES[@]:-}"; do - [[ -f "$f" ]] || continue - case "$f" in - *.yml | *.yaml) extra_f+=(-f "$f") ;; - esac - done - - local host_os="${HOST_OS:-$(detect_host_os)}" - - HOST_OS="$host_os" "${__LDS_DC_BIN[@]}" \ - --project-directory "$DIR" \ - -f "$COMPOSE_FILE" \ - "${extra_f[@]}" \ - --env-file "$ENV_DOCKER" \ - "$@" -} - -# helper: print project name -lds_project() { printf '%s' "${__LDS_PROJECT:-$(basename -- "$DIR")}"; } - -# (QUIET by default) ──────────────────────────────── -# Centralize quiet/verbose handling for compose subcommands. -# Usage: dc_cmd [args...] -dc_cmd() { - local sub="${1:-}" - shift || true - - local -a quiet=() - if ((VERBOSE == 0)); then - case "$sub" in - up) quiet+=(--quiet-pull) ;; - pull) quiet+=(-q) ;; - build) quiet+=(--quiet) ;; - esac - fi - - docker_compose "$sub" "${quiet[@]}" "$@" -} - -dc_up() { dc_cmd up "$@"; } -dc_pull() { dc_cmd pull "$@"; } -dc_build() { dc_cmd build "$@"; } - -# helper for our own minimal logging (still shows in quiet mode) -logv() { ((VERBOSE)) && printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2 || true; } -logq() { printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2; } - -############################################################################### -# 1b. PROMPTS + DOTENV HELPERS -############################################################################### - -# Unified prompt helper (used by env_init + profiles) -tty_readline() { - # Robust prompt/read across Linux/macOS/WSL/Windows Git Bash. - # Prefer stdin when it is a TTY (normal interactive use). If stdin is not a TTY, - # fall back to /dev/tty when available. - local __var_name="$1" __prompt="$2" __line - - if [[ -t 0 ]]; then - # Interactive: show prompt on stderr (so it is never swallowed) and read stdin. - printf '%s' "$__prompt" >&2 - IFS= read -r __line || return 1 - elif [[ -r /dev/tty ]]; then - # Non-interactive stdin (piped) but we still have a controlling terminal. - printf '%s' "$__prompt" >/dev/tty - IFS= read -r __line keep as-is - if [[ "$v" =~ ^\".*\"$ || "$v" =~ ^\'.*\'$ ]]; then - printf '%s' "$v" - return 0 - fi - - # Leading/trailing whitespace or any internal whitespace or # or quotes => quote - if [[ "$v" =~ ^[[:space:]] || "$v" =~ [[:space:]]$ || "$v" == *$'\t'* || "$v" == *" "* || "$v" == *"#"* || "$v" == *"\""* ]]; then - env_quote "$v" - return 0 - fi - - printf '%s' "$v" -} - -# Escape replacement for sed (delimiter '|') -sed_escape_repl() { - local s=${1-} - s=${s//\\/\\\\} - s=${s//&/\\&} - s=${s//|/\\|} - printf '%s' "$s" -} - -update_env() { - local file=$1 var=$2 val=${3-} - mkdir -p "$(dirname "$file")" - [[ -f "$file" ]] || { - printf "%bFile '%s' not found. Creating one.%b\n" "$YELLOW" "$file" "$NC" - : >"$file" - } - - # Apply quoting only when needed (spaces etc.) - val="$(env_quote_if_needed "$val")" - - # Sed-safe replacement - local val_sed - val_sed="$(sed_escape_repl "$val")" - - var=$(echo "$var" | sed 's/[]\/$*.^|[]/\\&/g') - if grep -qE "^[# ]*$var=" "$file" 2>/dev/null; then - sed -Ei "s|^[# ]*($var)=.*|\1=$val_sed|" "$file" - else - printf "%s=%s\n" "$var" "$val" >>"$file" - fi -} - -############################################################################### -# 1c. HTTP / WEB SERVER HELPERS -############################################################################### - -http_reload() { - printf "%bReloading HTTP...%b" "$MAGENTA" "$NC" - docker ps -qf name=NGINX &>/dev/null && docker exec NGINX nginx -s reload &>/dev/null || true - docker ps -qf name=APACHE &>/dev/null && docker exec APACHE apachectl graceful &>/dev/null || true - printf "\r%bHTTP reloaded! %b\n" "$GREEN" "$NC" -} - -############################################################################### -# 2. INSTALL / PERMISSIONS (HOST) -############################################################################### -add_to_windows_path() { - [[ "$OSTYPE" =~ (msys|cygwin) ]] || return 0 - has_cmd cygpath || return 0 - - # Only add if lds.bat exists where we think it is - [[ -f "$DIR/lds.bat" ]] || return 0 - - local win_repo - win_repo="$(cygpath -w "$DIR")" - - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " - \$t = '$win_repo' - \$cur = [Environment]::GetEnvironmentVariable('Path','User') - if ([string]::IsNullOrWhiteSpace(\$cur)) { \$cur = '' } - - # Normalize (trim + case-insensitive compare) to avoid duplicates - \$parts = \$cur -split ';' | ForEach-Object { \$_.Trim() } | Where-Object { \$_ } - \$exists = \$false - foreach (\$p in \$parts) { if (\$p.ToLowerInvariant() -eq \$t.ToLowerInvariant()) { \$exists = \$true; break } } - - if (-not \$exists) { - \$new = (\$parts + \$t) -join ';' - [Environment]::SetEnvironmentVariable('Path', \$new, 'User') - } - " >/dev/null 2>&1 || true -} - -fix_perms() { - if [[ "$OSTYPE" =~ (msys|cygwin) ]]; then - add_to_windows_path - printf "%bWindows PATH configured.%b\n" "$GREEN" "$NC" - return 0 - fi - - ((EUID == 0)) || die "Please run with sudo." - - chmod 755 "$DIR" - chmod 2775 "$DIR/configuration" - find "$DIR/configuration" -type f ! -perm 664 -exec chmod 664 {} + - - chmod 755 "$DIR/docker" - find "$DIR/docker" -type f ! -perm 644 -exec chmod 644 {} + - - chmod -R 777 "$DIR/logs" - chown -R "$USER:docker" "$DIR/logs" - - chmod 755 "$DIR/bin" - find "$DIR/bin" -type f -exec chmod +x {} + - chmod +x "$DIR/lds" - - ln -fs "$DIR/lds" /usr/local/bin/lds - printf "%bPermissions assigned.%b\n" "$GREEN" "$NC" -} - -############################################################################### -# 3. DOMAIN / PROFILE INTEGRATION -############################################################################### -mkhost() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec "$ctr" mkhost "$@" -} -rmhost() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec "$ctr" rmhost "$@" -} - -setup_domain() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - - mkhost --RESET - docker exec -it "$ctr" mkhost - local mk_state svr_prof - mk_state="$(mkhost --JSON || true)" - if has_tool jq; then - svr_prof="$(printf '%s' "$mk_state" | jq -r '.state.apache_active // empty' 2>/dev/null || true)" - else - svr_prof="$(printf '%s' "$mk_state" | tr -d '\r\n' | sed -n 's/.*"apache_active"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" - fi - [[ -n $svr_prof ]] && modify_profiles add "$svr_prof" - mkhost --RESET - cmd_reboot -} - -delete_domain() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - - rmhost --RESET - - # interactive delete - docker exec -it "$ctr" rmhost "$@" - - local rm_state apache_cont - rm_state="$(rmhost --JSON || true)" - if has_tool jq; then - apache_cont="$(printf '%s' "$rm_state" | jq -r '.state.apache_delete // empty' 2>/dev/null || true)" - else - apache_cont="$(printf '%s' "$rm_state" | tr -d '\r\n' | sed -n 's/.*"apache_delete"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" - fi - [[ -n "$apache_cont" ]] && modify_profiles remove "$apache_cont" - - rmhost --RESET - cmd_reboot -} - -modify_profiles() { - local action=$1 - shift - local file=$ENV_DOCKER var=COMPOSE_PROFILES - local -a existing updated - - if [[ -r $file ]]; then - local line value - line=$(grep -E "^${var}=" "$file" | tail -n1 || true) - value=${line#*=} - IFS=',' read -r -a existing <<<"$value" - fi - - case $action in - add) - local p - for p; do - [[ -n $p && ! " ${existing[*]} " =~ " $p " ]] && updated+=("$p") - done - updated+=("${existing[@]}") - ;; - remove) - local old - for old in "${existing[@]}"; do - [[ ! " $* " =~ " $old " ]] && updated+=("$old") - done - ;; - *) die "modify_profiles: invalid action '$action'" ;; - esac - - update_env "$file" "$var" "$( - IFS=, - echo "${updated[*]}" - )" -} - -# ───────────────────────────────────────────────────────────────────────────── -# Profiles -# ───────────────────────────────────────────────────────────────────────────── - -############################################################################### -# 3a. PROFILES: DEFINITIONS + SETUP FLOW -############################################################################### - -declare -A SERVICES=( - [POSTGRESQL]="postgresql" - [MYSQL]="mysql" - [MARIADB]="mariadb" - [ELASTICSEARCH]="elasticsearch" - [MONGODB]="mongodb" - [REDIS]="redis" -) - -declare -a SERVICE_ORDER=(POSTGRESQL MYSQL MARIADB ELASTICSEARCH MONGODB REDIS) - -declare -A PROFILE_ENV=( - [elasticsearch]="ELASTICSEARCH_VERSION=9.3.0" - [mysql]="MYSQL_VERSION=latest MYSQL_ROOT_PASSWORD=12345 MYSQL_USER=infocyph MYSQL_PASSWORD=12345 MYSQL_DATABASE=localdb" - [mariadb]="MARIADB_VERSION=latest MARIADB_ROOT_PASSWORD=12345 MARIADB_USER=infocyph MARIADB_PASSWORD=12345 MARIADB_DATABASE=localdb" - [mongodb]="MONGODB_VERSION=latest MONGODB_ROOT_USERNAME=root MONGODB_ROOT_PASSWORD=12345" - [redis]="REDIS_VERSION=latest" - [postgresql]="POSTGRES_VERSION=latest POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DATABASE=postgres" -) - -declare -a PENDING_ENVS=() -declare -a PENDING_PROFILES=() - -queue_env() { PENDING_ENVS+=("$1"); } -queue_profile() { PENDING_PROFILES+=("$1"); } - -flush_envs() { - local env_file="$ENV_DOCKER" kv key val - for kv in "${PENDING_ENVS[@]}"; do - IFS='=' read -r key val <<<"$kv" - update_env "$env_file" "$key" "$val" - done -} - -flush_profiles() { - local profile - for profile in "${PENDING_PROFILES[@]}"; do - modify_profiles add "$profile" - done -} - -# ── setup menu (selection-first) ────────────────────────────────────────────── - -setup_menu_print() { - # Print menu to stderr to avoid stdout buffering in some Windows wrappers. - { - printf "\n%bSetup profiles%b (will replace previous configuration, if exists):\n\n" "$CYAN" "$NC" - local i=1 key slug - for key in "${SERVICE_ORDER[@]}"; do - slug="${SERVICES[$key]}" - printf " %2d) %-12s (%s)\n" "$i" "$key" "$slug" - i=$((i + 1)) - done - printf "\n a) ALL\n" - printf " n) NONE / Back\n\n" - } >&2 -} - -# Parse user selection into indices or ALL/NONE (prints one token per line) -setup_menu_parse() { - local input="${1//[[:space:]]/}" - [[ -n "$input" ]] || return 1 - input="${input//;/,}" - - echo "$input" | tr ',' '\n' | awk ' - BEGIN { ok=1 } - /^[0-9]+-[0-9]+$/ { - split($0,a,"-") - if (a[1] > a[2]) { t=a[1]; a[1]=a[2]; a[2]=t } - for (i=a[1]; i<=a[2]; i++) print i - next - } - /^[0-9]+$/ { print $0; next } - /^[aA]$/ { print "ALL"; next } - /^[nN]$/ { print "NONE"; next } - { ok=0 } - END { if (!ok) exit 2 } - ' -} - -# Outputs: newline-separated service KEYS from SERVICE_ORDER (e.g. MYSQL, REDIS) -setup_choose_services() { - local ans parsed - while :; do - setup_menu_print - tty_readline ans "Select (e.g. 1,3,5 or 2-4 or a): " || return 1 - - if ! parsed="$(setup_menu_parse "$ans" 2>/dev/null)"; then - printf "%bInvalid selection.%b Try again.\n" "$YELLOW" "$NC" - continue - fi - - if grep -qx "NONE" <<<"$parsed"; then - return 1 - fi - - if grep -qx "ALL" <<<"$parsed"; then - printf "%s\n" "${SERVICE_ORDER[@]}" - return 0 - fi - - # Indices -> keys (de-dupe, preserve order) - local -A seen=() - local out=() - local idx key - while IFS= read -r idx; do - [[ "$idx" =~ ^[0-9]+$ ]] || continue - ((idx >= 1 && idx <= ${#SERVICE_ORDER[@]})) || continue - key="${SERVICE_ORDER[idx - 1]}" - [[ -n "${seen[$key]:-}" ]] && continue - seen[$key]=1 - out+=("$key") - done <<<"$parsed" - - if ((${#out[@]} == 0)); then - printf "%bNo valid items selected.%b\n" "$YELLOW" "$NC" - continue - fi - - printf "%s\n" "${out[@]}" - return 0 - done -} - -setup_service() { - local service="$1" - local profile="${SERVICES[$service]:-}" - [[ -n "$profile" ]] || die "Unknown service: $service" - - printf "\n%b→ %s%b\n" "$YELLOW" "$service" "$NC" - queue_profile "$profile" - - printf "%bEnter value(s) for %s:%b\n" "$BLUE" "$service" "$NC" - local pair key def val - for pair in ${PROFILE_ENV[$profile]}; do - IFS='=' read -r key def <<<"$pair" - val=$(read_default "$key" "$def") - queue_env "$key=$val" - done -} - -process_all() { - local selected - if ! selected="$(setup_choose_services)"; then - printf "\n%bSetup cancelled.%b\n" "$YELLOW" "$NC" - return 0 - fi - - printf "\n%bWill configure:%b\n" "$CYAN" "$NC" - while IFS= read -r svc; do - printf " - %s (%s)\n" "$svc" "${SERVICES[$svc]}" - done <<<"$selected" - echo - - local svc - while IFS= read -r svc; do - setup_service "$svc" - done <<<"$selected" - - flush_envs - flush_profiles - printf "\n%b✅ Selected services configured!%b\n" "$GREEN" "$NC" -} - -############################################################################### -# 5. ENVIRONMENT + CERT / CA -############################################################################### -detect_timezone() { - if has_cmd timedatectl; then - timedatectl show -p Timezone --value - elif [[ -n ${TZ-} ]]; then - printf '%s' "$TZ" - elif [[ -r /etc/timezone ]]; then - /dev/null | tr -d '\r' - else - date +%Z - fi -} - -env_init() { - local env_file="$ENV_DOCKER" - printf "%bBootstrapping environment defaults…%b\n" "$YELLOW" "$NC" - - local default_tz tz - default_tz="$(detect_timezone)" - tz="$(read_default "Timezone (TZ)" "$default_tz")" - - local default_git_name default_git_email git_name git_email - default_git_name="$(git config --global --get user.name 2>/dev/null || true)" - default_git_email="$(git config --global --get user.email 2>/dev/null || true)" - git_name="$(read_default "Git user.name (GIT_USER_NAME)" "$default_git_name")" - git_email="$(read_default "Git user.email (GIT_USER_EMAIL)" "$default_git_email")" - - # update_env now quotes automatically when needed - update_env "$env_file" "TZ" "$tz" - update_env "$env_file" "GIT_USER_NAME" "$git_name" - update_env "$env_file" "GIT_USER_EMAIL" "$git_email" - - printf "%bConfiguration saved!%b\n" "$GREEN" "$NC" -} - -# ───────────────────────────────────────────────────────────────────────────── -# Root CA helpers (cross-distro) -# ───────────────────────────────────────────────────────────────────────────── - -# Unique identity (avoid conflicts with other mkcert/dev CAs) -CA_BASENAME="localdevstack-rootca" -CA_NICK="LocalDevStack Root CA" - -detect_os_family() { - # Output: "id|like" - # Must never fail under set -e - if [[ "${OSTYPE:-}" =~ (msys|cygwin|win32) ]]; then - echo "windows|windows" - return 0 - fi - - local id like - id="unknown" - like="unknown" - - if [[ -r /etc/os-release ]]; then - # shellcheck disable=SC1091 - . /etc/os-release || true - id="${ID:-unknown}" - like="${ID_LIKE:-unknown}" - elif has_cmd uname; then - # fallback for macOS / other unix - case "$(uname -s 2>/dev/null || true)" in - Darwin) - id="macos" - like="darwin" - ;; - Linux) - id="linux" - like="linux" - ;; - esac - fi - - echo "$id|$like" -} - -# Decide destination path + update mechanism. Echo: family|dest|updater -ca_plan() { - local os_id os_like - IFS='|' read -r os_id os_like < <(detect_os_family) - - case " $os_id $os_like " in - *" debian "* | *" ubuntu "* | *" linuxmint "* | *" pop "* | *" raspbian "*) - printf "debian|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|update-ca-certificates\n" - ;; - *" alpine "*) - printf "alpine|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|update-ca-certificates\n" - ;; - *" fedora "* | *" rhel "* | *" redhat "* | *" centos "* | *" rocky "* | *" alma "* | *" amzn "* | *" amazon "* | *" sles "* | *" suse "*) - printf "rhel|/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.crt|update-ca-trust\n" - ;; - *" arch "* | *" manjaro "*) - printf "arch|/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.crt|trust\n" - ;; - *) - # best default: Debian-style location (works on many distros even if updater differs) - printf "fallback|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|\n" - ;; - esac -} - -is_windows_shell() { - [[ "${OSTYPE:-}" =~ (msys|cygwin) ]] || [[ -n "${WORKDIR_WIN:-}" ]] -} - -need_windows_tools() { - has_cmd cygpath || die "Windows certificate install needs 'cygpath' (Git Bash)." - has_cmd powershell.exe || die "Windows certificate install needs 'powershell.exe' on PATH." -} - -# Import CA into the invoking user's NSS DB (Chrome/Chromium/Firefox on many Linux setups) -install_ca_nss_user() { - local ca_file="$1" - has_cmd certutil || return 0 - - local user="${SUDO_USER:-}" - [[ -n "$user" && "$user" != "root" ]] || return 0 - - local home - home="$(getent passwd "$user" | cut -d: -f6)" - [[ -n "$home" && -d "$home" ]] || return 0 - - local nssdb="sql:${home}/.pki/nssdb" - sudo -u "$user" mkdir -p "${home}/.pki/nssdb" >/dev/null 2>&1 || true - - if sudo -u "$user" certutil -d "$nssdb" -L 2>/dev/null | grep -Fq "$CA_NICK"; then - printf "%b✔ NSS already has CA%b (%s)\n" "$GREEN" "$NC" "$user" - return 0 - fi - - if sudo -u "$user" certutil -d "$nssdb" -A -n "$CA_NICK" -t "C,," -i "$ca_file" >/dev/null 2>&1; then - printf "%b✔ Imported CA into NSS%b (%s)\n" "$GREEN" "$NC" "$user" - else - printf "%bWARN%b: NSS import failed (certutil).\n" "$YELLOW" "$NC" >&2 - fi -} - -install_ca_windows() { - need_windows_tools - - local src_ca="$DIR/configuration/rootCA/rootCA.pem" - [[ -r "$src_ca" ]] || die "certificate not found: $src_ca" - - local win_ca - win_ca="$(cygpath -w "$src_ca")" - - printf "%bInstalling root CA into Windows trust store (CurrentUser\\Root)…%b\n" "$CYAN" "$NC" - - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " - \$ErrorActionPreference = 'Stop' - \$path = '$win_ca' - \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$path) - \$cert.FriendlyName = '$CA_NICK' - - \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser') - \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - - \$exists = \$store.Certificates | Where-Object { \$_.Thumbprint -eq \$cert.Thumbprint } - if (-not \$exists) { \$store.Add(\$cert) } - - \$store.Close() - " >/dev/null 2>&1 || die "Windows certificate install failed (PowerShell import)." - - printf "%bRoot CA installed on Windows%b (CurrentUser\\Root) as %s\n" "$GREEN" "$NC" "$CA_NICK" - printf "%bNote:%b restart browsers if they still show trust errors.\n" "$YELLOW" "$NC" -} - -install_ca() { - if is_windows_shell; then - install_ca_windows - return 0 - fi - - local src_ca="$DIR/configuration/rootCA/rootCA.pem" - [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate install requires sudo" - [[ -r "$src_ca" ]] || die "certificate not found: $src_ca" - - local family dest updater os_id os_like - IFS='|' read -r os_id os_like < <(detect_os_family) - IFS='|' read -r family dest updater < <(ca_plan) - - printf "%bInstalling root CA…%b\n" "$CYAN" "$NC" - printf "%bDetected OS%b: id=%s like=%s → %s\n" "$CYAN" "$NC" "$os_id" "$os_like" "$family" - - install -d -m 755 "$(dirname "$dest")" - install -m 644 "$src_ca" "$dest" - printf "%b✔ Copied%b → %s\n" "$GREEN" "$NC" "$dest" - - case "$family" in - debian | alpine) - if has_cmd update-ca-certificates; then - printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" - if update-ca-certificates; then - printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" - printf "%bNote:%b If you see \"rehash: skipping ca-certificates.crt…\", that’s normal (it’s a bundle).\n" "$YELLOW" "$NC" - else - printf "%bWARN%b: update-ca-certificates failed. CA is installed but may not be active yet.\n" "$YELLOW" "$NC" >&2 - fi - else - printf "%bWARN%b: update-ca-certificates not found. CA is installed but auto-update is unavailable.\n" "$YELLOW" "$NC" >&2 - fi - - # Optional p11-kit sync: best-effort only (can be missing helper on minimal installs) - if has_cmd trust; then - printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" - if trust extract-compat >/dev/null 2>&1; then - printf "%b✔ p11-kit trust synced%b\n" "$GREEN" "$NC" - else - printf "%bWARN%b: trust extract-compat failed (helper missing on some installs). Skipping.\n" "$YELLOW" "$NC" >&2 - fi - else - printf "%bINFO%b: 'trust' not found — skipping p11-kit sync.\n" "$YELLOW" "$NC" - fi - ;; - rhel) - if has_cmd update-ca-trust; then - printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" - if update-ca-trust extract; then - printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" - else - printf "%bWARN%b: update-ca-trust extract failed. CA is installed but may not be active yet.\n" "$YELLOW" "$NC" >&2 - fi - else - printf "%bWARN%b: update-ca-trust not found. CA is installed but auto-update is unavailable.\n" "$YELLOW" "$NC" >&2 - fi - ;; - arch) - if has_cmd trust; then - printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" - if trust extract-compat >/dev/null 2>&1; then - printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" - else - printf "%bWARN%b: trust extract-compat failed. CA is installed, but trust sync may be incomplete.\n" "$YELLOW" "$NC" >&2 - fi - else - printf "%bWARN%b: 'trust' not found. CA is installed, but trust sync is unavailable.\n" "$YELLOW" "$NC" >&2 - fi - ;; - *) - printf "%bINFO%b: Unknown distro; CA copied to %s.\n" "$YELLOW" "$NC" "$dest" - printf "%bINFO%b: You may need to update trust store manually for your OS.\n" "$YELLOW" "$NC" - ;; - esac - - # Extra: ensure browsers that rely on NSS trust pick it up - install_ca_nss_user "$src_ca" - - printf "%bRoot CA installed%b → %s (%s)\n" "$GREEN" "$NC" "$dest" "$CA_NICK" -} - -uninstall_ca_windows() { - need_windows_tools - - local src_ca="$DIR/configuration/rootCA/rootCA.pem" - [[ -r "$src_ca" ]] || die "certificate not found: $src_ca" - - local win_ca - win_ca="$(cygpath -w "$src_ca")" - - printf "%bUninstalling root CA from Windows trust store (CurrentUser\\Root)…%b\n" "$CYAN" "$NC" - - local removed - removed="$(powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " - \$ErrorActionPreference = 'Stop' - \$path = '$win_ca' - \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$path) - \$thumb = \$cert.Thumbprint - - \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser') - \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - - \$matches = @(\$store.Certificates | Where-Object { \$_.Thumbprint -eq \$thumb }) - foreach (\$c in \$matches) { \$store.Remove(\$c) } - - \$store.Close() - [string]\$matches.Count - " 2>/dev/null || true)" - - removed="${removed//[$'\r\n\t ']/}" - if [[ "${removed:-0}" =~ ^[0-9]+$ ]] && ((removed > 0)); then - printf "%bRoot CA uninstalled on Windows%b (removed %s cert)\n" "$GREEN" "$NC" "$removed" - else - printf "%bRoot CA already absent on Windows%b (no matching cert)\n" "$YELLOW" "$NC" - fi -} - -uninstall_ca_nss_user() { - has_cmd certutil || return 0 - local user="${SUDO_USER:-}" - [[ -n "$user" && "$user" != "root" ]] || return 0 - - local home - home="$(getent passwd "$user" | cut -d: -f6)" - [[ -n "$home" && -d "$home" ]] || return 0 - - local nssdb="sql:${home}/.pki/nssdb" - if sudo -u "$user" certutil -d "$nssdb" -L 2>/dev/null | grep -Fq "$CA_NICK"; then - sudo -u "$user" certutil -d "$nssdb" -D -n "$CA_NICK" >/dev/null 2>&1 || true - printf "%b✔ Removed CA from NSS%b (%s)\n" "$GREEN" "$NC" "$user" - fi -} - -uninstall_ca() { - if is_windows_shell; then - uninstall_ca_windows - return 0 - fi - - [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate uninstall requires sudo" - - local all=0 - if [[ "${1:-}" == "--all" ]]; then - all=1 - shift - fi - - local family dest updater os_id os_like - IFS='|' read -r os_id os_like < <(detect_os_family) - IFS='|' read -r family dest updater < <(ca_plan) - - printf "%bUninstalling root CA…%b\n" "$CYAN" "$NC" - printf "%bDetected OS%b: id=%s like=%s → %s\n" "$CYAN" "$NC" "$os_id" "$os_like" "$family" - - local removed=0 - - if [[ -e "$dest" ]]; then - rm -f "$dest" - removed=$((removed + 1)) - printf "%b✔ Removed%b → %s\n" "$GREEN" "$NC" "$dest" - else - printf "%bINFO%b: CA file not found at %s (nothing to remove)\n" "$YELLOW" "$NC" "$dest" - fi - - if ((all)); then - printf "%bScanning all known CA anchor paths…%b\n" "$CYAN" "$NC" - local f - for f in \ - "/usr/local/share/ca-certificates/${CA_BASENAME}.crt" \ - "/usr/local/share/ca-certificates/${CA_BASENAME}.pem" \ - "/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.crt" \ - "/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.pem" \ - "/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.crt" \ - "/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.pem"; do - [[ "$f" == "$dest" ]] && continue - if [[ -e "$f" ]]; then - rm -f "$f" - removed=$((removed + 1)) - printf "%b✔ Removed%b → %s\n" "$GREEN" "$NC" "$f" - fi - done - fi - - case "$family" in - debian | alpine) - if has_cmd update-ca-certificates; then - printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" - update-ca-certificates || printf "%bWARN%b: update-ca-certificates failed.\n" "$YELLOW" "$NC" >&2 - else - printf "%bWARN%b: update-ca-certificates not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 - fi - - if has_cmd trust; then - printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" - trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed. Skipping.\n" "$YELLOW" "$NC" >&2 - fi - ;; - rhel) - if has_cmd update-ca-trust; then - printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" - update-ca-trust extract || printf "%bWARN%b: update-ca-trust extract failed.\n" "$YELLOW" "$NC" >&2 - else - printf "%bWARN%b: update-ca-trust not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 - fi - ;; - arch) - if has_cmd trust; then - printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" - trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed.\n" "$YELLOW" "$NC" >&2 - else - printf "%bWARN%b: 'trust' not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 - fi - ;; - *) - if has_cmd update-ca-certificates; then - printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" - update-ca-certificates || true - fi - if has_cmd update-ca-trust; then - printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" - update-ca-trust extract || true - fi - if has_cmd trust; then - printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" - trust extract-compat >/dev/null 2>&1 || true - fi - printf "%bINFO%b: Unknown distro; removed CA file(s) if present. Refresh trust store manually if needed.\n" "$YELLOW" "$NC" - ;; - esac - - uninstall_ca_nss_user - - if ((removed)); then - printf "%bRoot CA uninstalled%b (removed %d file(s))\n" "$GREEN" "$NC" "$removed" - else - printf "%bRoot CA already absent%b (no files removed)\n" "$YELLOW" "$NC" - fi -} - -add_required_env() { - update_env "$ENV_DOCKER" WORKING_DIR "$DIR" - ((EUID == 0)) && return 0 - update_env "$ENV_DOCKER" USER "$(id -un)" - update_env "$ENV_DOCKER" UID "$(id -u)" - update_env "$ENV_DOCKER" GID "$(id -g)" -} - -############################################################################### -# Compose helpers for rebuild (robust: supports service key OR container name) -############################################################################### -__COMPOSE_CFG_JSON="" -__COMPOSE_CFG_YAML="" -__COMPOSE_SVCS_LOADED=0 -declare -a __COMPOSE_SVCS=() - -compose_cfg_json() { - if [[ -z "${__COMPOSE_CFG_JSON}" ]]; then - __COMPOSE_CFG_JSON="$(docker_compose config --format json 2>/dev/null || true)" - fi - printf '%s' "${__COMPOSE_CFG_JSON}" -} - -compose_cfg_yaml() { - if [[ -z "${__COMPOSE_CFG_YAML}" ]]; then - __COMPOSE_CFG_YAML="$(docker_compose config 2>/dev/null || true)" - fi - printf '%s' "${__COMPOSE_CFG_YAML}" -} - -compose_services_load() { - ((__COMPOSE_SVCS_LOADED)) && return 0 - mapfile -t __COMPOSE_SVCS < <(docker_compose config --services 2>/dev/null || true) - __COMPOSE_SVCS_LOADED=1 -} - -compose_service_exists() { - local want="${1:-}" s - [[ -n "$want" ]] || return 1 - compose_services_load - for s in "${__COMPOSE_SVCS[@]}"; do - [[ "$s" == "$want" ]] && return 0 - done - return 1 -} - -resolve_service() { - local raw="${1:-}" norm svc - raw="${raw//[[:space:]]/}" - [[ -n "$raw" ]] || { - printf '' - return 0 - } - - compose_service_exists "$raw" && { - printf '%s' "$raw" - return 0 - } - - norm="$(normalize_service "$raw")" - compose_service_exists "$norm" && { - printf '%s' "$norm" - return 0 - } - - if docker inspect "$raw" >/dev/null 2>&1; then - svc="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$raw" 2>/dev/null || true)" - if [[ -n "$svc" ]] && compose_service_exists "$svc"; then - printf '%s' "$svc" - return 0 - fi - fi - - printf '%s' "$norm" -} - -compose_has_build() { - local svc="$1" json - json="$(compose_cfg_json)" - if [[ -n "$json" ]]; then - if has_tool jq; then - jq -e --arg s "$svc" '.services[$s].build != null' >/dev/null <<<"$json" - return $? - fi - fi - - compose_cfg_yaml | awk -v s="$svc" ' - $1=="services:" {in_services=1; next} - in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} - in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 1} - in_svc && $0 ~ /^ build:/ {exit 0} - END {exit 1} - ' -} - -compose_image_for_service() { - local svc="$1" json - json="$(compose_cfg_json)" - if [[ -n "$json" ]]; then - if has_tool jq; then - jq -r --arg s "$svc" '.services[$s].image // empty' <<<"$json" - return 0 - fi - fi - - compose_cfg_yaml | awk -v s="$svc" ' - $1=="services:" {in_services=1; next} - in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} - in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 0} - in_svc && $0 ~ /^ image:/ { - sub(/^ image:[[:space:]]*/, "", $0) - print $0 - exit 0 - } - ' -} - -############################################################################### -# 6. STACK COMMANDS (CLI) -############################################################################### -cmd_up() { dc_up "$@"; } - -cmd_start() { - dc_up -d "$@" - http_reload -} - -cmd_stop() { docker_compose down; } - -cmd_down() { - # Safety rails: - # lds down --volumes requires --yes - local yes=0 vols=0 - local -a args=() - while [[ "${1:-}" ]]; do - case "$1" in - --yes | -y) - yes=1 - shift - ;; - --volumes | -v) - vols=1 - args+=("--volumes") - shift - ;; - --remove-orphans) - args+=("--remove-orphans") - shift - ;; - *) - args+=("$1") - shift - ;; - esac - done - if ((vols)) && ((yes == 0)); then - die "Refusing: down --volumes requires --yes" - fi - docker_compose down "${args[@]}" -} - -cmd_restart() { - cmd_stop - cmd_start -} -cmd_reboot() { cmd_restart; } - -# ───────────────────────────────────────────────────────────────────────────── -# 6a. STATUS / PS / STATS -# ───────────────────────────────────────────────────────────────────────────── -cmd_ps() { - if (($#)); then - docker_compose ps "$@" - else - docker_compose ps - fi -} - -cmd_status() { - local ctr project - project="$(lds_project)" - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container not found or not running for project: $project" - - local -a flags=() - [[ -t 1 ]] && flags+=(-t) - docker exec "${flags[@]}" "$ctr" status "$@" -} -# ───────────────────────────────────────────────────────────────────────────── -# 6b. LOGS / OPEN -# ───────────────────────────────────────────────────────────────────────────── -cmd_logs() { - local svc="" follow=0 since="" grep_pat="" - while [[ "${1:-}" ]]; do - case "$1" in - -f | --follow) - follow=1 - shift - ;; - --since) - since="${2:-}" - shift 2 - ;; - --grep) - grep_pat="${2:-}" - shift 2 - ;; - *) - svc="${1:-}" - shift - ;; - esac - done - - local -a args=() - ((follow)) && args+=("-f") - [[ -n "$since" ]] && args+=("--since" "$since") - - if [[ -n "$svc" ]]; then - local s - s="$(resolve_service "$svc" || true)" - [[ -n "$s" ]] || die "Unknown service: $svc" - if [[ -n "$grep_pat" ]]; then - docker_compose logs "${args[@]}" "$s" 2>&1 | text_grep "$grep_pat" - else - docker_compose logs "${args[@]}" "$s" - fi - else - if [[ -n "$grep_pat" ]]; then - docker_compose logs "${args[@]}" 2>&1 | text_grep "$grep_pat" - else - docker_compose logs "${args[@]}" - fi - fi -} - -cmd_open() { - local target="${1:-}" - [[ -n "$target" ]] || die "open " - local url="" - case "${target,,}" in - mail | mailpit | webmail) url="https://webmail.localhost" ;; - db | cloudbeaver) url="https://db.localhost" ;; - redis | redisinsight | redis-insight | rds) url="http://ri.localhost" ;; - mongo | me | mongoexpress | mongo-express) url="http://me.localhost" ;; - kibana | kbn) url="http://kibana.localhost" ;; - *) - url="https://${target}" - ;; - esac - open_url "$url" -} - -# ───────────────────────────────────────────────────────────────────────────── -# 6c. PROFILES -# ───────────────────────────────────────────────────────────────────────────── -_known_profile() { - local p="${1:-}" - [[ -n "$p" ]] || return 1 - - # Prefer compose-config JSON for exact profile membership. - local json - json="$(compose_cfg_json)" - if [[ -n "$json" ]] && has_tool jq; then - printf '%s' "$json" | jq -e --arg p "$p" ' - [ .services[]? | (.profiles // [])[] ] | index($p) != null - ' >/dev/null 2>&1 - return $? - fi - - # Fallback: text scan when jq/json path is unavailable. - local f - for f in "$COMPOSE_FILE" "${__EXTRA_FILES[@]:-}"; do - [[ -r "$f" ]] || continue - grep -Fq -- "$p" "$f" && return 0 - done - return 1 -} - -cmd_profiles() { - local action="${1:-list}" - shift || true - case "${action,,}" in - list | "") - local cur="" - [[ -r "$ENV_DOCKER" ]] && cur="$(grep -E '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)" - printf "%bEnabled profiles:%b %s -" "$CYAN" "$NC" "${cur:-}" - printf "%bAvailable profiles:%b -" "$CYAN" "$NC" - printf ' - %s -' "${SERVICES[@]}" | LC_ALL=C sort -u - # warn if enabled profile has no mention in compose - if [[ -n "$cur" ]]; then - local p - IFS=',' read -r -a __ps <<<"$cur" - for p in "${__ps[@]}"; do - p="${p//[[:space:]]/}" - [[ -n "$p" ]] || continue - _known_profile "$p" || printf "%b[warn]%b enabled profile '%s' has no matching services in compose -" "$YELLOW" "$NC" "$p" - done - fi - ;; - add) - [[ $# -gt 0 ]] || die "profiles add " - for p in "$@"; do - modify_profiles add "$p" - done - ;; - remove | rm | del) - [[ $# -gt 0 ]] || die "profiles remove " - modify_profiles remove "$@" - ;; - *) - die "profiles " - ;; - esac -} - -# ───────────────────────────────────────────────────────────────────────────── -# 6d. DIAG / SNIFF -# ───────────────────────────────────────────────────────────────────────────── -_tools_exec() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - # NOTE: pass a SINGLE command string; do not pass arrays here. - docker exec -i "$ctr" sh -lc "$*" -} - -_shq() { printf '%q' "$1"; } - -cmd_diag() { - local sub="${1:-}" - shift || true - - case "${sub,,}" in - dns) - local dom="${1:-}" - [[ -n "$dom" ]] || die "diag dns " - local qdom - qdom="$(_shq "$dom")" - _tools_exec "dig +short $qdom; echo; nslookup $qdom 2>/dev/null || true; echo; getent hosts $qdom 2>/dev/null || true" - ;; - route | net) - _tools_exec "ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || netstat -tulpen 2>/dev/null || true" - ;; - tcp) - local h="${1:-}" - local p="${2:-}" - [[ -n "$h" && -n "$p" ]] || die "diag tcp " - _tools_exec "nc -vz -w2 $(_shq "$h") $(_shq "$p")" - ;; - http) - local url="${1:-}" - shift || true - [[ -n "$url" ]] || die "diag http [curl-args...]" - local -a qargs=() - local a - for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done - _tools_exec "curl -vkI $(_shq "$url") ${qargs[*]}" - ;; - tls) - local dom="${1:-}" - [[ -n "$dom" ]] || die "diag tls " - local qdom - qdom="$(_shq "$dom")" - _tools_exec "echo | openssl s_client -connect ${qdom}:443 -servername $qdom -showcerts 2>/dev/null | sed -n '1,60p'" - ;; - *) - die "diag " - ;; - esac -} - -cmd_sniff() { - local url="${1:-}" - shift || true - [[ -n "$url" ]] || die "sniff [curl-args...]" - local -a qargs=() - local a - for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done - _tools_exec "curl -vk -D - $(_shq "$url") ${qargs[*]} | (command -v jq >/dev/null 2>&1 && jq . 2>/dev/null || cat)" -} - -# ───────────────────────────────────────────────────────────────────────────── -# 6e. SECRETS / CERT / HOST / UI -# ───────────────────────────────────────────────────────────────────────────── -cmd_secrets() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec -it "$ctr" senv "$@" -} - -cmd_cert() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec -it "$ctr" certify "$@" -} - -cmd_host() { - local sub="${1:-}" - shift || true - case "${sub,,}" in - add) - setup_domain - ;; - rm | remove | del | delete) - delete_domain "$@" - ;; - list) - shopt -s nullglob - for f in "$DIR/configuration/nginx/"*.conf; do - printf '%s -' "$(basename -- "$f" .conf)" - done - shopt -u nullglob - ;; - *) - die "host " - ;; - esac -} - -cmd_ui() { - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec -it "$ctr" lazydocker -} - -# ───────────────────────────────────────────────────────────────────────────── -# 6f. EXEC / EVENTS / CLEAN / DISK -# ───────────────────────────────────────────────────────────────────────────── -cmd_exec() { - local svc="${1:-}" - shift || true - [[ -n "$svc" ]] || die "exec [cmd...]" - local s - s="$(resolve_service "$svc" || true)" - [[ -n "$s" ]] || die "Unknown service: $svc" - if [[ $# -gt 0 ]]; then - docker_compose exec "$s" "$@" - else - docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' - fi -} - -cmd_events() { - local since="${1:-1h}" - local project - project="$(lds_project)" - docker events --since "$since" --filter "label=com.docker.compose.project=$project" -} - -cmd_clean() { - local yes=0 vols=0 - while [[ "${1:-}" ]]; do - case "$1" in - --yes | -y) - yes=1 - shift - ;; - --volumes | -v) - vols=1 - shift - ;; - *) - die "clean [--yes|-y] [--volumes|-v]" - ;; - esac - done - - ((yes)) || die "clean requires --yes" - - printf "%b[clean]%b pruning stopped containers...\n" "$CYAN" "$NC" - docker container prune -f >/dev/null 2>&1 || true - - printf "%b[clean]%b pruning unused networks...\n" "$CYAN" "$NC" - docker network prune -f >/dev/null 2>&1 || true - - printf "%b[clean]%b pruning unused images...\n" "$CYAN" "$NC" - docker image prune -a -f >/dev/null 2>&1 || true - - printf "%b[clean]%b pruning build cache...\n" "$CYAN" "$NC" - docker builder prune -a -f >/dev/null 2>&1 || true - - if ((vols)); then - printf "%b[clean]%b pruning unused volumes...\n" "$CYAN" "$NC" - docker volume prune -f >/dev/null 2>&1 || true - fi - - printf "%b[clean]%b done\n" "$GREEN" "$NC" -} - -# ───────────────────────────────────────────────────────────────────────────── -# 6g. HELP MARKDOWN -# ───────────────────────────────────────────────────────────────────────────── - -normalize_service() { - local raw="${1:-}" - local s="${raw//[[:space:]]/}" - [[ -n "$s" ]] || { - printf '%s' "" - return 0 - } - - local low="${s,,}" - - local key="${low//_/}" - key="${key//-/}" - if [[ "$key" =~ ^php ]]; then - local ver="${key#php}" - ver="${ver//[^0-9]/}" - if [[ "$ver" =~ ^([0-9])([0-9]).* ]]; then - printf 'php%s%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" - return 0 - fi - printf 'php' - return 0 - fi - - low="${low//_/-}" - while [[ "$low" == *"--"* ]]; do low="${low//--/-}"; done - printf '%s' "$low" -} - -cmd_rebuild() { - local -a targets=() all_svcs=() - local arg svc img - declare -A seen=() - - # ----------------------------- - # helper: add a service once - # ----------------------------- - _add_target() { - local s="$1" - [[ -n "$s" ]] || return 0 - [[ -n "${seen[$s]:-}" ]] && return 0 - seen[$s]=1 - targets+=("$s") - } - - # ----------------------------- - # helper: trim - # ----------------------------- - _trim() { - local s="$1" - s="${s#"${s%%[![:space:]]*}"}" - s="${s%"${s##*[![:space:]]}"}" - printf '%s' "$s" - } - - # ----------------------------- - # helper: interactive selection (comma separated, supports ranges) - # accepts: "all" or "1,3,5-7" or mix with names "nginx,2,5-6" - # ----------------------------- - _pick_targets_interactive() { - compose_services_load - all_svcs=("${__COMPOSE_SVCS[@]}") - if ((${#all_svcs[@]})); then - mapfile -t all_svcs < <(printf '%s\n' "${all_svcs[@]}" | LC_ALL=C sort -f -u) - fi - [[ ${#all_svcs[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" - - echo - echo "Select services to rebuild (comma separated; ranges allowed)." - echo "Examples: 1,3,5-7 | nginx,2,5-6 | all" - echo - - local i - for i in "${!all_svcs[@]}"; do - printf " %2d) %s\n" "$((i + 1))" "${all_svcs[$i]}" - done - - echo - local sel - read -r -p "Pick: " sel - sel="$(_trim "${sel:-}")" - [[ -n "$sel" ]] || die "No selection provided." - - if [[ "${sel,,}" == "all" ]]; then - for svc in "${all_svcs[@]}"; do _add_target "$svc"; done - return 0 - fi - - # split by comma - local IFS=, - for arg in $sel; do - arg="$(_trim "$arg")" - [[ -n "$arg" ]] || continue - - # range like 3-7 - if [[ "$arg" =~ ^[0-9]+-[0-9]+$ ]]; then - local a b - a="${arg%-*}" - b="${arg#*-}" - ((a >= 1)) || continue - ((b >= 1)) || continue - ((a <= b)) || { - local t="$a" - a="$b" - b="$t" - } - - local n - for ((n = a; n <= b; n++)); do - ((n >= 1 && n <= ${#all_svcs[@]})) || continue - _add_target "${all_svcs[$((n - 1))]}" - done - continue - fi - - # single index - if [[ "$arg" =~ ^[0-9]+$ ]]; then - local n="$arg" - ((n >= 1 && n <= ${#all_svcs[@]})) || continue - _add_target "${all_svcs[$((n - 1))]}" - continue - fi - - # treat as service/container name - svc="$(resolve_service "$arg")" - [[ -n "$svc" ]] && _add_target "$svc" - done - - [[ ${#targets[@]} -gt 0 ]] || die "No valid services selected." - } - - # ----------------------------- - # build target list - # ----------------------------- - if (($# == 0)); then - _pick_targets_interactive - elif [[ "${1,,}" == "all" ]]; then - compose_services_load - targets=("${__COMPOSE_SVCS[@]}") - [[ ${#targets[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" - else - for arg in "$@"; do - svc="$(resolve_service "$arg")" - [[ -n "$svc" ]] || continue - _add_target "$svc" - done - [[ ${#targets[@]} -gt 0 ]] || die "No valid services provided." - fi - - # ----------------------------- - # rebuild each target - # ----------------------------- - for svc in "${targets[@]}"; do - [[ -n "$svc" ]] || continue - compose_service_exists "$svc" || die "Unknown service/container: '$svc'" - - if compose_has_build "$svc"; then - logq rebuild "build/recreate $svc" - dc_build --no-cache --pull "$svc" - dc_up -d --no-deps --force-recreate "$svc" - continue - fi - - img="$(compose_image_for_service "$svc")" - logq rebuild "pull/recreate $svc${img:+ ($img)}" - - docker_compose rm -sf "$svc" >/dev/null 2>&1 || true - - if [[ -n "${img:-}" ]]; then - docker rmi -f "$img" >/dev/null 2>&1 || true - dc_pull "$svc" || true - else - dc_build --no-cache --pull "$svc" >/dev/null 2>&1 || true - fi - - dc_up -d --no-deps --force-recreate "$svc" - done - logq reboot "Rebooting stacks" - cmd_reboot -} - -cmd_config() { docker_compose config; } - -docker_shell() { - local c="${1:-}" - [[ -n "$c" ]] || die "container name required" - if docker exec "$c" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then - exec docker exec -it "$c" bash - else - exec docker exec -it "$c" sh - fi -} -cmd_tools() { - local sub="${1:-sh}" - shift || true - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - case "${sub,,}" in - sh | shell | "") - docker_shell "$ctr" - ;; - exec) - [[ $# -gt 0 ]] || die "tools exec " - docker exec -it "$ctr" sh -lc "$*" - ;; - file) - local p="${1:-}" - [[ -n "$p" ]] || die "tools file " - docker exec -it "$ctr" sh -lc "ls -la -- \"$p\" 2>/dev/null || true; echo; sed -n '1,200p' -- \"$p\" 2>/dev/null || true" - ;; - *) - die "tools " - ;; - esac -} -cmd_http() { [[ ${1:-} == reload ]] && http_reload; } -cmd_cli() { - local ctr="${1:-}" - shift || true - - [[ -n "$ctr" ]] || die "Usage: lds cli [cmd...]" - - docker inspect "$ctr" >/dev/null 2>&1 || die "Container not found: $ctr" - docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" - - # If user provided a command, run it; otherwise open an interactive shell. - if [[ "$#" -gt 0 ]]; then - local cmd="$*" - docker exec -it "$ctr" sh -lc ' - if command -v bash >/dev/null 2>&1; then - exec bash --login -lc "$1" - fi - exec sh -lc "$1" - ' sh "$cmd" - return - fi - - docker exec -it "$ctr" sh -lc ' - if command -v bash >/dev/null 2>&1; then - exec bash --login - fi - exec sh - ' -} - -cmd_core() { - # Usage: - # lds core -> open correct container for that domain (PHP/Node) - # lds core -> open a shell in that container - # lds core -> list domains and let user pick - - local target="${1:-}" - - # domain regex (same as domain-which/mkhost family) - local re='^([a-zA-Z0-9]([-a-zA-Z0-9]{0,61}[a-zA-Z0-9])?\.)+(localhost|local|test|loc|[a-zA-Z]{2,})$' - - # If no target -> prompt from domain-which list - if [[ -z "$target" ]]; then - local tools_ctr - tools_ctr="$(_project_tools_container_running || true)" - [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - - local -a domains=() - mapfile -t domains < <(docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | sed '/^[[:space:]]*$/d' || true) - - ((${#domains[@]} > 0)) || die "No domains found" - - # stable ordering - IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) - - if ((${#domains[@]} == 1)); then - target="${domains[0]}" - else - if [[ ! -t 0 ]]; then - printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 - local i=1 - local d - for d in "${domains[@]}"; do - printf " %2d) %s\n" "$i" "$d" >&2 - ((i++)) - done - die "No TTY to prompt. Use: lds core " - fi - - printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 - local i=1 d - for d in "${domains[@]}"; do - printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$d" >&2 - ((i++)) - done - - local ans="" - while true; do - read -r -p "Enter number (1-${#domains[@]}): " ans - ans="$(echo "$ans" | xargs)" - [[ "$ans" =~ ^[0-9]+$ ]] || { - printf "%bInvalid input.%b\n" "$YELLOW" "$NC" >&2 - continue - } - ((ans >= 1 && ans <= ${#domains[@]})) || { - printf "%bOut of range.%b\n" "$YELLOW" "$NC" >&2 - continue - } - target="${domains[$((ans - 1))]}" - break - done - fi - fi - - # If target looks like a domain -> resolve via domain-which then shell in - if [[ "$target" =~ $re ]]; then - local tools_ctr - tools_ctr="$(_project_tools_container_running || true)" - [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - - local app container wd - app="$(docker exec "$tools_ctr" domain-which --app --quiet "$target" 2>/dev/null)" || die "Unknown domain: $target" - container="$(docker exec "$tools_ctr" domain-which --container --quiet "$target" 2>/dev/null)" || die "No container resolved for: $target" - wd="$(docker exec "$tools_ctr" domain-which --docroot --quiet "$target" 2>/dev/null)" || true - [[ -n "${container:-}" ]] || die "No container resolved for: $target" - - # Node apps should always land at /app. Others follow resolved docroot. - if [[ "${app:-}" == "node" ]]; then - wd="/app" - fi - [[ -n "${wd:-}" ]] || wd="/app" - - docker exec -it "$container" bash -lc "cd \"$wd\" 2>/dev/null || cd /app 2>/dev/null || cd /; exec bash" - return 0 - fi - - # Otherwise treat target as a container name - docker exec -it "$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" sh -lc 'exec bash -i || exec sh' -} - -cmd_setup() { - add_required_env - case ${1:-} in - init) env_init ;; - permission | permissions | perms | perm) fix_perms ;; - domain) setup_domain ;; - profiles | profile) process_all ;; - *) die "setup " ;; - esac -} - -cmd_certificate() { - case ${1:-} in - install) - shift || true - install_ca - ;; - uninstall | remove | rm) - shift || true - uninstall_ca "${@:-}" - ;; - *) - die "certificate " - ;; - esac -} - -############################################################################### -# NOTIFY -############################################################################### -notify_watch() { - local container="${1:-}" - if [[ -z "$container" ]]; then - container="$(_project_tools_container_running || true)" - [[ -n "$container" ]] || die "server-tools container is not running for project: $(lds_project)" - fi - local prefix="__HOST_NOTIFY__" - - need docker - - local _disp="${DISPLAY-}" - local _dbus="${DBUS_SESSION_BUS_ADDRESS-}" - - # Args: timeout(ms) urgency title body - _host_notify() { - local timeout="${1:-2500}" urgency="${2:-normal}" title="${3:-Notification}" body="${4:-}" - - # Linux desktop (or WSLg) - if has_cmd notify-send; then - (env DISPLAY="${_disp-}" DBUS_SESSION_BUS_ADDRESS="${_dbus-}" \ - setsid -f notify-send -u "$urgency" -t "$timeout" "$title" "$body" \ - >/dev/null 2>&1 || true) & - return 0 - fi - - # Windows toast (Git Bash) / WSL-on-Windows - if has_cmd powershell.exe; then - # Pass values as args to avoid quoting issues entirely. - # Note: urgency/timeout not used by toast api here; kept for parity. - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \ - 'param([string]$t,[string]$b) - try { - [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null - [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null - - function Esc([string]$s) { - if ($null -eq $s) { return "" } - return ($s -replace "&","&" -replace "<","<" -replace ">",">" -replace "\"",""" -replace "'\''","'") - } - - $title = Esc $t - $body = Esc $b - - $xml = New-Object Windows.Data.Xml.Dom.XmlDocument - $xml.LoadXml("$title$body") - $toast = New-Object Windows.UI.Notifications.ToastNotification $xml - [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Devtainer").Show($toast) - } catch { }' \ - --% "$title" "$body" >/dev/null 2>&1 || true - - return 0 - fi - - # Fallback - printf "%s [%s] %s - %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$urgency" "$title" "$body" >&2 - return 0 - } - - trap - ERR - set +e - set +o pipefail - - local _stop=0 - - _watcher_notify() { - local urgency="${1:-critical}" title="${2:-Notifier}" body="${3:-Watcher event}" - _host_notify 2500 "$urgency" "$title" "$body" - } - - _watcher_int_term() { - _stop=1 - _watcher_notify critical "Notifier" "Notification watcher interrupted/exiting" - printf "%b[watcher]%b Notification watcher interrupted/exiting\n" "$RED" "$NC" >&2 - } - trap _watcher_int_term INT TERM - - local grep_cmd=(grep -a --line-buffered -E "^${prefix}([[:space:]]|$)") - has_cmd stdbuf && grep_cmd=(stdbuf -oL -eL "${grep_cmd[@]}") - - printf "%bNotify Watch:%b monitoring is active. Ctrl+C to stop.\n" "$GREEN" "$NC" - - while ((_stop == 0)); do - if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then - _watcher_notify critical "Notifier" "Watcher stopped: $container is not running" - printf "%b[watcher]%b %s is not running; exiting.\n" "$RED" "$NC" "$container" >&2 - break - fi - - docker logs -f --tail 0 "$container" 2>&1 | - ("${grep_cmd[@]}" || true) | - while IFS=$'\t' read -r _ f1 f2 f3 f4 rest; do - local timeout urgency title body - - if [[ "${f1:-}" =~ ^[0-9]{1,6}$ ]]; then - timeout="$f1" - urgency="${f2:-normal}" - title="${f3:-Notification}" - body="${f4:-}" - else - timeout="2500" - urgency="${f1:-normal}" - title="${f2:-Notification}" - body="${f3:-}" - fi - - [[ -n "${rest:-}" ]] && body+=$'\t'"${rest}" - case "$urgency" in low | normal | critical) ;; *) urgency="normal" ;; esac - - _host_notify "$timeout" "$urgency" "$title" "$body" - printf "%s [%s] %s - %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$urgency" "$title" "$body" >&2 - done - - ((_stop)) && break - - if docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then - _watcher_notify critical "Notifier" "Watcher lost log stream (docker logs ended). Reconnecting…" - printf "%b[watcher]%b docker logs ended; reconnecting...\n" "$YELLOW" "$NC" >&2 - sleep 1 - continue - fi - - _watcher_notify critical "Notifier" "Watcher stopped: $container stopped" - printf "%b[watcher]%b %s stopped; exiting.\n" "$RED" "$NC" "$container" >&2 - break - done - - trap - INT TERM - set -euo pipefail - - ((_stop)) && return 130 - return 0 -} - -notify_test() { - local title="${1:-Notifier OK}" - local body="${2:-Hello from host via project server-tools container}" - local ctr - ctr="$(_project_tools_container_running || true)" - [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - docker exec "$ctr" notify -t 2500 -u normal "$title" "$body" -} - -cmd_notify() { - case ${1:-watch} in - watch) notify_watch "${2:-}" ;; - test) notify_test "${2:-Notifier OK}" "${3:-Hello from host}" ;; - *) die "notify " ;; - esac -} - -open_url() { - local url="${1:-}" - [[ -n "$url" ]] || return 0 - - # WSL/Windows helpers first when available - if grep -qi microsoft /proc/version 2>/dev/null; then - if has_cmd powershell.exe; then - powershell.exe -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 || true - return 0 - fi - if has_cmd cmd.exe; then - cmd.exe /c start "" "$url" >/dev/null 2>&1 || true - return 0 - fi - fi - - if has_cmd xdg-open; then - (xdg-open "$url" >/dev/null 2>&1 &) - return 0 - fi - if has_cmd open; then - (open "$url" >/dev/null 2>&1 &) - return 0 - fi - if has_cmd powershell; then - (powershell -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 &) - return 0 - fi - - printf "%bINFO%b: open this URL manually → %s\n" "$YELLOW" "$NC" "$url" -} - -############################################################################### -# RUN (ad-hoc Dockerfile runner) -############################################################################### -hash_short() { - local s="$1" - if has_cmd sha1sum; then - printf '%s' "$s" | sha1sum | cut -c1-8 - elif has_cmd shasum; then - printf '%s' "$s" | shasum -a 1 | cut -c1-8 - else - # POSIX fallback; stable (not cryptographic) - printf '%s' "$s" | cksum | awk '{print $1}' - fi -} - -run_slug() { - local dir="$1" base hash - base="$(basename "$dir" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')" - hash="$(hash_short "$dir")" - printf '%s-%s' "$base" "$hash" -} - -run_plan() { - local dir="$1" slug - slug="$(run_slug "$dir")" - printf '%s|%s|%s\n' \ - "lds-run-${slug}" \ - "${slug}:local" \ - "$dir" -} - -detect_host_os() { - if is_windows_shell || grep -qi microsoft /proc/version 2>/dev/null; then - printf 'windows' - return 0 - fi - - if has_cmd uname; then - case "$(uname -s 2>/dev/null || true)" in - Darwin) - printf 'macos' - ;; - Linux) - printf 'linux' - ;; - *) - printf 'unknown' - ;; - esac - else - printf 'unknown' - fi -} - -run_find_container() { - local dir="$1" - docker ps -a --filter "label=com.infocyph.lds.run=1" \ - --filter "label=com.infocyph.lds.dir=${dir}" \ - --format '{{.Names}}' | head -n 1 -} - -run_build() { - local tag="$1" dir="$2" - - # Build only if the image doesn't already exist. - if docker image inspect "$tag" >/dev/null 2>&1; then - printf "%b[run]%b Image exists, skipping build: %b%s%b -" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" - return 0 - fi - - printf "%b[run]%b Building image %b%s%b from %s -" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" "$dir" - docker build -t "$tag" "$dir" -} - -run_start() { - local name="$1" tag="$2" dir="$3" keepalive="$4" sock="$5" host_os="$6" - shift 6 || true - - # Remaining args are split by a "--" sentinel: - # - before "--" : publish specs (HOST:CONT), repeatable - # - after "--" : mount specs (HOST[:CONT]), repeatable - local -a pubs=() mounts=() - local seen_delim=0 x - for x in "$@"; do - if [[ "$x" == "--" ]]; then - seen_delim=1 - continue - fi - if ((seen_delim)); then - mounts+=("$x") - else - pubs+=("$x") - fi - done - - # Normalize project dir (POSIX absolute). - # On Windows Git Bash, /e/... is OK; docker.exe will receive converted path automatically. - local dir_posix - dir_posix="$(cd "$dir" 2>/dev/null && pwd -P)" || die "invalid dir: $dir" - - # MSYS-safe container paths: use '//' prefix to prevent path conversion. - # Docker interprets //path as /path inside container. - local WDIR="//workspace" - local WDIR_MOUNT="${dir_posix}://workspace" - - local -a args=(docker run -d --name "$name" - --label "com.infocyph.lds.run=1" - --label "com.infocyph.lds.dir=$dir_posix" - --label "com.infocyph.lds.tag=$tag" - -w "$WDIR" - -v "$WDIR_MOUNT" - ) - - if [[ -n "$host_os" ]]; then - args+=(-e "HOST_OS=$host_os") - fi - - # Mount extra directories/files (HOST[:CONT]). - # - If container path missing, mounts under /mnt/. - # - HOST may be relative to the run directory. - if ((${#mounts[@]})); then - local spec host cont base - for spec in "${mounts[@]}"; do - [[ -n "$spec" ]] || continue - host="$spec" - cont="" - - # Split as HOST:CONT ONLY if suffix after last ':' looks like a container absolute path (/...) - # (safe for Windows drive letters like E:\... because tail won't start with '/') - if [[ "$spec" == *:* ]]; then - local tail="${spec##*:}" - if [[ "$tail" == /* ]]; then - host="${spec%:*}" - cont="$tail" - fi - fi - - # Resolve host to absolute (POSIX) for checks - if [[ "$host" != /* && "$host" != ~* && ! "$host" =~ ^[A-Za-z]:[\\/].* ]]; then - host="${dir_posix%/}/$host" - fi - - # If user provided Windows path (E:\...), convert to POSIX for existence check - if [[ "$host" =~ ^[A-Za-z]:[\\/].* ]] && has_cmd cygpath; then - host="$(cygpath -u "$host")" - fi - - host="$(cd "${host%/*}" 2>/dev/null && pwd -P)/${host##*/}" || { - printf "%b[run]%b Warning: cannot resolve mount path: %s\n" "$YELLOW" "$NC" "$spec" >&2 - continue - } - - [[ -e "$host" ]] || { - printf "%b[run]%b Warning: mount path does not exist: %s\n" "$YELLOW" "$NC" "$host" >&2 - continue - } - - if [[ -z "$cont" ]]; then - base="${host##*/}" - cont="/mnt/${base}" - fi - [[ "$cont" == /* ]] || cont="/mnt/${cont}" - - # Prevent MSYS conversion for container side by using '//' prefix - cont="//${cont#/}" - - args+=(-v "${host}:${cont}") - done - fi - - # Optional docker sock - if [[ "${sock:-0}" == 1 ]]; then - args+=(-v "/var/run/docker.sock:/var/run/docker.sock") - fi - - # Publish ports - local pub - for pub in "${pubs[@]}"; do - [[ -n "$pub" ]] || continue - args+=(-p "$pub") - done - - if [[ "$keepalive" == 1 ]]; then - # Keepalive mode replaces the image command; disable image healthcheck to avoid false "unhealthy". - args+=(--no-healthcheck --entrypoint sh "$tag" -c "trap : TERM INT; sleep infinity & wait") - else - args+=("$tag") - fi - - printf "%b[run]%b Starting container %b%s%b\n" "$CYAN" "$NC" "$BLUE" "$name" "$NC" - - # IMPORTANT: don't hide errors; if it fails, you need to see why - if ! "${args[@]}"; then - printf "%b[run]%b docker run failed.\n" "$RED" "$NC" >&2 - return 1 - fi - printf "\n" -} - -run_exec_shell() { - local name="$1" - if docker exec "$name" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then - exec docker exec -it "$name" bash - else - exec docker exec -it "$name" sh - fi -} - -cmd_run() { - local action="*" dir="$PWD" name="" tag="" nobuild=0 keepalive=1 sock=0 host_os="" - local -a publish=() mounts=() - local open_port="" open_path="/" open_proto="http" - - while [[ $# -gt 0 ]]; do - case "$1" in - stop | rm | ps | shell | logs | open | "*") - action="$1" - shift - ;; - build) - action="*" - shift - ;; - --name) - name="${2:-}" - shift 2 - ;; - --tag) - tag="${2:-}" - shift 2 - ;; - --no-build) - nobuild=1 - shift - ;; - --no-keepalive) - keepalive=0 - shift - ;; - --sock) - sock=1 - shift - ;; - --host-os) - host_os="${2:-}" - shift 2 - ;; - -p | --publish) - publish+=("${2:-}") - shift 2 - ;; - --mount) - mounts+=("${2:-}") - shift 2 - ;; - --port) - open_port="${2:-}" - shift 2 - ;; - --path) - open_path="${2:-/}" - shift 2 - ;; - --https) - open_proto="https" - shift - ;; - --http) - open_proto="http" - shift - ;; - *) break ;; - esac - done - - # Host path (POSIX) for planning/labels - local dir_posix - dir_posix="$(cd "$dir" && pwd -P)" +# Read a literal dotenv KEY=VALUE without sourcing/evaluating the file. +dotenv_value() { + local file="${1:-}" key="${2:-}" line value first last + [[ -r "$file" && -n "$key" ]] || return 1 - if [[ -z "$host_os" ]]; then - host_os="${HOST_OS:-$(detect_host_os)}" - fi + line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 || true)" + [[ -n "$line" ]] || return 1 + value="${line#*=}" - # Docker path (may need Windows form for docker.exe) - local dir_docker="$dir_posix" - - # Windows Git Bash/MSYS hardening: - # - stop MSYS rewriting container paths (/workspace -> D:/Program Files/Git/workspace) - # - but still feed docker.exe Windows-absolute host paths for build/run contexts - if is_windows_shell; then - export MSYS_NO_PATHCONV=1 - export MSYS2_ARG_CONV_EXCL='*' - if has_cmd cygpath; then - dir_docker="$(cygpath -w "$dir_posix")" + if (("${#value}" >= 2)); then + first="${value:0:1}" + last="${value: -1}" + if [[ "$first" == '"' && "$last" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "$first" == "'" && "$last" == "'" ]]; then + value="${value:1:${#value}-2}" fi fi + printf '%s' "$value" +} - # Plan/name/tag should be based on the real project identity (POSIX dir) - IFS='|' read -r def_name def_tag _def_dir < <(run_plan "$dir_posix") - name="${name:-$def_name}" - - # Tag rules: - # - Default tag is ":local" (from run_plan) - # - If user passes --tag without ":", append ":local" - if [[ -n "${tag:-}" ]]; then - if [[ "$tag" != *:* ]]; then - tag="${tag}:local" - fi - else - tag="$def_tag" +# Control-state precedence: shell > user docker/.env > release.env > fallback. +compose_control_value() { + local key="${1:?}" fallback="${2-}" value + if [[ -v "$key" ]]; then + printf '%s' "${!key}" + return 0 fi - - _find_for_dir() { - local found - found="$(run_find_container "$dir_posix" || true)" - if [[ -n "$found" ]]; then - printf '%s' "$found" - return 0 - fi - if docker inspect "$name" >/dev/null 2>&1; then - printf '%s' "$name" - return 0 - fi - return 1 - } - - _run_build_summary() { - local img="$1" build_dir="$2" cname="$3" - local tag_only="${img##*:}" - - printf "\n%b[run]%b Build summary\n" "$CYAN" "$NC" - printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" - printf " %bTag:%b %s\n" "$BOLD" "$NC" "$tag_only" - printf " %bDir:%b %s\n" "$BOLD" "$NC" "$build_dir" - printf " %bName:%b %s\n" "$BOLD" "$NC" "$cname" - printf " %bKeepalive:%b %s\n" "$BOLD" "$NC" "$keepalive" - printf " %bSock:%b %s\n" "$BOLD" "$NC" "$sock" - printf " %bHost OS:%b %s\n" "$BOLD" "$NC" "$host_os" - - if ((${#publish[@]})); then - printf " %bPublish:%b %s\n" "$BOLD" "$NC" "${publish[*]}" - else - printf " %bPublish:%b (none)\n" "$BOLD" "$NC" - fi - - if ((${#mounts[@]})); then - printf " %bMounts:%b %s\n" "$BOLD" "$NC" "${mounts[*]}" - else - printf " %bMounts:%b (none)\n" "$BOLD" "$NC" - fi - printf "\n" - } - - _run_runtime_summary() { - local cname="$1" - local id img state ports - id="$(docker inspect -f '{{.Id}}' "$cname" 2>/dev/null | cut -c1-12 || true)" - img="$(docker inspect -f '{{.Config.Image}}' "$cname" 2>/dev/null || true)" - state="$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || true)" - ports="$(docker port "$cname" 2>/dev/null | sed '/^[[:space:]]*$/d' | tr '\n' '; ' | sed 's/; $//' || true)" - - printf "%b[run]%b Runtime summary\n" "$CYAN" "$NC" - printf " %bContainer:%b %s\n" "$BOLD" "$NC" "${cname}${id:+ ($id)}" - [[ -n "$img" ]] && printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" - [[ -n "$state" ]] && printf " %bState:%b %s\n" "$BOLD" "$NC" "$state" - if [[ -n "$ports" ]]; then - printf " %bPorts:%b %s\n" "$BOLD" "$NC" "$ports" - else - printf " %bPorts:%b (none published)\n" "$BOLD" "$NC" - fi - printf "%b\n[run]%b Example Usage (in Composer)\n" "$CYAN" "$NC" - printf " %bimage:%b %s\n" "$BOLD" "$NC" "$img" - printf " %bpull_policy:%b never\n" "$BOLD" "$NC" - printf "\n" - } - - case "$action" in - ps) - docker ps -a --filter "label=com.infocyph.lds.run=1" \ - --format 'table {{.Names}} {{.Image}} {{.Status}} {{.Labels}}' + if value="$(dotenv_value "$ENV_DOCKER" "$key")"; then + printf '%s' "$value" return 0 - ;; - stop) - local existing - existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" - docker stop "$existing" >/dev/null - printf "%b[run]%b Stopped %s\n" "$GREEN" "$NC" "$existing" + fi + if value="$(dotenv_value "$ENV_RELEASE" "$key")"; then + printf '%s' "$value" return 0 - ;; - logs) - local existing - existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" - exec docker logs -f "$existing" - ;; - open) - local existing line addr hp url - existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" - - [[ -n "$open_path" ]] || open_path="/" - [[ "$open_path" == /* ]] || open_path="/$open_path" + fi + printf '%s' "$fallback" +} - if [[ -n "$open_port" ]]; then - line="$(docker port "$existing" "$open_port" 2>/dev/null | head -n 1 || true)" - [[ -n "$line" ]] || line="$(docker port "$existing" "${open_port}/tcp" 2>/dev/null | head -n 1 || true)" - else - line="$(docker port "$existing" 2>/dev/null | head -n 1 || true)" - fi +if [[ "${1:-}" == "--__win_workdir" ]]; then + export WORKDIR_WIN="${2:-}" + shift 2 +fi - if [[ -z "$line" ]]; then - printf "%b[run]%b No published ports found.\n" "$YELLOW" "$NC" - printf "%b[run]%b Tip: start with %blds run --publish 8025:8025%b then %blds run open%b\n" \ - "$YELLOW" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" - return 1 - fi +COLOR() { printf '[%sm' "$1"; } +############################################################################### +# Colors + UI (higher contrast; aligned with mkhost.sh) +############################################################################### +# Color control: +# - If stdout isn't a TTY, disable colors by default. +# - If NO_COLOR is set, disable colors. +# - Set LDS_FORCE_COLOR=1 to force colors. +_is_tty() { [[ -t 1 ]]; } - addr="${line##*-> }" - hp="${addr##*:}" - url="${open_proto}://localhost:${hp}${open_path}" - open_url "$url" - printf "%b[run]%b Opened: %s\n" "$GREEN" "$NC" "$url" - return 0 - ;; - rm) - local existing img - existing="$(_find_for_dir)" || true - if [[ -n "${existing:-}" ]]; then - img="$(docker inspect -f '{{.Config.Image}}' "$existing" 2>/dev/null || true)" - docker stop "$existing" >/dev/null 2>&1 || true - docker rm "$existing" >/dev/null 2>&1 || true - printf "%b[run]%b Removed container %s\n" "$GREEN" "$NC" "$existing" - if [[ -n "${img:-}" ]]; then - docker rmi -f "$img" >/dev/null 2>&1 || true - printf "%b[run]%b Removed image %s\n" "$GREEN" "$NC" "$img" - fi - else - printf "%b[run]%b No container found for %s\n" "$YELLOW" "$NC" "$dir_posix" - fi - return 0 - ;; - shell | "*") - if ((nobuild == 0)); then - # Build needs docker.exe-friendly path on Windows - run_build "$tag" "$dir_docker" - else - printf "%b[run]%b Skipping build (--no-build)\n" "$YELLOW" "$NC" - fi +_use_color=1 +if [[ "${LDS_FORCE_COLOR:-0}" != "1" ]]; then + if [[ -n "${NO_COLOR:-}" ]] || ! _is_tty; then + _use_color=0 + fi +fi - _run_build_summary "$tag" "$dir_posix" "$name" +if ((_use_color)); then + BOLD=$'\033[1m' + DIM=$'\033[2m' + RED=$'\033[1;31m' + GREEN=$'\033[1;32m' + CYAN=$'\033[1;36m' + YELLOW=$'\033[1;33m' + BLUE=$'\033[1;34m' + MAGENTA=$'\033[1;35m' + NC=$'\033[0m' +else + BOLD='' DIM='' RED='' GREEN='' CYAN='' YELLOW='' BLUE='' MAGENTA='' NC='' +fi - if docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then - printf "%b[run]%b Container already running: %s\n\n" "$GREEN" "$NC" "$name" - else - if docker inspect "$name" >/dev/null 2>&1; then - docker rm -f "$name" >/dev/null 2>&1 || true - fi +# Output control: +# - --quiet suppresses non-error output +QUIET=0 - # Keep mounts as user gave them (POSIX/relative); run_start should validate POSIX - # and convert host-side to Windows only at docker run time. - run_start "$name" "$tag" "$dir_docker" "$keepalive" "$sock" "$host_os" \ - "${publish[@]}" -- "${mounts[@]}" - fi +say() { ((QUIET)) || printf '%b\n' "$*"; } +ok() { ((QUIET)) || printf '%b\n' "${GREEN}$*${NC}"; } +warn() { ((QUIET)) || printf '%b\n' "${YELLOW}$*${NC}"; } +err() { printf '%b\n' "${RED}$*${NC}" >&2; } - _run_runtime_summary "$name" +# Default behavior: QUIET +VERBOSE=0 - # "shell" enters the container; "*" / "build" does not. - if [[ "$action" == "shell" ]]; then - run_exec_shell "$name" - else - printf "%b[run]%b Built/started. Use %blds run shell%b to enter, %blds run logs%b to follow logs.\n" \ - "$GREEN" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" - return 0 - fi - ;; - esac +#─────────────────────────────────────────────────────────────────────────────── +# 0a. GLOBAL ERROR HANDLER +#─────────────────────────────────────────────────────────────────────────────── +command_not_found_handle() { + local unknown="$1" + [[ $unknown == cmd_* ]] && unknown=${unknown#cmd_} + printf "\n%bError:%b Unknown command '%b'\n\n" "$RED" "$NC" "$unknown" + cmd_help + exit 1 } -############################################################################### -# 6w. NEW FEATURES: stack diff | support trace -############################################################################### - -# stack diff: show what would run (compose) vs what's running (docker) -cmd_stack_diff() { - local json=0 - local show_config=0 - while [[ "${1:-}" ]]; do - case "$1" in - --json) - json=1 - shift - ;; - --config) - show_config=1 - shift - ;; - *) break ;; - esac - done +trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR +on_error() { + local code="$1" line="$2" cmd="$3" + local fn="${FUNCNAME[1]:-main}" + local src="${BASH_SOURCE[1]:-$0}" - local project - project="$(lds_project)" - local cfg_json="" + printf "\n%bError:%b %s:%s in %s() (exit %d)\n" "$RED" "$NC" "$src" "$line" "$fn" "$code" >&2 + printf "%bCommand:%b %s\n" "$RED" "$NC" "$cmd" >&2 - if docker_compose config --format json >/dev/null 2>&1; then - cfg_json="$(docker_compose config --format json)" - else - # fallback: best-effort text config - cfg_json="" + if ((VERBOSE)); then + printf "%bStack:%b\n" "$DIM" "$NC" >&2 + local i=1 + while caller "$i" >/dev/null 2>&1; do + caller "$i" >&2 + i=$((i + 1)) + done fi + printf "\n" >&2 + exit "$code" +} - # running: service -> image - declare -A running=() - local line - while IFS= read -r line; do - [[ -n "$line" ]] || continue - local svc="${line%%|*}" - local img="${line#*|}" - running["$svc"]="$img" - done < <(docker ps \ - --filter "label=com.docker.compose.project=$project" \ - --format '{{index .Labels "com.docker.compose.service"}}|{{.Image}}' 2>/dev/null || true) - - # desired: service -> image/build context (best-effort) - declare -A desired_img=() - declare -A desired_ctx=() - declare -A desired_df=() - - if [[ -n "$cfg_json" ]]; then - if has_tool jq; then - while IFS= read -r line; do - local svc="${line%%|*}" - local img="${line#*|}" - desired_img["$svc"]="$img" - done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"') - while IFS= read -r line; do - local svc="${line%%|*}" - local ctx="${line#*|}" - desired_ctx["$svc"]="$ctx" - done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"') - while IFS= read -r line; do - local svc="${line%%|*}" - local df="${line#*|}" - desired_df["$svc"]="$df" - done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"') - elif _server_tools_has jq; then - # Fallback: parse via project tools container jq through stdin (no shell re-quoting of JSON payload). - local ctr - ctr="$(_project_tools_container_running || true)" - if [[ -n "$ctr" ]]; then - while IFS= read -r line; do - local svc="${line%%|*}" - local img="${line#*|}" - desired_img["$svc"]="$img" - done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"' 2>/dev/null || true) - while IFS= read -r line; do - local svc="${line%%|*}" - local ctx="${line#*|}" - desired_ctx["$svc"]="$ctx" - done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"' 2>/dev/null || true) - while IFS= read -r line; do - local svc="${line%%|*}" - local df="${line#*|}" - desired_df["$svc"]="$df" - done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"' 2>/dev/null || true) - fi - fi - fi +############################################################################### +# 1a. DOCKER COMPOSE WRAPPER +############################################################################### +# shellcheck source=lib/compose.sh +source "$DIR/lib/compose.sh" - # Build result object - if ((json)); then - if has_tool jq; then - # assemble in bash -> jq - local tmp - tmp="$(mktemp)" - { - printf '{' - printf '"project":%s,' "$(printf '%s' "$project" | jq -Rsa .)" - printf '"compose_file":%s,' "$(printf '%s' "$COMPOSE_FILE" | jq -Rsa .)" - printf '"running":{' - local first=1 k - for k in "${!running[@]}"; do - ((first)) || printf ',' - first=0 - printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${running[$k]}" | jq -R .)" - done - printf '},' - printf '"desired":{' - first=1 - for k in "${!desired_img[@]}"; do - ((first)) || printf ',' - first=0 - printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${desired_img[$k]}" | jq -R .)" - done - printf '},' - printf '"diff":[' - first=1 - # union keys - declare -A seen=() - for k in "${!running[@]}"; do seen["$k"]=1; done - for k in "${!desired_img[@]}"; do seen["$k"]=1; done - for k in "${!seen[@]}"; do - local r="${running[$k]:-}" - local d="${desired_img[$k]:-}" - if [[ "$r" != "$d" ]]; then - ((first)) || printf ',' - first=0 - printf '{"service":%s,"running":%s,"desired":%s}' \ - "$(printf '%s' "$k" | jq -R .)" \ - "$(printf '%s' "$r" | jq -R .)" \ - "$(printf '%s' "$d" | jq -R .)" - fi - done - printf ']' - printf '}\n' - } >"$tmp" - cat "$tmp" | jq . - rm -f "$tmp" - else - die "jq required for --json (or run inside project server-tools container)" - fi - return 0 - fi +############################################################################### +# 1b. PROMPTS + DOTENV HELPERS +############################################################################### +# shellcheck source=lib/env.sh +source "$DIR/lib/env.sh" - printf "%bStack diff%b (project=%s)\n" "$CYAN" "$NC" "$project" - printf "%bCompose file:%b %s\n" "$DIM" "$NC" "$COMPOSE_FILE" +############################################################################### +# 1c. HTTP / WEB SERVER HELPERS +############################################################################### +# shellcheck source=lib/hosts.sh +source "$DIR/lib/hosts.sh" - if ((show_config)); then - if [[ -n "$cfg_json" ]]; then - printf "\n%bEffective compose config (json):%b\n" "$DIM" "$NC" - printf '%s\n' "$cfg_json" - else - printf "\n%bEffective compose config:%b\n" "$DIM" "$NC" - docker_compose config || true - fi - fi +############################################################################### +# 2. INSTALL / PERMISSIONS (HOST) +############################################################################### +# shellcheck source=lib/platform.sh +source "$DIR/lib/platform.sh" - # union services - declare -A all=() - local svc - for svc in "${!running[@]}"; do all["$svc"]=1; done - for svc in "${!desired_img[@]}"; do all["$svc"]=1; done - - printf "\n%-22s %-40s %-40s %s\n" "SERVICE" "RUNNING" "DESIRED" "STATUS" - printf "%-22s %-40s %-40s %s\n" "------" "-------" "-------" "------" - for svc in $(printf '%s\n' "${!all[@]}" | sort); do - local r="${running[$svc]:-}" - local d="${desired_img[$svc]:-}" - local st - if [[ -z "$r" ]]; then - st="(not running)" - elif [[ -z "$d" ]]; then - st="(not in config)" - elif [[ "$r" == "$d" ]]; then - st="OK" - else - st="DIFF" - fi - printf "%-22s %-40.40s %-40.40s %s\n" "$svc" "$r" "$d" "$st" - done +############################################################################### +# 3. DOMAIN / PROFILE INTEGRATION +############################################################################### +# Host/domain functions are loaded by lib/hosts.sh above. - printf "\n%bNotes:%b\n" "$DIM" "$NC" - printf " - Desired image is derived from 'docker compose config'. If a service uses only 'build:' and no 'image:', desired may be empty.\n" - printf " - Use: lds stack diff --config (to print resolved compose config)\n" -} +# ───────────────────────────────────────────────────────────────────────────── +# Profiles +# ───────────────────────────────────────────────────────────────────────────── + +############################################################################### +# 3a. PROFILES: DEFINITIONS + SETUP FLOW +############################################################################### +# shellcheck source=lib/profiles.sh +source "$DIR/lib/profiles.sh" -# support trace: quick end-to-end trace for a domain -cmd_support_trace() { - local dom="${1:-}" - [[ -n "$dom" ]] || die "support trace " +############################################################################### +# 5. ENVIRONMENT + CERT / CA +############################################################################### +# shellcheck source=lib/certificates.sh +source "$DIR/lib/certificates.sh" - local nconf="$DIR/configuration/nginx/$dom.conf" - printf "%bTrace%b: %s\n" "$CYAN" "$NC" "$dom" +############################################################################### +# SERVICE / STACK OPERATIONS +############################################################################### +# shellcheck source=lib/services.sh +source "$DIR/lib/services.sh" - # 1) DNS - if _server_tools_running; then - printf "\n%b[DNS]%b\n" "$DIM" "$NC" - _tools_exec "dig +short $(_shq "$dom") || true; getent hosts $(_shq "$dom") 2>/dev/null || true" - else - printf "\n%b[DNS]%b\n" "$DIM" "$NC" - (has_cmd dig && dig +short "$dom") || true - (has_cmd getent && getent hosts "$dom") || true - fi +# ───────────────────────────────────────────────────────────────────────────── +# 6d. DIAG / SNIFF +# ───────────────────────────────────────────────────────────────────────────── +# shellcheck source=lib/ai.sh +source "$DIR/lib/ai.sh" +# shellcheck source=lib/diagnostics.sh +source "$DIR/lib/diagnostics.sh" - # 2) TLS certificate - printf "\n%b[TLS]%b\n" "$DIM" "$NC" - if _server_tools_running; then - _tools_exec "echo | openssl s_client -connect $(_shq "$dom"):443 -servername $(_shq "$dom") -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true" - else - echo | openssl s_client -connect "${dom}:443" -servername "$dom" -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true - fi +# ───────────────────────────────────────────────────────────────────────────── +# 6e/6f. SERVICE / HOST / MAINTENANCE COMMANDS +# ───────────────────────────────────────────────────────────────────────────── +# Implementations are loaded by lib/services.sh above. - # 3) HTTP probe (timings) - printf "\n%b[HTTP]%b\n" "$DIM" "$NC" - if _server_tools_running; then - _tools_exec "curl -sk -o /dev/null -D - -w 'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' https://$(_shq "$dom") | sed -n '1,30p'" - else - curl -sk -o /dev/null -D - -w $'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' "https://$dom" | sed -n '1,30p' - fi +# ───────────────────────────────────────────────────────────────────────────── +# 6g. HELP MARKDOWN +# ───────────────────────────────────────────────────────────────────────────── - # 4) Upstream inference from nginx conf (if exists) - printf "\n%b[Upstream]%b\n" "$DIM" "$NC" - if [[ -r "$nconf" ]]; then - if grep -q fastcgi_pass "$nconf"; then - local php - php="$(grep -Eo 'fastcgi_pass[[:space:]]+[^;]+' "$nconf" | awk '{print $2}' | head -n1 || true)" - printf "type=php\nfastcgi_pass=%s\n" "${php:-unknown}" - elif grep -q proxy_pass "$nconf"; then - local up - up="$(grep -m1 -Eo 'proxy_pass[[:space:]]+http[s]?://[^;]+' "$nconf" | awk '{print $2}' | head -n1 || true)" - printf "type=proxy\nproxy_pass=%s\n" "${up:-unknown}" - else - printf "type=static\n" - fi - else - printf "nginx_conf=%s (missing)\n" "$nconf" - fi +# Service/rebuild/core/notify implementations are loaded by lib/services.sh. - # 5) Recent nginx logs (compose) - printf "\n%b[Recent nginx logs]%b\n" "$DIM" "$NC" - docker_compose logs --no-color --tail 120 nginx 2>/dev/null | text_grep -i "$dom" || docker_compose logs --no-color --tail 120 nginx 2>/dev/null || true +############################################################################### +# RUN / MAINTENANCE +############################################################################### +# shellcheck source=lib/maintenance.sh +source "$DIR/lib/maintenance.sh" - printf "\n%bDone.%b If this still looks wrong, run: lds diag tls %s\n" "$GREEN" "$NC" "$dom" -} +# Extended stack/support diagnostics are loaded by lib/diagnostics.sh. ############################################################################### # 6x. GROUPED COMMAND ROUTERS (stack/domain/support) + backward-compatible aliases @@ -3113,7 +528,7 @@ cmd_stack() { http) cmd_http "$@" ;; *) - die "stack " + die "stack " ;; esac } @@ -3141,6 +556,7 @@ cmd_support() { open) cmd_open "$@" ;; bundle) cmd_bundle "$@" ;; notify) cmd_notify "$@" ;; + trace) cmd_support_trace "$@" ;; ui) cmd_ui "$@" ;; *) die "support " @@ -3154,7 +570,7 @@ cmd_support() { # --full : include more logs/inspect output (can be large) cmd_bundle() { local mode="redact" - local out="${1:-}" + local out="" while [[ "${1:-}" ]]; do case "$1" in --redact) @@ -3183,8 +599,12 @@ cmd_bundle() { local base="lds_bundle_${project}_${ts}" [[ -n "$out" ]] || out="$PWD/${base}.zip" - cleanup_bundle() { rm -rf "$tmp" 2>/dev/null || true; } - trap cleanup_bundle RETURN + # The archive is created from inside the temporary staging directory. Resolve + # explicit relative destinations before changing directory so the final ZIP + # survives staging cleanup. + if [[ "$out" != /* ]]; then + out="$PWD/${out#./}" + fi { echo "project=$project" @@ -3193,11 +613,19 @@ cmd_bundle() { echo "mode=$mode" } >"$tmp/meta.txt" - # compose config (effective) - { - echo "# docker compose config" - docker_compose config 2>&1 || true - } >"$tmp/compose.config.txt" + # compose config (effective). Redacted bundles must never contain interpolated + # secrets from docker/.env. + if [[ "$mode" == "redact" ]]; then + { + echo "# docker compose config" + docker_compose config 2>&1 || true + } | _redact_support_text >"$tmp/compose.config.txt" + else + { + echo "# docker compose config" + docker_compose config 2>&1 || true + } >"$tmp/compose.config.txt" + fi # ps + networks docker_compose ps >"$tmp/compose.ps.txt" 2>&1 || true @@ -3212,13 +640,22 @@ cmd_bundle() { >"$tmp/docker.inspect.json" 2>/dev/null || true fi - # recent logs (tail) - docker_compose logs --no-color --tail 400 >"$tmp/compose.logs.txt" 2>&1 || true + # recent logs (tail). Apply best-effort secret-pattern redaction in the + # default shareable bundle. + if [[ "$mode" == "redact" ]]; then + docker_compose logs --no-color --tail 400 2>&1 | _redact_support_text >"$tmp/compose.logs.txt" || true + else + docker_compose logs --no-color --tail 400 >"$tmp/compose.logs.txt" 2>&1 || true + fi - # vhost configs - if [[ -d "$DIR/configuration/nginx" ]]; then - mkdir -p "$tmp/nginx" - cp -a "$DIR/configuration/nginx/." "$tmp/nginx/" 2>/dev/null || true + # Generated vhosts live in Docker named volumes. Capture them through the + # server-tools mount rather than stale host paths. + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + if [[ -n "$tools_ctr" ]]; then + mkdir -p "$tmp/vhosts/nginx" "$tmp/vhosts/apache" + docker cp "$tools_ctr:/etc/share/vhosts/nginx/." "$tmp/vhosts/nginx/" >/dev/null 2>&1 || true + docker cp "$tools_ctr:/etc/share/vhosts/apache/." "$tmp/vhosts/apache/" >/dev/null 2>&1 || true fi # env files (redacted best-effort) @@ -3238,98 +675,134 @@ cmd_bundle() { fi fi - # tools-side quick diagnostics (inside network) - { - echo "# ip r / ip a / ss" - _tools_exec "ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || true" - } >"$tmp/tools.net.txt" 2>&1 || true + # tools-side quick diagnostics (inside network). A support bundle remains + # usable even when server-tools itself is down. + if [[ -n "$tools_ctr" ]]; then + { + echo "# ip r / ip a / ss" + docker exec -i "$tools_ctr" sh -lc 'ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || true' + } >"$tmp/tools.net.txt" 2>&1 || true + else + printf '%s\n' "server-tools is not running; in-container network diagnostics unavailable." >"$tmp/tools.net.txt" + fi # pack - (cd "$tmp" && zip -qr "$out" .) || die "Failed to write bundle: $out" + if ! (cd "$tmp" && zip -qr "$out" .); then + rm -rf "$tmp" 2>/dev/null || true + die "Failed to write bundle: $out" + fi + rm -rf "$tmp" 2>/dev/null || true ok "Bundle created: $out" } cmd_help() { - if [[ "${1:-}" == "--markdown" ]]; then cat <<'MD' # LocalDevStack (lds) — Command Reference -## Stack (compose) -- `lds stack up` *(aliases: `up`)* -- `lds stack start` *(aliases: `start`)* -- `lds stack down [--volumes --yes]` *(aliases: `down`, `stop`)* -- `lds stack restart [svc]` *(aliases: `restart`, `reboot`)* -- `lds stack status [status-args…]` *(alias: `status`; forwards args to tools `status`)* -- `lds stack ps` *(alias: `ps`)* -- `lds stack logs [svc] [--follow] [--since ] [--grep ]` *(alias: `logs`)* -- `lds stack exec [cmd…]` *(alias: `exec`)* -- `lds stack events [--since ]` *(alias: `events`)* -- `lds stack clean --yes [--volumes]` *(alias: `clean`)* -- `lds stack diff [--config] [--json]` *(shows desired vs running images)* - - -## Domain (vhost lifecycle + routing) -- `lds domain add …` -- `lds domain rm …` +## Global options +- `-v`, `--verbose` +- `-q`, `--quiet` +- `--reload-extras` +- `-h`, `--help` + +## Stack +- `lds stack up` *(alias: `up`)* +- `lds stack start` *(alias: `start`)* +- `lds stack down [--volumes --yes]` *(aliases: `down`, `stop`)* +- `lds stack restart [service...]` *(aliases: `restart`, `reboot`)* +- `lds stack status [args...]` *(alias: `status`)* +- `lds stack ps` *(alias: `ps`)* +- `lds stack logs [service] [--follow] [--since ] [--grep ]` *(alias: `logs`)* +- `lds stack exec [cmd...]` *(alias: `exec`)* +- `lds stack events [since]` *(alias: `events`)* +- `lds stack clean --yes [--volumes] [--global]` *(alias: `clean`; scoped by default)* +- `lds stack diff [--config] [--json]` +- `lds stack config ` +- `lds stack http reload` + +## Domains +- `lds domain add` +- `lds domain rm [args...]` - `lds domain ls` +- Legacy: `lds host add|rm|list` -Legacy alias: `lds host …` → same subcommands as `domain`. +## Setup and profiles +- `lds setup init|permissions|domain|profile|profiles` +- `lds profiles list` +- `lds profiles add ` +- `lds profiles remove ` -## Certificates (TLS) +## Certificates - `lds cert status [domain|all]` - `lds cert regen [domain|all] [--yes]` - `lds cert diagnose ` - `lds certificate install` - `lds certificate uninstall [--all]` -## Diagnostics +## Diagnostics and support +- `lds doctor` - `lds diag dns ` - `lds diag net` - `lds diag tcp ` -- `lds diag http ` *(alias: `sniff `)* +- `lds diag http [curl-args...]` - `lds diag tls ` +- `lds sniff [curl-args...]` +- `lds support open ` +- `lds support trace ` +- `lds support bundle [--redact|--full] [output.zip]` +- `lds support notify ...` +- `lds support ui` +- Shortcuts: `open`, `bundle`, `notify`, `ui` ## Config -- `lds config show [--json]` +- `lds config show [--json] [--raw]` - `lds config services` - `lds config profiles` - `lds config env-used` - `lds config validate` +- `lds images` +- `lds urls` -## Support -- `lds support open - support trace ` -- `lds support bundle [--redact|--full]` -- `lds support notify …` -- `lds support ui` - -Shortcuts: `open`, `bundle`, `notify`, `ui` map to `support …`. - -## Secrets (senv) -- `lds secrets …` - -## Tools (project server-tools container) +## Tools / shells / secrets - `lds tools sh` - `lds tools exec ""` - `lds tools file ` - -## Setup -- `lds setup init|permissions|domain|profiles` - -## Runner (ad‑hoc Dockerfile runner) -- `lds run` *(default: build+start only)* / `lds run shell` *(build+start+enter)* / `lds run *` *(same as default)* (+ `ps|logs|stop|rm|open` and flags: `--publish|-p`, `--no-keepalive`, `--mount`, `--sock`, `--host-os`, `--tag`, `--name`) - -## Other -- `lds rebuild [all|]` -- `lds core [domain]` - -## Short client wrappers -- `lds php|composer|node|npm|npx …` -- `lds my|mysql|mysqldump …` -- `lds maria|mariadb|mariadb-dump …` -- `lds pg|psql|pg_dump|pg_restore …` -- `lds redis|redis-cli …` +- `lds cli [cmd...]` +- `lds core [domain|container]` +- `lds secrets ` + +## AI consumer +- `lds ai status|ask|explain|troubleshoot|review|repo-review|graphify ...` + +## Host Graphify workflow +- `lds graphify [path] [graphify-extract-options...]` + +## LLM provider +- `lds llm models|ps|show|pull|rm|unload|run|ask|chat|prompt|code|review|json|ai-commit|ollama|api|version ...` +- `lds llm runtime [cpu|nvidia|amd]` + +## Rebuild +- `lds rebuild` +- `lds rebuild all` +- `lds rebuild ` + +## Ad-hoc Dockerfile runner +- `lds run` +- `lds run shell|ps|logs|stop|rm|open` +- flags: `--name`, `--tag`, `--no-build`, `--no-keepalive`, `--sock`, `--host-os`, `--publish|-p`, `--mount`, `--port`, `--path`, `--http`, `--https` + +## Client/runtime wrappers +- PHP/Node: `php`, `composer`, `node`, `npm`, `npx` +- PostgreSQL: `pg`, `psql`, `pg_dump`, `pg_restore` +- MySQL: `my`, `mysql`, `mysqldump` +- MariaDB: `maria`, `mariadb`, `mariadb-dump` +- Redis: `redis`, `redis-cli` +- MongoDB: `mongo`, `mongodb`, `mongosh`, `mongoimport`, `mongoexport` +- Elasticsearch: `es`, `elastic`, `elasticsearch` + +## Deprecated +- `lds vpn-fix` — fixed-subnet manipulation is no longer required. MD return 0 fi @@ -3337,60 +810,74 @@ MD cat < - clean Alias of: stack <...> +${CYAN}Global:${NC} + -v|--verbose -q|--quiet --reload-extras -h|--help -${CYAN}Domain (vhosts + routing):${NC} +${CYAN}Stack:${NC} + stack up|start|down|restart|status|ps|logs|exec|events|clean|config|diff|http + up|start|down|stop|restart|reboot|status|ps|logs|exec|events + clean --yes [--volumes] [--global] (LocalDevStack-scoped unless --global) + +${CYAN}Domains:${NC} domain add|rm|ls - host add|rm|list Legacy alias of: domain <...> + host add|rm|list Legacy aliases + core [domain|container] + cli [cmd...] + +${CYAN}Setup / Profiles:${NC} + setup init|permissions|domain|profile|profiles + profiles list|add|remove -${CYAN}Certificates (TLS):${NC} +${CYAN}Certificates:${NC} cert status|regen|diagnose certificate install|uninstall [--all] -${CYAN}Diagnostics:${NC} +${CYAN}Diagnostics / Support:${NC} + doctor diag dns|net|tcp|http|tls - sniff Alias of: diag http - -${CYAN}Config:${NC} - config show|services|profiles|env-used|validate - -${CYAN}Support:${NC} - support open + sniff + support open support trace - support bundle [--redact|--full] - support notify ... + support bundle [--redact|--full] [output.zip] + support notify ... support ui - open|bundle|notify|ui Shortcuts → support <...> + open|bundle|notify|ui Shortcuts -${CYAN}Secrets:${NC} - secrets +${CYAN}Config:${NC} + config show|services|profiles|env-used|validate + images + urls -${CYAN}Tools (project server-tools container):${NC} +${CYAN}Tools / Secrets:${NC} tools sh|exec|file + secrets -${CYAN}Setup:${NC} - setup init|permissions|domain|profiles - -${CYAN}Runner (ad-hoc Dockerfile runner):${NC} - run [ps|logs|stop|rm|open] [--publish|-p A:B] [--no-keepalive] [--mount HOST[:CONT]] [--sock] [--host-os VALUE] - -${CYAN}Other:${NC} - rebuild [all|] - core [domain] +${CYAN}AI:${NC} + ai status|ask|explain|troubleshoot|review|repo-review|graphify + graphify [path] [graphify-extract-options...] + llm models|ps|show|pull|rm|unload|run|ask|chat|prompt|code|review|json|ai-commit|ollama|api|version + llm runtime [cpu|nvidia|amd] + +${CYAN}Maintenance:${NC} + rebuild [all|] + run [shell|ps|logs|stop|rm|open] [runner flags...] + +${CYAN}Wrappers:${NC} + php|composer|node|npm|npx + pg|psql|pg_dump|pg_restore + my|mysql|mysqldump + maria|mariadb|mariadb-dump + redis|redis-cli + mongo|mongodb|mongosh|mongoimport|mongoexport + es|elastic|elasticsearch ${CYAN}Help:${NC} - help [--markdown] - + help + help --markdown EOF } + ############################################################################### # 7. MAIN ############################################################################### @@ -3442,8 +929,17 @@ main() { exit 0 fi - # Do not require docker for pure help output; everything else expects the stack. - need docker + # Offline-safe read-only commands do not require a running Docker daemon. + case "$cmd" in + images | urls | doctor) + ;; + config) + [[ "${1:-}" == "env-used" ]] || need docker + ;; + *) + need docker + ;; + esac case "$cmd" in php | composer | node | npm | npx) exec "$DIR/bin/$cmd" "$@" ;; @@ -3453,6 +949,9 @@ main() { redis | redis-cli) exec "$DIR/bin/redis-cli" "$@" ;; es | elastic | elasticsearch) exec "$DIR/bin/es" "$@" ;; mongo | mongodb | mongosh | mongoimport | mongoexport) exec "$DIR/bin/mongo" "$@" ;; + ai) cmd_ai "$@" ;; + llm) cmd_llm "$@" ;; + vpn-fix) cmd_vpn_fix "$@" ;; *) if declare -F "cmd_$cmd" >/dev/null 2>&1; then "cmd_$cmd" "$@" diff --git a/lds.bat b/lds.bat index e838a65d..955baa08 100644 --- a/lds.bat +++ b/lds.bat @@ -41,22 +41,6 @@ if not exist "%DEVHOME%\lds" ( exit /b 4 ) -where docker.exe >nul 2>&1 -if errorlevel 1 goto :no_docker - -docker info >nul 2>&1 -if errorlevel 1 goto :docker_not_running - -goto :run - -:no_docker -echo %WARN% docker.exe not found on Windows PATH -exit /b 10 - -:docker_not_running -echo %WARN% Docker is installed but NOT running/reachable (docker info failed) -exit /b 11 - :run "%BASH_EXE%" -lc "set -euo pipefail; export TERM=xterm-256color; DEVHOME_WIN=\"$1\"; CALLER_WIN=\"$2\"; DEVHOME=$(cygpath -u \"$DEVHOME_WIN\"); CALLER=$(cygpath -u \"$CALLER_WIN\"); cd \"$DEVHOME\"; chmod +x ./lds >/dev/null 2>&1 || true; cd \"$CALLER\"; shift 2; exec \"$DEVHOME/lds\" --__win_workdir \"$CALLER_WIN\" \"$@\"" bash "%DEVHOME%" "%WORKDIR%" %* diff --git a/lib/ai.sh b/lib/ai.sh new file mode 100644 index 00000000..ebef391c --- /dev/null +++ b/lib/ai.sh @@ -0,0 +1,618 @@ +# shellcheck shell=bash +_tools_exec() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + # NOTE: pass a SINGLE command string; do not pass arrays here. + docker exec -i "$ctr" sh -lc "$*" +} + +_tools_exec_argv() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + local -a flags=(-i) + [[ -t 0 && -t 1 ]] && flags+=(-t) + docker exec "${flags[@]}" "$ctr" "$@" +} + +cmd_ai() { + local sub="${1:-status}" + shift || true + case "${sub,,}" in + status | provider) _tools_exec_argv aiops provider "$@" ;; + ask) _tools_exec_argv askai "$@" ;; + explain | troubleshoot | review | repo-review | graphify) + _tools_exec_argv aiops "${sub,,}" "$@" + ;; + *) die "ai [args...]" ;; + esac +} + +_active_llm_runtime() { + effective_ai_runtime +} + +_active_llm_service() { + ai_service_for_runtime "$(_active_llm_runtime)" +} + +_active_llm_provider() { + ai_provider_for_runtime "$(_active_llm_runtime)" +} + +_active_llm_cli() { + case "$(_active_llm_provider)" in + fastflow) printf '%s' llm-fastflow ;; + ollama) printf '%s' llm-ollama ;; + *) return 1 ;; + esac +} + +_graphify_local_base_url() { + local service provider_ctr nginx_ctr + service="$(_active_llm_service)" + + provider_ctr="$(docker_compose ps -q "$service" 2>/dev/null | sed -n '1p' || true)" + [[ -n "$provider_ctr" ]] || + die "$service is not running. Enable the ai profile and start the stack first." + docker inspect -f '{{.State.Running}}' "$provider_ctr" 2>/dev/null | grep -qx true || + die "$service container exists but is not running." + + nginx_ctr="$(docker_compose ps -q nginx 2>/dev/null | sed -n '1p' || true)" + [[ -n "$nginx_ctr" ]] || + die "nginx is not running. Start the LocalDevStack edge before using Graphify." + docker inspect -f '{{.State.Running}}' "$nginx_ctr" 2>/dev/null | grep -qx true || + die "nginx container exists but is not running." + + printf '%s' 'http://llm.localhost:11434/v1' +} + +_graphify_local_model_preflight() { + local model="${1:-}" response + [[ -n "$model" ]] || return 1 + need_bin curl "install curl to validate the selected LocalDevStack model" + need_bin jq "install jq to validate the selected LocalDevStack model" + + response="$(curl --connect-timeout 3 --max-time 10 -fsS \ + 'http://llm.localhost:11434/v1/models' 2>/dev/null)" || + die "The selected LocalDevStack LLM endpoint is unavailable. Start the ai profile first." + + jq -e --arg model "$model" '[.data[]?.id // empty] | index($model) != null' <<<"$response" >/dev/null 2>&1 || + die "Model '$model' is not available from the active LocalDevStack provider. Run: lds llm pull $model" +} + +_graphify_backend_for_provider() { + case "${1,,}" in + fastflow) printf '%s' openai ;; + ollama) printf '%s' ollama ;; + *) return 1 ;; + esac +} + +_graphify_local_backend_for_provider() { + case "${1,,}" in + fastflow) printf '%s' lds-fastflow ;; + ollama) printf '%s' lds-ollama ;; + *) return 1 ;; + esac +} + +_graphify_write_local_provider() { + local dir="$1" provider="$2" base_url="$3" model="$4" token_budget="$5" think_mode="${6:-off}" + local backend num_ctx + backend="$(_graphify_local_backend_for_provider "$provider")" || return 1 + + mkdir -p "$dir/.graphify" + case "$provider" in + fastflow) + case "$think_mode" in + off) + jq -n \ + --arg backend "$backend" \ + --arg base_url "$base_url" \ + --arg model "$model" \ + '{ + ($backend): { + base_url: $base_url, + default_model: $model, + env_key: "LDS_GRAPHIFY_API_KEY", + extra_body: {think: false} + } + }' >"$dir/.graphify/providers.json" + ;; + on) + jq -n \ + --arg backend "$backend" \ + --arg base_url "$base_url" \ + --arg model "$model" \ + '{ + ($backend): { + base_url: $base_url, + default_model: $model, + env_key: "LDS_GRAPHIFY_API_KEY", + reasoning_effort: "high", + extra_body: {think: true} + } + }' >"$dir/.graphify/providers.json" + ;; + auto) + jq -n \ + --arg backend "$backend" \ + --arg base_url "$base_url" \ + --arg model "$model" \ + '{ + ($backend): { + base_url: $base_url, + default_model: $model, + env_key: "LDS_GRAPHIFY_API_KEY" + } + }' >"$dir/.graphify/providers.json" + ;; + *) return 1 ;; + esac + ;; + ollama) + num_ctx=$((token_budget + 8192 + 2400)) + ((num_ctx < 8192)) && num_ctx=8192 + ((num_ctx > 131072)) && num_ctx=131072 + num_ctx=$((((num_ctx + 1023) / 1024) * 1024)) + jq -n \ + --arg backend "$backend" \ + --arg base_url "$base_url" \ + --arg model "$model" \ + --argjson num_ctx "$num_ctx" \ + '{ + ($backend): { + base_url: $base_url, + default_model: $model, + env_key: "LDS_GRAPHIFY_API_KEY", + reasoning_effort: "none", + extra_body: { + options: {num_ctx: $num_ctx}, + keep_alive: "30m" + } + } + }' >"$dir/.graphify/providers.json" + ;; + *) return 1 ;; + esac + + printf '%s' "$backend" +} + +_graphify_python_bin() { + local graphify_bin="${1:-}" first_line="" candidate="" + + for candidate in python3 python; do + if type -P -- "$candidate" >/dev/null 2>&1; then + type -P -- "$candidate" + return 0 + fi + done + + if [[ -n "$graphify_bin" && -f "$graphify_bin" ]]; then + IFS= read -r first_line <"$graphify_bin" || true + if [[ "$first_line" == '#!'* ]]; then + candidate="${first_line#\#!}" + candidate="${candidate%% *}" + if [[ -x "$candidate" && "${candidate##*/}" == python* ]]; then + printf '%s' "$candidate" + return 0 + fi + fi + fi + return 1 +} + +_graphify_diagnostics_enabled() { + case "${LDS_GRAPHIFY_DIAGNOSTICS:-0}" in + 1 | true | TRUE | yes | YES | on | ON) return 0 ;; + 0 | false | FALSE | no | NO | off | OFF) return 1 ;; + *) die "LDS_GRAPHIFY_DIAGNOSTICS must be true/false" ;; + esac +} + +cmd_graphify() { + need_bin graphify "install the Graphify CLI on the host first" + + local target="${1:-.}" + [[ $# -eq 0 ]] || shift + [[ -e "$target" ]] || die "Graphify target does not exist: $target" + + local runtime provider backend base_url timeout model api_key graphify_bin arg provider_dir target_abs graphify_target + local graphify_python="" diagnostic_root="" diagnostic_log="" diagnostic_preview="4096" graphify_think="off" + local diagnostic_mode="off" structured_timeout="" graphify_sdk_retries="" graphify_retry_depth="" + local local_provider=0 force_rebuild=0 + local next_is_model=0 next_is_timeout=0 next_is_token_budget=0 next_is_max_concurrency=0 + local has_token_budget=0 has_max_concurrency=0 + local token_budget_value="" max_concurrency_value="" + local -a graphify_defaults=() + + runtime="$(_active_llm_runtime)" + provider="$(_active_llm_provider)" + backend="$(_graphify_backend_for_provider "$provider")" || + die "Unsupported active LLM provider for Graphify: $provider" + + case "$backend" in + openai) + if [[ -n "${OPENAI_BASE_URL:-}" ]]; then + base_url="$OPENAI_BASE_URL" + else + base_url="$(_graphify_local_base_url)" + local_provider=1 + fi + model="${OPENAI_MODEL:-$(effective_ai_model "$runtime")}" + api_key="${OPENAI_API_KEY:-local}" + ;; + ollama) + if [[ -n "${OLLAMA_BASE_URL:-}" ]]; then + base_url="$OLLAMA_BASE_URL" + else + base_url="$(_graphify_local_base_url)" + local_provider=1 + fi + model="${OLLAMA_MODEL:-$(effective_ai_model "$runtime")}" + api_key="${OLLAMA_API_KEY:-local}" + ;; + esac + + timeout="${GRAPHIFY_API_TIMEOUT:-$(compose_control_value LDS_AI_TIMEOUT 1800)}" + + structured_timeout="${LDS_GRAPHIFY_STRUCTURED_TIMEOUT:-120}" + [[ "$structured_timeout" =~ ^[0-9]+$ ]] && ((structured_timeout >= 1)) || + die "LDS_GRAPHIFY_STRUCTURED_TIMEOUT must be a positive integer" + + graphify_sdk_retries="${GRAPHIFY_MAX_RETRIES:-${LDS_GRAPHIFY_SDK_RETRIES:-0}}" + [[ "$graphify_sdk_retries" =~ ^[0-9]+$ ]] || + die "GRAPHIFY_MAX_RETRIES/LDS_GRAPHIFY_SDK_RETRIES must be a non-negative integer" + + graphify_retry_depth="${GRAPHIFY_MAX_RETRY_DEPTH:-${LDS_GRAPHIFY_MAX_RETRY_DEPTH:-1}}" + [[ "$graphify_retry_depth" =~ ^[0-9]+$ ]] || + die "GRAPHIFY_MAX_RETRY_DEPTH/LDS_GRAPHIFY_MAX_RETRY_DEPTH must be a non-negative integer" + + case "${LDS_GRAPHIFY_THINK:-off}" in + off | false | 0) graphify_think=off ;; + on | true | 1) graphify_think=on ;; + auto | default) graphify_think=auto ;; + *) die "LDS_GRAPHIFY_THINK must be off/on/auto" ;; + esac + + # Keep explicit model/timeout/resource overrides consistent while LocalDevStack + # retains ownership of backend selection and the two-stage extract/cluster flow. + for arg in "$@"; do + if ((next_is_model)); then + model="$arg" + next_is_model=0 + continue + fi + if ((next_is_timeout)); then + timeout="$arg" + next_is_timeout=0 + continue + fi + if ((next_is_token_budget)); then + token_budget_value="$arg" + next_is_token_budget=0 + continue + fi + if ((next_is_max_concurrency)); then + max_concurrency_value="$arg" + next_is_max_concurrency=0 + continue + fi + + case "$arg" in + --model) next_is_model=1 ;; + --model=*) model="${arg#--model=}" ;; + --api-timeout) next_is_timeout=1 ;; + --api-timeout=*) timeout="${arg#--api-timeout=}" ;; + --token-budget) + has_token_budget=1 + next_is_token_budget=1 + ;; + --token-budget=*) + has_token_budget=1 + token_budget_value="${arg#--token-budget=}" + ;; + --max-concurrency) + has_max_concurrency=1 + next_is_max_concurrency=1 + ;; + --max-concurrency=*) + has_max_concurrency=1 + max_concurrency_value="${arg#--max-concurrency=}" + ;; + --force) + force_rebuild=1 + ;; + --backend | --backend=*) + die "lds graphify selects the Graphify backend from the active LLM provider; do not pass --backend" + ;; + --no-cluster) + die "lds graphify already separates extraction and clustering; do not pass --no-cluster" + ;; + esac + done + + ((next_is_model == 0)) || die "--model requires a value" + ((next_is_timeout == 0)) || die "--api-timeout requires a value" + ((next_is_token_budget == 0)) || die "--token-budget requires a value" + ((next_is_max_concurrency == 0)) || die "--max-concurrency requires a value" + + [[ -n "$model" ]] || die "Graphify model cannot be empty" + [[ "$timeout" =~ ^[0-9]+$ ]] && ((timeout >= 1)) || + die "GRAPHIFY_API_TIMEOUT/--api-timeout must be a positive integer" + + if ((has_token_budget)); then + [[ "$token_budget_value" =~ ^[0-9]+$ ]] && ((token_budget_value >= 1)) || + die "--token-budget must be a positive integer" + fi + if ((has_max_concurrency)); then + [[ "$max_concurrency_value" =~ ^[0-9]+$ ]] && ((max_concurrency_value >= 1)) || + die "--max-concurrency must be a positive integer" + fi + + # Local models are more reliable with conservative semantic chunking and + # serialized requests. Callers can still override both limits explicitly. + if [[ "$provider" == fastflow ]] || ((local_provider)); then + if ((has_token_budget == 0)); then + token_budget_value="${LDS_GRAPHIFY_TOKEN_BUDGET:-4000}" + [[ "$token_budget_value" =~ ^[0-9]+$ ]] && ((token_budget_value >= 1)) || + die "LDS_GRAPHIFY_TOKEN_BUDGET must be a positive integer" + graphify_defaults+=(--token-budget "$token_budget_value") + fi + if ((has_max_concurrency == 0)); then + max_concurrency_value="${LDS_GRAPHIFY_MAX_CONCURRENCY:-1}" + [[ "$max_concurrency_value" =~ ^[0-9]+$ ]] && ((max_concurrency_value >= 1)) || + die "LDS_GRAPHIFY_MAX_CONCURRENCY must be a positive integer" + graphify_defaults+=(--max-concurrency "$max_concurrency_value") + fi + fi + + ((local_provider == 0)) || _graphify_local_model_preflight "$model" + + graphify_bin="$(bin_path graphify)" + target_abs="$(_realpath "$target")" + graphify_target="$target" + provider_dir="" + + if [[ -d "$target_abs" && -f "$target_abs/graphify-out/graph.json" ]]; then + if ((force_rebuild)); then + printf '%s\n' "[lds graphify] existing graph detected; --force requested, performing a full rebuild" >&2 + else + printf '%s\n' "[lds graphify] existing graph detected; using Graphify incremental update (changed files only)" >&2 + fi + else + printf '%s\n' "[lds graphify] no existing graph detected; performing initial full build" >&2 + fi + + if ((local_provider)); then + graphify_target="$target_abs" + provider_dir="$(mktemp -d)" || die "Unable to create temporary Graphify provider directory" + + graphify_python="$(_graphify_python_bin "$graphify_bin")" || + die "Unable to find the Python interpreter required for LocalDevStack Graphify structured output" + + if [[ -d "$target_abs" ]]; then + diagnostic_root="$target_abs" + else + diagnostic_root="$(dirname "$target_abs")" + fi + diagnostic_log="${LDS_GRAPHIFY_DIAGNOSTIC_LOG:-$diagnostic_root/graphify-out/lds-graphify-diagnostics.jsonl}" + diagnostic_preview="${LDS_GRAPHIFY_DIAGNOSTIC_PREVIEW:-4096}" + [[ "$diagnostic_preview" =~ ^[0-9]+$ ]] && ((diagnostic_preview >= 256)) || + die "LDS_GRAPHIFY_DIAGNOSTIC_PREVIEW must be an integer >= 256" + + if _graphify_diagnostics_enabled; then + diagnostic_mode=on + fi + fi + + ( + proxy_pid="" + cleanup_graphify_local() { + if [[ -n "${proxy_pid:-}" ]]; then + kill "$proxy_pid" >/dev/null 2>&1 || true + wait "$proxy_pid" >/dev/null 2>&1 || true + fi + [[ -z "$provider_dir" ]] || rm -rf "$provider_dir" + } + [[ -z "$provider_dir" ]] || trap cleanup_graphify_local EXIT + export GRAPHIFY_API_TIMEOUT="$timeout" + + if ((local_provider)); then + export GRAPHIFY_MAX_RETRIES="$graphify_sdk_retries" + export GRAPHIFY_MAX_RETRY_DEPTH="$graphify_retry_depth" + local_provider_base_url="$base_url" + + if [[ -n "$graphify_python" ]]; then + ready_file="$provider_dir/graphify-proxy.port" + if [[ "$diagnostic_mode" == on ]]; then + mkdir -p "$(dirname "$diagnostic_log")" + : >"$diagnostic_log" + fi + + "$graphify_python" "$DIR/scripts/graphify-diagnostic-proxy.py" \ + --upstream "${base_url%/v1}" \ + --provider "$provider" \ + --diagnostics "$diagnostic_mode" \ + --timeout "$timeout" \ + --structured-timeout "$structured_timeout" \ + --ready-file "$ready_file" \ + --log-file "$diagnostic_log" \ + --preview-chars "$diagnostic_preview" & + proxy_pid=$! + + proxy_port="" + for _ in {1..100}; do + if [[ -s "$ready_file" ]]; then + proxy_port="$(cat "$ready_file")" + break + fi + kill -0 "$proxy_pid" >/dev/null 2>&1 || + die "Local Graphify structured-output proxy exited before becoming ready" + sleep 0.05 + done + [[ "$proxy_port" =~ ^[0-9]+$ ]] || + die "Local Graphify structured-output proxy did not become ready" + + local_provider_base_url="http://127.0.0.1:${proxy_port}/v1" + if [[ "$diagnostic_mode" == on ]]; then + printf '%s\n' "[lds graphify] suspect-response diagnostics enabled: $diagnostic_log" >&2 + fi + fi + + backend="$(_graphify_write_local_provider "$provider_dir" "$provider" "$local_provider_base_url" "$model" "$token_budget_value" "$graphify_think")" || + die "Unable to build LocalDevStack Graphify provider configuration" + + unset OPENAI_BASE_URL OPENAI_API_KEY OPENAI_MODEL OLLAMA_BASE_URL OLLAMA_API_KEY OLLAMA_MODEL + export LDS_GRAPHIFY_API_KEY=local + export GRAPHIFY_ALLOW_LOCAL_PROVIDERS=1 + cd "$provider_dir" + else + case "$backend" in + openai) + unset OLLAMA_BASE_URL OLLAMA_API_KEY OLLAMA_MODEL + export OPENAI_BASE_URL="$base_url" + export OPENAI_API_KEY="$api_key" + export OPENAI_MODEL="$model" + ;; + ollama) + unset OPENAI_BASE_URL OPENAI_API_KEY OPENAI_MODEL + export OLLAMA_BASE_URL="$base_url" + export OLLAMA_API_KEY="$api_key" + export OLLAMA_MODEL="$model" + ;; + esac + fi + + "$graphify_bin" extract "$graphify_target" --backend "$backend" --no-cluster "${graphify_defaults[@]}" "$@" && + "$graphify_bin" cluster-only "$graphify_target" --backend "$backend" + ) +} + +_llm_exec() { + local service cli ctr + service="$(_active_llm_service)" + cli="$(_active_llm_cli)" + + ctr="$(docker_compose ps -q "$service" 2>/dev/null | sed -n '1p' || true)" + [[ -n "$ctr" ]] || die "$service is not running. Enable the ai profile and start the stack first." + docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || + die "$service container exists but is not running." + + local -a exec_args=(exec) + [[ -t 0 && -t 1 ]] || exec_args+=(-T) + docker_compose "${exec_args[@]}" "$service" "$cli" "$@" +} + +_llm_require_provider() { + local expected="$1" command="$2" active + active="$(_active_llm_provider)" + [[ "$active" == "$expected" ]] || + die "llm $command is available only with the $expected provider; active provider is $active." +} + +cmd_llm() { + local sub="${1:-models}" + shift || true + + case "${sub,,}" in + runtime) + local mode="${1:-}" + if [[ -z "$mode" ]]; then + printf '%s\n' "$(_active_llm_runtime)" + return 0 + fi + + case "${mode,,}" in + auto) + update_env "$ENV_DOCKER" LDS_AI_RUNTIME "" + update_env "$ENV_DOCKER" LDS_AI_IGPU_ENABLE "" + local detected + detected="$(detect_ai_runtime)" + ok "LLM runtime set to auto; detected $detected ($(ai_provider_for_runtime "$detected")). Recreate the AI service to apply it." + ;; + cpu | nvidia | amd | npu) + local normalized igpu_enable provider image + normalized="${mode,,}" + igpu_enable="$(ai_igpu_default_for_runtime "$normalized")" + provider="$(ai_provider_for_runtime "$normalized")" + + if [[ "$normalized" == "npu" ]] && ! fastflow_npu_supported; then + warn "No FastFlow-supported XDNA2 NPU is currently detected; the explicit npu runtime will still be persisted." + fi + + update_env "$ENV_DOCKER" LDS_AI_RUNTIME "$normalized" + update_env "$ENV_DOCKER" LDS_AI_IGPU_ENABLE "$igpu_enable" + + if [[ "$provider" == "fastflow" ]]; then + image="infocyph/llm-fastflow:latest" + elif [[ "$normalized" == "amd" ]]; then + image="infocyph/llm-ollama:amd-latest" + else + image="infocyph/llm-ollama:latest" + fi + ok "LLM runtime set to $normalized ($provider, $image). Recreate the AI service to apply the change." + ;; + *) die "llm runtime " ;; + esac + ;; + + models | list | pull | rm | remove | run | ask | chat | prompt | code | review | json | ai-commit | api | version) + _llm_exec "${sub,,}" "$@" + ;; + + ps | show | unload | ollama) + _llm_require_provider ollama "${sub,,}" + _llm_exec "${sub,,}" "$@" + ;; + + validate | check | flm) + _llm_require_provider fastflow "${sub,,}" + _llm_exec "${sub,,}" "$@" + ;; + + think) + local think_mode="${1:-}" + if [[ -z "$think_mode" ]]; then + local configured + configured="$(compose_control_value LDS_AI_THINK "")" + printf '%s\n' "${configured:-auto}" + return 0 + fi + [[ $# -eq 1 ]] || die "llm think " + case "${think_mode,,}" in + auto | default) + remove_env "$ENV_DOCKER" LDS_AI_THINK + ok "LLM thinking override cleared; provider/model default will be used after the AI service is recreated." + ;; + on | true | 1) + update_env "$ENV_DOCKER" LDS_AI_THINK true + ok "LLM thinking forced on. Recreate the AI service to apply the change." + ;; + off | false | 0) + update_env "$ENV_DOCKER" LDS_AI_THINK false + ok "LLM thinking forced off. Recreate the AI service to apply the change." + ;; + *) die "llm think " ;; + esac + ;; + + provider) + printf '%s\n' "$(_active_llm_provider)" + ;; + + help | -h | --help) + printf '%s\n' "llm " + printf '%s\n' "llm provider" + printf '%s\n' "llm runtime " + printf '%s\n' "llm think " + printf '%s\n' "Ollama-only: llm " + printf '%s\n' "FastFlow-only: llm " + ;; + + *) die "llm " ;; + esac +} diff --git a/lib/certificates.sh b/lib/certificates.sh new file mode 100644 index 00000000..cd2fd006 --- /dev/null +++ b/lib/certificates.sh @@ -0,0 +1,454 @@ +# shellcheck shell=bash +############################################################################### +# 5. ENVIRONMENT + CERT / CA +############################################################################### +detect_timezone() { + if has_cmd timedatectl; then + timedatectl show -p Timezone --value + elif [[ -n ${TZ-} ]]; then + printf '%s' "$TZ" + elif [[ -r /etc/timezone ]]; then + /dev/null | tr -d '\r' + else + date +%Z + fi +} + +env_init() { + local env_file="$ENV_DOCKER" + printf "%bBootstrapping environment defaults…%b\n" "$YELLOW" "$NC" + + local default_tz tz + default_tz="$(detect_timezone)" + tz="$(read_default "Timezone (TZ)" "$default_tz")" + + local default_git_name default_git_email git_name git_email + default_git_name="$(git config --global --get user.name 2>/dev/null || true)" + default_git_email="$(git config --global --get user.email 2>/dev/null || true)" + git_name="$(read_default "Git user.name (GIT_USER_NAME)" "$default_git_name")" + git_email="$(read_default "Git user.email (GIT_USER_EMAIL)" "$default_git_email")" + + # update_env now quotes automatically when needed + update_env "$env_file" "TZ" "$tz" + update_env "$env_file" "GIT_USER_NAME" "$git_name" + update_env "$env_file" "GIT_USER_EMAIL" "$git_email" + + printf "%bConfiguration saved!%b\n" "$GREEN" "$NC" +} + +# ───────────────────────────────────────────────────────────────────────────── +# Root CA helpers (cross-distro) +# ───────────────────────────────────────────────────────────────────────────── + +# Unique identity (avoid conflicts with other mkcert/dev CAs) +CA_BASENAME="localdevstack-rootca" +CA_NICK="LocalDevStack Root CA" + +host_root_ca_path() { + local current="$DIR/configuration/ssl/rootCA.pem" + local legacy="$DIR/configuration/rootCA/rootCA.pem" + + if [[ -r "$current" ]]; then + printf '%s' "$current" + return 0 + fi + if [[ -r "$legacy" ]]; then + printf '%s' "$legacy" + return 0 + fi + return 1 +} + +detect_os_family() { + # Output: "id|like" + # Must never fail under set -e + if [[ "${OSTYPE:-}" =~ (msys|cygwin|win32) ]]; then + echo "windows|windows" + return 0 + fi + + local id like + id="unknown" + like="unknown" + + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release || true + id="${ID:-unknown}" + like="${ID_LIKE:-unknown}" + elif has_cmd uname; then + # fallback for macOS / other unix + case "$(uname -s 2>/dev/null || true)" in + Darwin) + id="macos" + like="darwin" + ;; + Linux) + id="linux" + like="linux" + ;; + esac + fi + + echo "$id|$like" +} + +# Decide destination path + update mechanism. Echo: family|dest|updater +ca_plan() { + local os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + + case " $os_id $os_like " in + *" debian "* | *" ubuntu "* | *" linuxmint "* | *" pop "* | *" raspbian "*) + printf "debian|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|update-ca-certificates\n" + ;; + *" alpine "*) + printf "alpine|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|update-ca-certificates\n" + ;; + *" fedora "* | *" rhel "* | *" redhat "* | *" centos "* | *" rocky "* | *" alma "* | *" amzn "* | *" amazon "* | *" sles "* | *" suse "*) + printf "rhel|/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.crt|update-ca-trust\n" + ;; + *" arch "* | *" manjaro "*) + printf "arch|/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.crt|trust\n" + ;; + *) + # best default: Debian-style location (works on many distros even if updater differs) + printf "fallback|/usr/local/share/ca-certificates/${CA_BASENAME}.crt|\n" + ;; + esac +} + +is_windows_shell() { + [[ "${OSTYPE:-}" =~ (msys|cygwin) ]] || [[ -n "${WORKDIR_WIN:-}" ]] +} + +need_windows_tools() { + has_cmd cygpath || die "Windows certificate install needs 'cygpath' (Git Bash)." + has_cmd powershell.exe || die "Windows certificate install needs 'powershell.exe' on PATH." +} + +# Import CA into the invoking user's NSS DB (Chrome/Chromium/Firefox on many Linux setups) +install_ca_nss_user() { + local ca_file="$1" + has_cmd certutil || return 0 + + local user="${SUDO_USER:-}" + [[ -n "$user" && "$user" != "root" ]] || return 0 + + local home + home="$(getent passwd "$user" | cut -d: -f6)" + [[ -n "$home" && -d "$home" ]] || return 0 + + local nssdb="sql:${home}/.pki/nssdb" + sudo -u "$user" mkdir -p "${home}/.pki/nssdb" >/dev/null 2>&1 || true + + if sudo -u "$user" certutil -d "$nssdb" -L 2>/dev/null | grep -Fq "$CA_NICK"; then + printf "%b✔ NSS already has CA%b (%s)\n" "$GREEN" "$NC" "$user" + return 0 + fi + + if sudo -u "$user" certutil -d "$nssdb" -A -n "$CA_NICK" -t "C,," -i "$ca_file" >/dev/null 2>&1; then + printf "%b✔ Imported CA into NSS%b (%s)\n" "$GREEN" "$NC" "$user" + else + printf "%bWARN%b: NSS import failed (certutil).\n" "$YELLOW" "$NC" >&2 + fi +} + +install_ca_windows() { + need_windows_tools + + local src_ca + src_ca="$(host_root_ca_path || true)" + [[ -n "$src_ca" ]] || die "certificate not found: expected configuration/ssl/rootCA.pem (legacy: configuration/rootCA/rootCA.pem)" + + local win_ca + win_ca="$(cygpath -w "$src_ca")" + + printf "%bInstalling root CA into Windows trust store (CurrentUser\\Root)…%b\n" "$CYAN" "$NC" + + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " + \$ErrorActionPreference = 'Stop' + \$path = '$win_ca' + \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$path) + \$cert.FriendlyName = '$CA_NICK' + + \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser') + \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + + \$exists = \$store.Certificates | Where-Object { \$_.Thumbprint -eq \$cert.Thumbprint } + if (-not \$exists) { \$store.Add(\$cert) } + + \$store.Close() + " >/dev/null 2>&1 || die "Windows certificate install failed (PowerShell import)." + + printf "%bRoot CA installed on Windows%b (CurrentUser\\Root) as %s\n" "$GREEN" "$NC" "$CA_NICK" + printf "%bNote:%b restart browsers if they still show trust errors.\n" "$YELLOW" "$NC" +} + +install_ca() { + if is_windows_shell; then + install_ca_windows + return 0 + fi + + local src_ca + src_ca="$(host_root_ca_path || true)" + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate install requires sudo" + [[ -n "$src_ca" ]] || die "certificate not found: expected configuration/ssl/rootCA.pem (legacy: configuration/rootCA/rootCA.pem)" + + local family dest updater os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + IFS='|' read -r family dest updater < <(ca_plan) + + printf "%bInstalling root CA…%b\n" "$CYAN" "$NC" + printf "%bDetected OS%b: id=%s like=%s → %s\n" "$CYAN" "$NC" "$os_id" "$os_like" "$family" + + install -d -m 755 "$(dirname "$dest")" + install -m 644 "$src_ca" "$dest" + printf "%b✔ Copied%b → %s\n" "$GREEN" "$NC" "$dest" + + case "$family" in + debian | alpine) + if has_cmd update-ca-certificates; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + if update-ca-certificates; then + printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" + printf "%bNote:%b If you see \"rehash: skipping ca-certificates.crt…\", that’s normal (it’s a bundle).\n" "$YELLOW" "$NC" + else + printf "%bWARN%b: update-ca-certificates failed. CA is installed but may not be active yet.\n" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: update-ca-certificates not found. CA is installed but auto-update is unavailable.\n" "$YELLOW" "$NC" >&2 + fi + + # Optional p11-kit sync: best-effort only (can be missing helper on minimal installs) + if has_cmd trust; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + if trust extract-compat >/dev/null 2>&1; then + printf "%b✔ p11-kit trust synced%b\n" "$GREEN" "$NC" + else + printf "%bWARN%b: trust extract-compat failed (helper missing on some installs). Skipping.\n" "$YELLOW" "$NC" >&2 + fi + else + printf "%bINFO%b: 'trust' not found — skipping p11-kit sync.\n" "$YELLOW" "$NC" + fi + ;; + rhel) + if has_cmd update-ca-trust; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + if update-ca-trust extract; then + printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" + else + printf "%bWARN%b: update-ca-trust extract failed. CA is installed but may not be active yet.\n" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: update-ca-trust not found. CA is installed but auto-update is unavailable.\n" "$YELLOW" "$NC" >&2 + fi + ;; + arch) + if has_cmd trust; then + printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" + if trust extract-compat >/dev/null 2>&1; then + printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" + else + printf "%bWARN%b: trust extract-compat failed. CA is installed, but trust sync may be incomplete.\n" "$YELLOW" "$NC" >&2 + fi + else + printf "%bWARN%b: 'trust' not found. CA is installed, but trust sync is unavailable.\n" "$YELLOW" "$NC" >&2 + fi + ;; + *) + printf "%bINFO%b: Unknown distro; CA copied to %s.\n" "$YELLOW" "$NC" "$dest" + printf "%bINFO%b: You may need to update trust store manually for your OS.\n" "$YELLOW" "$NC" + ;; + esac + + # Extra: ensure browsers that rely on NSS trust pick it up + install_ca_nss_user "$src_ca" + + printf "%bRoot CA installed%b → %s (%s)\n" "$GREEN" "$NC" "$dest" "$CA_NICK" +} + +uninstall_ca_windows() { + need_windows_tools + + local src_ca + src_ca="$(host_root_ca_path || true)" + [[ -n "$src_ca" ]] || die "certificate not found: expected configuration/ssl/rootCA.pem (legacy: configuration/rootCA/rootCA.pem)" + + local win_ca + win_ca="$(cygpath -w "$src_ca")" + + printf "%bUninstalling root CA from Windows trust store (CurrentUser\\Root)…%b\n" "$CYAN" "$NC" + + local removed + removed="$(powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " + \$ErrorActionPreference = 'Stop' + \$path = '$win_ca' + \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$path) + \$thumb = \$cert.Thumbprint + + \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser') + \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + + \$matches = @(\$store.Certificates | Where-Object { \$_.Thumbprint -eq \$thumb }) + foreach (\$c in \$matches) { \$store.Remove(\$c) } + + \$store.Close() + [string]\$matches.Count + " 2>/dev/null || true)" + + removed="${removed//[$'\r\n\t ']/}" + if [[ "${removed:-0}" =~ ^[0-9]+$ ]] && ((removed > 0)); then + printf "%bRoot CA uninstalled on Windows%b (removed %s cert)\n" "$GREEN" "$NC" "$removed" + else + printf "%bRoot CA already absent on Windows%b (no matching cert)\n" "$YELLOW" "$NC" + fi +} + +uninstall_ca_nss_user() { + has_cmd certutil || return 0 + local user="${SUDO_USER:-}" + [[ -n "$user" && "$user" != "root" ]] || return 0 + + local home + home="$(getent passwd "$user" | cut -d: -f6)" + [[ -n "$home" && -d "$home" ]] || return 0 + + local nssdb="sql:${home}/.pki/nssdb" + if sudo -u "$user" certutil -d "$nssdb" -L 2>/dev/null | grep -Fq "$CA_NICK"; then + sudo -u "$user" certutil -d "$nssdb" -D -n "$CA_NICK" >/dev/null 2>&1 || true + printf "%b✔ Removed CA from NSS%b (%s)\n" "$GREEN" "$NC" "$user" + fi +} + +uninstall_ca() { + if is_windows_shell; then + uninstall_ca_windows + return 0 + fi + + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "certificate uninstall requires sudo" + + local all=0 + if [[ "${1:-}" == "--all" ]]; then + all=1 + shift + fi + + local family dest updater os_id os_like + IFS='|' read -r os_id os_like < <(detect_os_family) + IFS='|' read -r family dest updater < <(ca_plan) + + printf "%bUninstalling root CA…%b\n" "$CYAN" "$NC" + printf "%bDetected OS%b: id=%s like=%s → %s\n" "$CYAN" "$NC" "$os_id" "$os_like" "$family" + + local removed=0 + + if [[ -e "$dest" ]]; then + rm -f "$dest" + removed=$((removed + 1)) + printf "%b✔ Removed%b → %s\n" "$GREEN" "$NC" "$dest" + else + printf "%bINFO%b: CA file not found at %s (nothing to remove)\n" "$YELLOW" "$NC" "$dest" + fi + + if ((all)); then + printf "%bScanning all known CA anchor paths…%b\n" "$CYAN" "$NC" + local f + for f in \ + "/usr/local/share/ca-certificates/${CA_BASENAME}.crt" \ + "/usr/local/share/ca-certificates/${CA_BASENAME}.pem" \ + "/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.crt" \ + "/etc/pki/ca-trust/source/anchors/${CA_BASENAME}.pem" \ + "/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.crt" \ + "/etc/ca-certificates/trust-source/anchors/${CA_BASENAME}.pem"; do + [[ "$f" == "$dest" ]] && continue + if [[ -e "$f" ]]; then + rm -f "$f" + removed=$((removed + 1)) + printf "%b✔ Removed%b → %s\n" "$GREEN" "$NC" "$f" + fi + done + fi + + case "$family" in + debian | alpine) + if has_cmd update-ca-certificates; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + update-ca-certificates || printf "%bWARN%b: update-ca-certificates failed.\n" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: update-ca-certificates not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 + fi + + if has_cmd trust; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed. Skipping.\n" "$YELLOW" "$NC" >&2 + fi + ;; + rhel) + if has_cmd update-ca-trust; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + update-ca-trust extract || printf "%bWARN%b: update-ca-trust extract failed.\n" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: update-ca-trust not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 + fi + ;; + arch) + if has_cmd trust; then + printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed.\n" "$YELLOW" "$NC" >&2 + else + printf "%bWARN%b: 'trust' not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 + fi + ;; + *) + if has_cmd update-ca-certificates; then + printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" + update-ca-certificates || true + fi + if has_cmd update-ca-trust; then + printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" + update-ca-trust extract || true + fi + if has_cmd trust; then + printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" + trust extract-compat >/dev/null 2>&1 || true + fi + printf "%bINFO%b: Unknown distro; removed CA file(s) if present. Refresh trust store manually if needed.\n" "$YELLOW" "$NC" + ;; + esac + + uninstall_ca_nss_user + + if ((removed)); then + printf "%bRoot CA uninstalled%b (removed %d file(s))\n" "$GREEN" "$NC" "$removed" + else + printf "%bRoot CA already absent%b (no files removed)\n" "$YELLOW" "$NC" + fi +} + +add_required_env() { + update_env "$ENV_DOCKER" WORKING_DIR "$DIR" + + local ai_runtime ai_igpu_enable + ai_runtime="$(compose_control_value LDS_AI_RUNTIME "")" + if [[ -z "$ai_runtime" ]]; then + ai_runtime="$(detect_ai_runtime)" + update_env "$ENV_DOCKER" LDS_AI_RUNTIME "$ai_runtime" + fi + + ai_igpu_enable="$(compose_control_value LDS_AI_IGPU_ENABLE "")" + if [[ -z "$ai_igpu_enable" ]]; then + ai_igpu_enable="$(ai_igpu_default_for_runtime "$ai_runtime")" + update_env "$ENV_DOCKER" LDS_AI_IGPU_ENABLE "$ai_igpu_enable" + fi + + ((EUID == 0)) && return 0 + update_env "$ENV_DOCKER" USER "$(id -un)" + update_env "$ENV_DOCKER" UID "$(id -u)" + update_env "$ENV_DOCKER" GID "$(id -g)" +} + diff --git a/lib/compose.sh b/lib/compose.sh new file mode 100644 index 00000000..7f62a05f --- /dev/null +++ b/lib/compose.sh @@ -0,0 +1,161 @@ +# shellcheck shell=bash +############################################################################### +# 1a. DOCKER COMPOSE WRAPPER +############################################################################### + +# ── compose extras (docker/extras/*.y{a,}ml) ──────────────────────────────── +__EXTRAS_LOADED=0 +declare -a __EXTRA_FILES=() + +load_extras() { + # Set LDS_EXTRAS_RELOAD=1 (or global --reload-extras) to re-scan templates every call. + if [[ "${LDS_EXTRAS_RELOAD:-0}" == "1" ]]; then + __EXTRAS_LOADED=0 + fi + + ((__EXTRAS_LOADED)) && return 0 + __EXTRAS_LOADED=1 + + [[ -d "$EXTRAS_DIR" ]] || return 0 + + mapfile -t __EXTRA_FILES < <( + find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null | sort | sed '/^[[:space:]]*$/d' + ) +} + +docker_compose() { + load_extras + # Create required runtime files only when docker/compose operations are invoked. + ((EUID == 0)) || ensure_files_exist "/docker/.env" "/configuration/php/php.ini" "/.env" + if [[ -z "${__LDS_DC_BIN:-}" ]]; then + if docker compose version >/dev/null 2>&1; then + __LDS_DC_BIN=(docker compose) + else + __LDS_DC_BIN=(docker-compose) + fi + fi + + [[ -r "$ENV_RELEASE" ]] || die "Missing release compatibility manifest: $ENV_RELEASE" + + # Release defaults are loaded first; user docker/.env overrides them. + # Shell variables remain higher-precedence Compose interpolation inputs. + local -a env_files=(--env-file "$ENV_RELEASE") + [[ -r "$ENV_DOCKER" ]] && env_files+=(--env-file "$ENV_DOCKER") + + local ai_runtime ai_provider ai_model ollama_profile fastflow_profile runtime_override="" + local -a runtime_f=() + + ai_runtime="$(compose_control_value LDS_AI_RUNTIME "")" + [[ -n "$ai_runtime" ]] || ai_runtime="$(detect_ai_runtime)" + case "${ai_runtime,,}" in + cpu | nvidia | amd | npu) ;; + *) die "Invalid LDS_AI_RUNTIME: $ai_runtime (expected cpu|nvidia|amd|npu)" ;; + esac + ai_runtime="${ai_runtime,,}" + + ai_provider="$(ai_provider_for_runtime "$ai_runtime")" || + die "Cannot resolve AI provider for runtime: $ai_runtime" + ai_model="$(effective_ai_model "$ai_runtime")" || + die "Cannot resolve AI model for runtime: $ai_runtime" + if [[ "$ai_provider" == "fastflow" ]]; then + ollama_profile=__lds-ai-disabled-ollama + fastflow_profile=ai + else + ollama_profile=ai + fastflow_profile=__lds-ai-disabled-fastflow + fi + + # Only Ollama GPU modes need generated hardware overrides. FastFlow's XDNA2 + # device + memlock contract is part of its tracked service definition. + if [[ "$ai_runtime" == "nvidia" || "$ai_runtime" == "amd" ]]; then + mkdir -p "$CFG/.runtime" + runtime_override="$(mktemp "$CFG/.runtime/ai.XXXXXX")" || + die "Unable to create temporary AI Compose override" + + { + printf '%s\n' 'services:' ' llm-ollama:' + case "$ai_runtime" in + nvidia) + printf '%s\n' ' gpus: all' + ;; + amd) + printf '%s\n' ' image: infocyph/llm-ollama:amd-latest' ' devices:' ' - /dev/kfd:/dev/kfd' ' - /dev/dri:/dev/dri' + ;; + esac + } >"$runtime_override" + + runtime_f=(-f "$runtime_override") + fi + + # Runtime-generated product overrides are applied before user-provided extras. + local -a extra_f=() f + for f in "${__EXTRA_FILES[@]:-}"; do + [[ -f "$f" ]] || continue + case "$f" in + *.yml | *.yaml) extra_f+=(-f "$f") ;; + esac + done + + local host_os="${HOST_OS:-$(detect_host_os)}" + + local rc=0 + HOST_OS="$host_os" \ + LDS_AI_PROVIDER=llm \ + LDS_AI_URL=http://llm:11434 \ + LDS_AI_MODEL="$ai_model" \ + LDS_AI_OLLAMA_PROFILE="$ollama_profile" \ + LDS_AI_FASTFLOW_PROFILE="$fastflow_profile" \ + "${__LDS_DC_BIN[@]}" \ + --project-directory "$DIR" \ + -f "$COMPOSE_FILE" \ + "${runtime_f[@]}" \ + "${extra_f[@]}" \ + "${env_files[@]}" \ + "$@" || rc=$? + + [[ -z "$runtime_override" ]] || rm -f "$runtime_override" + return "$rc" +} + +# helper: print the effective Compose project name. +# main.yaml owns the LocalDevStack default; COMPOSE_PROJECT_NAME remains an +# explicit override and must be reflected by label-scoped diagnostics. +lds_project() { + local project + project="$(compose_control_value COMPOSE_PROJECT_NAME LocalDevStack)" + printf '%s' "${__LDS_PROJECT:-$project}" +} + +# (QUIET by default) ──────────────────────────────── +# Centralize quiet/verbose handling for compose subcommands. +# Usage: dc_cmd [args...] +dc_cmd() { + local sub="${1:-}" + shift || true + + local -a quiet=() + if ((VERBOSE == 0)); then + case "$sub" in + up) quiet+=(--quiet-pull) ;; + pull) quiet+=(-q) ;; + build) quiet+=(--quiet) ;; + esac + fi + + docker_compose "$sub" "${quiet[@]}" "$@" +} + +dc_up() { dc_cmd up "$@"; } +dc_pull() { dc_cmd pull "$@"; } +dc_build() { + local scriptomatic_ref + scriptomatic_ref="$(compose_control_value SCRIPTOMATIC_REF main)" + [[ "$scriptomatic_ref" == "main" || "$scriptomatic_ref" =~ ^[0-9A-Fa-f]{40}$ ]] || + die "SCRIPTOMATIC_REF must be main or a full 40-character commit SHA" + dc_cmd build --build-arg "SCRIPTOMATIC_REF=$scriptomatic_ref" "$@" +} + +# helper for our own minimal logging (still shows in quiet mode) +logv() { ((VERBOSE)) && printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2 || true; } +logq() { printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2; } + diff --git a/lib/diagnostics.sh b/lib/diagnostics.sh new file mode 100644 index 00000000..8cd81a67 --- /dev/null +++ b/lib/diagnostics.sh @@ -0,0 +1,543 @@ +# shellcheck shell=bash +_shq() { printf '%q' "$1"; } + +cmd_diag() { + local sub="${1:-}" + shift || true + + case "${sub,,}" in + dns) + local dom="${1:-}" + [[ -n "$dom" ]] || die "diag dns " + local qdom + qdom="$(_shq "$dom")" + _tools_exec "dig +short $qdom; echo; nslookup $qdom 2>/dev/null || true; echo; getent hosts $qdom 2>/dev/null || true" + ;; + route | net) + _tools_exec "ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || netstat -tulpen 2>/dev/null || true" + ;; + tcp) + local h="${1:-}" + local p="${2:-}" + [[ -n "$h" && -n "$p" ]] || die "diag tcp " + _tools_exec "nc -vz -w2 $(_shq "$h") $(_shq "$p")" + ;; + http) + local url="${1:-}" + shift || true + [[ -n "$url" ]] || die "diag http [curl-args...]" + local -a qargs=() + local a + for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done + _tools_exec "curl -vkI $(_shq "$url") ${qargs[*]}" + ;; + tls) + local dom="${1:-}" + [[ -n "$dom" ]] || die "diag tls " + local qdom + qdom="$(_shq "$dom")" + _tools_exec "echo | openssl s_client -connect ${qdom}:443 -servername $qdom -showcerts 2>/dev/null | sed -n '1,60p'" + ;; + *) + die "diag " + ;; + esac +} + +cmd_sniff() { + local url="${1:-}" + shift || true + [[ -n "$url" ]] || die "sniff [curl-args...]" + local -a qargs=() + local a + for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done + _tools_exec "curl -vk -D - $(_shq "$url") ${qargs[*]} | (command -v jq >/dev/null 2>&1 && jq . 2>/dev/null || cat)" +} + + +############################################################################### +# 6w. NEW FEATURES: stack diff | support trace +############################################################################### + +# stack diff: show what would run (compose) vs what's running (docker) +cmd_stack_diff() { + local json=0 + local show_config=0 + while [[ "${1:-}" ]]; do + case "$1" in + --json) + json=1 + shift + ;; + --config) + show_config=1 + shift + ;; + *) break ;; + esac + done + + local project + project="$(lds_project)" + local cfg_json="" + + if docker_compose config --format json >/dev/null 2>&1; then + cfg_json="$(docker_compose config --format json)" + else + # fallback: best-effort text config + cfg_json="" + fi + + # running: service -> image + declare -A running=() + local line + while IFS= read -r line; do + [[ -n "$line" ]] || continue + local svc="${line%%|*}" + local img="${line#*|}" + running["$svc"]="$img" + done < <(docker ps \ + --filter "label=com.docker.compose.project=$project" \ + --format '{{index .Labels "com.docker.compose.service"}}|{{.Image}}' 2>/dev/null || true) + + # desired: service -> image/build context (best-effort) + declare -A desired_img=() + declare -A desired_ctx=() + declare -A desired_df=() + + if [[ -n "$cfg_json" ]]; then + if has_tool jq; then + while IFS= read -r line; do + local svc="${line%%|*}" + local img="${line#*|}" + desired_img["$svc"]="$img" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"') + while IFS= read -r line; do + local svc="${line%%|*}" + local ctx="${line#*|}" + desired_ctx["$svc"]="$ctx" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"') + while IFS= read -r line; do + local svc="${line%%|*}" + local df="${line#*|}" + desired_df["$svc"]="$df" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"') + elif _server_tools_has jq; then + # Fallback: parse via project tools container jq through stdin (no shell re-quoting of JSON payload). + local ctr + ctr="$(_project_tools_container_running || true)" + if [[ -n "$ctr" ]]; then + while IFS= read -r line; do + local svc="${line%%|*}" + local img="${line#*|}" + desired_img["$svc"]="$img" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"' 2>/dev/null || true) + while IFS= read -r line; do + local svc="${line%%|*}" + local ctx="${line#*|}" + desired_ctx["$svc"]="$ctx" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"' 2>/dev/null || true) + while IFS= read -r line; do + local svc="${line%%|*}" + local df="${line#*|}" + desired_df["$svc"]="$df" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"' 2>/dev/null || true) + fi + fi + fi + + # Build result object + if ((json)); then + if has_tool jq; then + # assemble in bash -> jq + local tmp + tmp="$(mktemp)" + { + printf '{' + printf '"project":%s,' "$(printf '%s' "$project" | jq -Rsa .)" + printf '"compose_file":%s,' "$(printf '%s' "$COMPOSE_FILE" | jq -Rsa .)" + printf '"running":{' + local first=1 k + for k in "${!running[@]}"; do + ((first)) || printf ',' + first=0 + printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${running[$k]}" | jq -R .)" + done + printf '},' + printf '"desired":{' + first=1 + for k in "${!desired_img[@]}"; do + ((first)) || printf ',' + first=0 + printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${desired_img[$k]}" | jq -R .)" + done + printf '},' + printf '"diff":[' + first=1 + # union keys + declare -A seen=() + for k in "${!running[@]}"; do seen["$k"]=1; done + for k in "${!desired_img[@]}"; do seen["$k"]=1; done + for k in "${!seen[@]}"; do + local r="${running[$k]:-}" + local d="${desired_img[$k]:-}" + if [[ "$r" != "$d" ]]; then + ((first)) || printf ',' + first=0 + printf '{"service":%s,"running":%s,"desired":%s}' \ + "$(printf '%s' "$k" | jq -R .)" \ + "$(printf '%s' "$r" | jq -R .)" \ + "$(printf '%s' "$d" | jq -R .)" + fi + done + printf ']' + printf '}\n' + } >"$tmp" + cat "$tmp" | jq . + rm -f "$tmp" + else + die "jq required for --json (or run inside project server-tools container)" + fi + return 0 + fi + + printf "%bStack diff%b (project=%s)\n" "$CYAN" "$NC" "$project" + printf "%bCompose file:%b %s\n" "$DIM" "$NC" "$COMPOSE_FILE" + + if ((show_config)); then + if [[ -n "$cfg_json" ]]; then + printf "\n%bEffective compose config (json):%b\n" "$DIM" "$NC" + printf '%s\n' "$cfg_json" + else + printf "\n%bEffective compose config:%b\n" "$DIM" "$NC" + docker_compose config || true + fi + fi + + # union services + declare -A all=() + local svc + for svc in "${!running[@]}"; do all["$svc"]=1; done + for svc in "${!desired_img[@]}"; do all["$svc"]=1; done + + printf "\n%-22s %-40s %-40s %s\n" "SERVICE" "RUNNING" "DESIRED" "STATUS" + printf "%-22s %-40s %-40s %s\n" "------" "-------" "-------" "------" + for svc in $(printf '%s\n' "${!all[@]}" | sort); do + local r="${running[$svc]:-}" + local d="${desired_img[$svc]:-}" + local st + if [[ -z "$r" ]]; then + st="(not running)" + elif [[ -z "$d" ]]; then + st="(not in config)" + elif [[ "$r" == "$d" ]]; then + st="OK" + else + st="DIFF" + fi + printf "%-22s %-40.40s %-40.40s %s\n" "$svc" "$r" "$d" "$st" + done + + printf "\n%bNotes:%b\n" "$DIM" "$NC" + printf " - Desired image is derived from 'docker compose config'. If a service uses only 'build:' and no 'image:', desired may be empty.\n" + printf " - Use: lds stack diff --config (to print resolved compose config)\n" +} + +# support trace: quick end-to-end trace for a domain +cmd_support_trace() { + local dom="${1:-}" + [[ -n "$dom" ]] || die "support trace " + + local nconf="/etc/share/vhosts/nginx/$dom.conf" + local nconf_source="server-tools:$nconf" + local nconf_text="" ctr nginx_ctr + ctr="$(_project_tools_container_running || true)" + if [[ -n "$ctr" ]]; then + nconf_text="$(docker exec "$ctr" sh -c 'cat "$1" 2>/dev/null || true' sh "$nconf" 2>/dev/null || true)" + else + nginx_ctr="$(docker_compose ps -q nginx 2>/dev/null | sed -n '1p' || true)" + if [[ -n "$nginx_ctr" ]] && docker inspect -f '{{.State.Running}}' "$nginx_ctr" 2>/dev/null | grep -qx true; then + nconf="/etc/nginx/conf.d/$dom.conf" + nconf_source="nginx:$nconf" + nconf_text="$(docker exec "$nginx_ctr" sh -c 'cat "$1" 2>/dev/null || true' sh "$nconf" 2>/dev/null || true)" + fi + fi + printf "%bTrace%b: %s\n" "$CYAN" "$NC" "$dom" + + # 1) DNS + if _server_tools_running; then + printf "\n%b[DNS]%b\n" "$DIM" "$NC" + _tools_exec "dig +short $(_shq "$dom") || true; getent hosts $(_shq "$dom") 2>/dev/null || true" + else + printf "\n%b[DNS]%b\n" "$DIM" "$NC" + (has_cmd dig && dig +short "$dom") || true + (has_cmd getent && getent hosts "$dom") || true + fi + + # 2) TLS certificate + printf "\n%b[TLS]%b\n" "$DIM" "$NC" + if _server_tools_running; then + _tools_exec "echo | openssl s_client -connect $(_shq "$dom"):443 -servername $(_shq "$dom") -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true" + else + echo | openssl s_client -connect "${dom}:443" -servername "$dom" -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true + fi + + # 3) HTTP probe (timings) + printf "\n%b[HTTP]%b\n" "$DIM" "$NC" + if _server_tools_running; then + _tools_exec "curl -sk -o /dev/null -D - -w 'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' https://$(_shq "$dom") | sed -n '1,30p'" + else + curl -sk -o /dev/null -D - -w $'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' "https://$dom" | sed -n '1,30p' + fi + + # 4) Upstream inference from the persisted NginxHosts state. + printf "\n%b[Upstream]%b\n" "$DIM" "$NC" + if [[ -n "$nconf_text" ]]; then + if grep -q fastcgi_pass <<<"$nconf_text"; then + local php + php="$(grep -Eo 'fastcgi_pass[[:space:]]+[^;]+' <<<"$nconf_text" | awk '{print $2}' | head -n1 || true)" + printf "type=php\nfastcgi_pass=%s\n" "${php:-unknown}" + elif grep -q proxy_pass <<<"$nconf_text"; then + local up + up="$(grep -m1 -Eo 'proxy_pass[[:space:]]+http[s]?://[^;]+' <<<"$nconf_text" | awk '{print $2}' | head -n1 || true)" + printf "type=proxy\nproxy_pass=%s\n" "${up:-unknown}" + else + printf "type=static\n" + fi + else + printf "nginx_conf=%s (missing or unavailable)\n" "$nconf_source" + fi + + # 5) Recent nginx logs (compose) + printf "\n%b[Recent nginx logs]%b\n" "$DIM" "$NC" + docker_compose logs --no-color --tail 120 nginx 2>/dev/null | text_grep -i "$dom" || docker_compose logs --no-color --tail 120 nginx 2>/dev/null || true + + printf "\n%bDone.%b If this still looks wrong, run: lds diag tls %s\n" "$GREEN" "$NC" "$dom" +} + + + +############################################################################### +# PRODUCT CONFIG / IMAGES / DOCTOR +############################################################################### + +_redact_effective_config() { + sed -E \ + -e 's/^([[:space:]]*[A-Z0-9_]*(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*:[[:space:]]*).*$/\1"***REDACTED***"/' \ + -e 's/^([[:space:]]*(ME_CONFIG_MONGODB_URL|DATABASE_URL):[[:space:]]*).*$/\1"***REDACTED***"/' \ + -e 's/("[A-Z0-9_]*(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|API_KEY|ACCESS_KEY)[A-Z0-9_]*"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"***REDACTED***"/g' \ + -e 's/("(ME_CONFIG_MONGODB_URL|DATABASE_URL)"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"***REDACTED***"/g' +} + +_redact_support_text() { + _redact_effective_config | sed -E \ + -e 's/((password|secret|token|api[_-]?key|access[_-]?key)[=:][[:space:]]*)[^[:space:]]+/\1***REDACTED***/Ig' +} + +_env_key_list() { + local file="${1:-}" source="${2:-}" + [[ -r "$file" ]] || return 0 + awk -F= -v source="$source" ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + /^[A-Za-z_][A-Za-z0-9_]*=/ { print source "\t" $1 } + ' "$file" +} + +_validate_scheduler_text() { + local file failed=0 + while IFS= read -r file; do + [[ -f "$file" ]] || continue + if grep -Iq . "$file" && grep -q $'\r$' "$file"; then + printf '%b[fail]%b CRLF scheduler file: %s\n' "$RED" "$NC" "$file" >&2 + failed=1 + fi + done < <(find "$DIR/configuration/scheduler" -type f ! -name '.gitignore' -print 2>/dev/null | sort) + return "$failed" +} + +cmd_config() { + local sub="${1:-show}" + shift || true + + case "${sub,,}" in + show | "") + local format="" raw=0 + while [[ "${1:-}" ]]; do + case "$1" in + --json) format=json; shift ;; + --raw) raw=1; shift ;; + *) die "config show [--json] [--raw]" ;; + esac + done + + local -a args=(config) + [[ "$format" == json ]] && args+=(--format json) + if ((raw)); then + warn "Printing raw effective configuration; secret values may be visible." + docker_compose "${args[@]}" + else + docker_compose "${args[@]}" | _redact_effective_config + fi + ;; + services) + docker_compose config --services + ;; + profiles) + docker_compose config --profiles + ;; + env-used) + { + _env_key_list "$ENV_RELEASE" release + _env_key_list "$ENV_DOCKER" user + } | LC_ALL=C sort -k2,2 -k1,1 + ;; + validate) + docker_compose config --quiet + _validate_scheduler_text || die "Scheduler files contain CRLF; convert them to LF before Runner consumes them." + + local runner + runner="$(docker_compose ps -q runner 2>/dev/null | sed -n '1p' || true)" + if [[ -n "$runner" ]] && docker inspect -f '{{.State.Running}}' "$runner" 2>/dev/null | grep -qx true; then + docker exec "$runner" supervisord -t -c /etc/supervisor/supervisord.conf >/dev/null + ok "Compose and mounted Supervisor configuration validate." + else + ok "Compose configuration validates." + warn "Runner is not running; Supervisor syntax check skipped." + fi + + local -a fragments=() + mapfile -t fragments < <(find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null | sort) + if (("${#fragments[@]}" > 0)); then + printf '%bGenerated Compose fragments:%b\n' "$CYAN" "$NC" + printf ' %s\n' "${fragments[@]}" + fi + ;; + *) + die "config " + ;; + esac +} + +cmd_images() { + local elastic + elastic="$(compose_control_value ELASTICSEARCH_VERSION 9.5.4)" + + printf '%-16s %s\n' "Tools" "infocyph/tools:latest" + printf '%-16s %s\n' "Runner" "infocyph/runner:latest" + printf '%-16s %s\n' "Nginx" "infocyph/nginx:latest" + printf '%-16s %s\n' "Apache" "infocyph/apache:latest" + local ai_runtime ai_provider llm_image + ai_runtime="$(compose_control_value LDS_AI_RUNTIME "")" + [[ -n "$ai_runtime" ]] || ai_runtime="$(detect_ai_runtime)" + ai_provider="$(ai_provider_for_runtime "$ai_runtime")" + if [[ "$ai_provider" == "fastflow" ]]; then + llm_image="infocyph/llm-fastflow:latest" + elif [[ "$ai_runtime" == "amd" ]]; then + llm_image="infocyph/llm-ollama:amd-latest" + else + llm_image="infocyph/llm-ollama:latest" + fi + printf '%-16s %s\n' "LLM" "$llm_image" + printf '%-16s %s\n' "LLM provider" "$ai_provider" + printf '%-16s %s\n' "LLM runtime" "$ai_runtime" + printf '%-16s postgres:%s\n' "PostgreSQL" "$(compose_control_value POSTGRES_VERSION alpine)" + printf '%-16s mysql:%s\n' "MySQL" "$(compose_control_value MYSQL_VERSION latest)" + printf '%-16s mariadb:%s\n' "MariaDB" "$(compose_control_value MARIADB_VERSION latest)" + printf '%-16s mongo:%s\n' "MongoDB" "$(compose_control_value MONGODB_VERSION latest)" + printf '%-16s redis/redis-stack-server:%s\n' "Redis" "$(compose_control_value REDIS_VERSION latest)" + printf '%-16s docker.elastic.co/elasticsearch/elasticsearch:%s\n' "Elasticsearch" "$elastic" + printf '%-16s docker.elastic.co/kibana/kibana:%s\n' "Kibana" "$elastic" + printf '%-16s docker.elastic.co/beats/filebeat:%s\n' "Filebeat" "$elastic" + printf '%-16s %s\n' "PHP runtimes" "localdevstack-php: (Alpine)" + printf '%-16s %s\n' "Node runtimes" "localdevstack-node: (Alpine)" +} + +_doctor_ok() { printf '%b[ok]%b %s\n' "$GREEN" "$NC" "$*"; } +_doctor_warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*"; } +_doctor_fail() { printf '%b[fail]%b %s\n' "$RED" "$NC" "$*" >&2; } + +cmd_doctor() { + local failures=0 warnings=0 project ctr status name + project="$(lds_project)" + + if ! has_bin docker; then + _doctor_fail "Docker CLI is not installed." + return 1 + fi + _doctor_ok "Docker CLI: $(docker --version 2>/dev/null || printf unknown)" + + if ! docker info >/dev/null 2>&1; then + _doctor_fail "Docker daemon is unavailable." + return 1 + fi + _doctor_ok "Docker daemon is reachable." + + if docker compose version >/dev/null 2>&1 || has_bin docker-compose; then + _doctor_ok "Docker Compose is available." + else + _doctor_fail "Docker Compose is unavailable." + failures=$((failures + 1)) + fi + + if docker_compose config --quiet >/dev/null 2>&1; then + _doctor_ok "Effective Compose configuration validates." + else + _doctor_fail "Effective Compose configuration is invalid." + failures=$((failures + 1)) + fi + + printf '%bProfiles:%b %s\n' "$CYAN" "$NC" "$(_enabled_profiles_csv | sed 's/^$//')" + + for name in Frontend Backend DataStore; do + if docker network inspect "$name" >/dev/null 2>&1; then + _doctor_ok "Network present: $name" + else + _doctor_warn "Network not created yet: $name" + warnings=$((warnings + 1)) + fi + done + + ctr="$(_project_tools_container_running || true)" + if [[ -n "$ctr" ]]; then + _doctor_ok "Tools container is running: $ctr" + if docker exec "$ctr" sh -ec 'test -s /etc/mkcert/lds-server.pem && test -s /etc/mkcert/lds-server-key.pem' >/dev/null 2>&1; then + _doctor_ok "Shared TLS certificate/key are present." + else + _doctor_warn "Shared TLS certificate/key are not ready." + warnings=$((warnings + 1)) + fi + else + _doctor_warn "Tools container is not running." + warnings=$((warnings + 1)) + fi + + while IFS='|' read -r name status; do + [[ -n "$name" ]] || continue + if [[ "$status" == *"(unhealthy)"* ]]; then + _doctor_fail "$name: $status" + failures=$((failures + 1)) + else + _doctor_ok "$name: $status" + fi + done < <( + docker ps \ + --filter "label=com.docker.compose.project=$project" \ + --format '{{.Names}}|{{.Status}}' 2>/dev/null || true + ) + + if profile_enabled ai; then + local llm llm_service llm_provider + llm_service="$(ai_service_for_runtime "$(effective_ai_runtime)")" + llm_provider="$(ai_provider_for_runtime "$(effective_ai_runtime)")" + llm="$(docker_compose ps -q "$llm_service" 2>/dev/null | sed -n '1p' || true)" + if [[ -n "$llm" ]] && docker inspect -f '{{.State.Running}}' "$llm" 2>/dev/null | grep -qx true; then + _doctor_ok "AI provider is running: $llm_provider ($llm_service)." + else + _doctor_warn "AI profile is selected but $llm_provider provider service '$llm_service' is not running." + warnings=$((warnings + 1)) + fi + fi + + printf '%bDoctor summary:%b %d failure(s), %d warning(s)\n' "$CYAN" "$NC" "$failures" "$warnings" + ((failures == 0)) +} diff --git a/lib/env.sh b/lib/env.sh new file mode 100644 index 00000000..aae6fb9c --- /dev/null +++ b/lib/env.sh @@ -0,0 +1,106 @@ +# shellcheck shell=bash +############################################################################### +# 1b. PROMPTS + DOTENV HELPERS +############################################################################### + +# Unified prompt helper (used by env_init + profiles) +tty_readline() { + # Robust prompt/read across Linux/macOS/WSL/Windows Git Bash. + # Prefer stdin when it is a TTY (normal interactive use). If stdin is not a TTY, + # fall back to /dev/tty when available. + local __var_name="$1" __prompt="$2" __line + + if [[ -t 0 ]]; then + # Interactive: show prompt on stderr (so it is never swallowed) and read stdin. + printf '%s' "$__prompt" >&2 + IFS= read -r __line || return 1 + elif [[ -r /dev/tty ]]; then + # Non-interactive stdin (piped) but we still have a controlling terminal. + printf '%s' "$__prompt" >/dev/tty + IFS= read -r __line keep as-is + if [[ "$v" =~ ^\".*\"$ || "$v" =~ ^\'.*\'$ ]]; then + printf '%s' "$v" + return 0 + fi + + # Leading/trailing whitespace or any internal whitespace or # or quotes => quote + if [[ "$v" =~ ^[[:space:]] || "$v" =~ [[:space:]]$ || "$v" == *$'\t'* || "$v" == *" "* || "$v" == *"#"* || "$v" == *"\""* ]]; then + env_quote "$v" + return 0 + fi + + printf '%s' "$v" +} + +# Escape replacement for sed (delimiter '|') +sed_escape_repl() { + local s=${1-} + s=${s//\\/\\\\} + s=${s//&/\\&} + s=${s//|/\\|} + printf '%s' "$s" +} + +update_env() { + local file=$1 var=$2 val=${3-} + mkdir -p "$(dirname "$file")" + [[ -f "$file" ]] || { + printf "%bFile '%s' not found. Creating one.%b\n" "$YELLOW" "$file" "$NC" + : >"$file" + } + + # Apply quoting only when needed (spaces etc.) + val="$(env_quote_if_needed "$val")" + + # Sed-safe replacement + local val_sed + val_sed="$(sed_escape_repl "$val")" + + var=$(echo "$var" | sed 's/[]\/$*.^|[]/\\&/g') + if grep -qE "^[# ]*$var=" "$file" 2>/dev/null; then + sed -Ei "s|^[# ]*($var)=.*|\1=$val_sed|" "$file" + else + printf "%s=%s\n" "$var" "$val" >>"$file" + fi +} + + +remove_env() { + local file="$1" var="$2" + [[ -f "$file" ]] || return 0 + var="$(printf '%s' "$var" | sed 's/[]\/$*.^|[]/\\&/g')" + sed -Ei "/^[# ]*$var=/d" "$file" +} diff --git a/lib/hosts.sh b/lib/hosts.sh new file mode 100644 index 00000000..cf043e14 --- /dev/null +++ b/lib/hosts.sh @@ -0,0 +1,107 @@ +# shellcheck shell=bash +############################################################################### +# 1c. HTTP / WEB SERVER HELPERS +############################################################################### + +http_reload() { + printf "%bReloading HTTP...%b" "$MAGENTA" "$NC" + docker ps -qf name=NGINX &>/dev/null && docker exec NGINX nginx -s reload &>/dev/null || true + docker ps -qf name=APACHE &>/dev/null && docker exec APACHE apachectl graceful &>/dev/null || true + printf "\r%bHTTP reloaded! %b\n" "$GREEN" "$NC" +} + + +############################################################################### +# 3. DOMAIN / PROFILE INTEGRATION +############################################################################### +mkhost() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" mkhost "$@" +} +rmhost() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" rmhost "$@" +} + +setup_domain() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + + mkhost --RESET + docker exec -it "$ctr" mkhost + local mk_state svr_prof + mk_state="$(mkhost --JSON || true)" + if has_tool jq; then + svr_prof="$(printf '%s' "$mk_state" | jq -r '.state.apache_active // empty' 2>/dev/null || true)" + else + svr_prof="$(printf '%s' "$mk_state" | tr -d '\r\n' | sed -n 's/.*"apache_active"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + fi + [[ -n $svr_prof ]] && modify_profiles add "$svr_prof" + mkhost --RESET + cmd_reboot +} + +delete_domain() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + + rmhost --RESET + + # interactive delete + docker exec -it "$ctr" rmhost "$@" + + local rm_state apache_cont + rm_state="$(rmhost --JSON || true)" + if has_tool jq; then + apache_cont="$(printf '%s' "$rm_state" | jq -r '.state.apache_delete // empty' 2>/dev/null || true)" + else + apache_cont="$(printf '%s' "$rm_state" | tr -d '\r\n' | sed -n 's/.*"apache_delete"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + fi + [[ -n "$apache_cont" ]] && modify_profiles remove "$apache_cont" + + rmhost --RESET + cmd_reboot +} + +modify_profiles() { + local action=$1 + shift + local file=$ENV_DOCKER var=COMPOSE_PROFILES + local -a existing updated + + if [[ -r $file ]]; then + local line value + line=$(grep -E "^${var}=" "$file" | tail -n1 || true) + value=${line#*=} + IFS=',' read -r -a existing <<<"$value" + fi + + case $action in + add) + local p + for p; do + [[ -n $p && ! " ${existing[*]} " =~ " $p " ]] && updated+=("$p") + done + updated+=("${existing[@]}") + ;; + remove) + local old + for old in "${existing[@]}"; do + [[ ! " $* " =~ " $old " ]] && updated+=("$old") + done + ;; + *) die "modify_profiles: invalid action '$action'" ;; + esac + + update_env "$file" "$var" "$( + IFS=, + echo "${updated[*]}" + )" +} + diff --git a/lib/maintenance.sh b/lib/maintenance.sh new file mode 100644 index 00000000..2a32f1ec --- /dev/null +++ b/lib/maintenance.sh @@ -0,0 +1,479 @@ +# shellcheck shell=bash +############################################################################### +# RUN (ad-hoc Dockerfile runner) +############################################################################### +hash_short() { + local s="$1" + if has_cmd sha1sum; then + printf '%s' "$s" | sha1sum | cut -c1-8 + elif has_cmd shasum; then + printf '%s' "$s" | shasum -a 1 | cut -c1-8 + else + # POSIX fallback; stable (not cryptographic) + printf '%s' "$s" | cksum | awk '{print $1}' + fi +} + +run_slug() { + local dir="$1" base hash + base="$(basename "$dir" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')" + hash="$(hash_short "$dir")" + printf '%s-%s' "$base" "$hash" +} + +run_plan() { + local dir="$1" slug + slug="$(run_slug "$dir")" + printf '%s|%s|%s\n' \ + "lds-run-${slug}" \ + "${slug}:local" \ + "$dir" +} + +detect_host_os() { + if is_windows_shell || grep -qi microsoft /proc/version 2>/dev/null; then + printf 'windows' + return 0 + fi + + if has_cmd uname; then + case "$(uname -s 2>/dev/null || true)" in + Darwin) + printf 'macos' + ;; + Linux) + printf 'linux' + ;; + *) + printf 'unknown' + ;; + esac + else + printf 'unknown' + fi +} + +run_find_container() { + local dir="$1" + docker ps -a --filter "label=com.infocyph.lds.run=1" \ + --filter "label=com.infocyph.lds.dir=${dir}" \ + --format '{{.Names}}' | head -n 1 +} + +run_build() { + local tag="$1" dir="$2" + + # Build only if the image doesn't already exist. + if docker image inspect "$tag" >/dev/null 2>&1; then + printf "%b[run]%b Image exists, skipping build: %b%s%b +" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" + return 0 + fi + + printf "%b[run]%b Building image %b%s%b from %s +" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" "$dir" + docker build -t "$tag" "$dir" +} + +run_start() { + local name="$1" tag="$2" dir="$3" keepalive="$4" sock="$5" host_os="$6" + shift 6 || true + + # Remaining args are split by a "--" sentinel: + # - before "--" : publish specs (HOST:CONT), repeatable + # - after "--" : mount specs (HOST[:CONT]), repeatable + local -a pubs=() mounts=() + local seen_delim=0 x + for x in "$@"; do + if [[ "$x" == "--" ]]; then + seen_delim=1 + continue + fi + if ((seen_delim)); then + mounts+=("$x") + else + pubs+=("$x") + fi + done + + # Normalize project dir (POSIX absolute). + # On Windows Git Bash, /e/... is OK; docker.exe will receive converted path automatically. + local dir_posix + dir_posix="$(cd "$dir" 2>/dev/null && pwd -P)" || die "invalid dir: $dir" + + # MSYS-safe container paths: use '//' prefix to prevent path conversion. + # Docker interprets //path as /path inside container. + local WDIR="//workspace" + local WDIR_MOUNT="${dir_posix}://workspace" + + local -a args=(docker run -d --name "$name" + --label "com.infocyph.lds.run=1" + --label "com.infocyph.lds.dir=$dir_posix" + --label "com.infocyph.lds.tag=$tag" + -w "$WDIR" + -v "$WDIR_MOUNT" + ) + + if [[ -n "$host_os" ]]; then + args+=(-e "HOST_OS=$host_os") + fi + + # Mount extra directories/files (HOST[:CONT]). + # - If container path missing, mounts under /mnt/. + # - HOST may be relative to the run directory. + if ((${#mounts[@]})); then + local spec host cont base + for spec in "${mounts[@]}"; do + [[ -n "$spec" ]] || continue + host="$spec" + cont="" + + # Split as HOST:CONT ONLY if suffix after last ':' looks like a container absolute path (/...) + # (safe for Windows drive letters like E:\... because tail won't start with '/') + if [[ "$spec" == *:* ]]; then + local tail="${spec##*:}" + if [[ "$tail" == /* ]]; then + host="${spec%:*}" + cont="$tail" + fi + fi + + # Resolve host to absolute (POSIX) for checks + if [[ "$host" != /* && "$host" != ~* && ! "$host" =~ ^[A-Za-z]:[\\/].* ]]; then + host="${dir_posix%/}/$host" + fi + + # If user provided Windows path (E:\...), convert to POSIX for existence check + if [[ "$host" =~ ^[A-Za-z]:[\\/].* ]] && has_cmd cygpath; then + host="$(cygpath -u "$host")" + fi + + host="$(cd "${host%/*}" 2>/dev/null && pwd -P)/${host##*/}" || { + printf "%b[run]%b Warning: cannot resolve mount path: %s\n" "$YELLOW" "$NC" "$spec" >&2 + continue + } + + [[ -e "$host" ]] || { + printf "%b[run]%b Warning: mount path does not exist: %s\n" "$YELLOW" "$NC" "$host" >&2 + continue + } + + if [[ -z "$cont" ]]; then + base="${host##*/}" + cont="/mnt/${base}" + fi + [[ "$cont" == /* ]] || cont="/mnt/${cont}" + + # Prevent MSYS conversion for container side by using '//' prefix + cont="//${cont#/}" + + args+=(-v "${host}:${cont}") + done + fi + + # Optional docker sock + if [[ "${sock:-0}" == 1 ]]; then + args+=(-v "/var/run/docker.sock:/var/run/docker.sock") + fi + + # Publish ports + local pub + for pub in "${pubs[@]}"; do + [[ -n "$pub" ]] || continue + args+=(-p "$pub") + done + + if [[ "$keepalive" == 1 ]]; then + # Keepalive mode replaces the image command; disable image healthcheck to avoid false "unhealthy". + args+=(--no-healthcheck --entrypoint sh "$tag" -c "trap : TERM INT; sleep infinity & wait") + else + args+=("$tag") + fi + + printf "%b[run]%b Starting container %b%s%b\n" "$CYAN" "$NC" "$BLUE" "$name" "$NC" + + # IMPORTANT: don't hide errors; if it fails, you need to see why + if ! "${args[@]}"; then + printf "%b[run]%b docker run failed.\n" "$RED" "$NC" >&2 + return 1 + fi + printf "\n" +} + +run_exec_shell() { + local name="$1" + if docker exec "$name" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then + exec docker exec -it "$name" bash + else + exec docker exec -it "$name" sh + fi +} + +cmd_run() { + local action="*" dir="$PWD" name="" tag="" nobuild=0 keepalive=1 sock=0 host_os="" + local -a publish=() mounts=() + local open_port="" open_path="/" open_proto="http" + + while [[ $# -gt 0 ]]; do + case "$1" in + stop | rm | ps | shell | logs | open | "*") + action="$1" + shift + ;; + build) + action="*" + shift + ;; + --name) + name="${2:-}" + shift 2 + ;; + --tag) + tag="${2:-}" + shift 2 + ;; + --no-build) + nobuild=1 + shift + ;; + --no-keepalive) + keepalive=0 + shift + ;; + --sock) + sock=1 + shift + ;; + --host-os) + host_os="${2:-}" + shift 2 + ;; + -p | --publish) + publish+=("${2:-}") + shift 2 + ;; + --mount) + mounts+=("${2:-}") + shift 2 + ;; + --port) + open_port="${2:-}" + shift 2 + ;; + --path) + open_path="${2:-/}" + shift 2 + ;; + --https) + open_proto="https" + shift + ;; + --http) + open_proto="http" + shift + ;; + *) break ;; + esac + done + + # Host path (POSIX) for planning/labels + local dir_posix + dir_posix="$(cd "$dir" && pwd -P)" + + if [[ -z "$host_os" ]]; then + host_os="${HOST_OS:-$(detect_host_os)}" + fi + + # Docker path (may need Windows form for docker.exe) + local dir_docker="$dir_posix" + + # Windows Git Bash/MSYS hardening: + # - stop MSYS rewriting container paths (/workspace -> D:/Program Files/Git/workspace) + # - but still feed docker.exe Windows-absolute host paths for build/run contexts + if is_windows_shell; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + if has_cmd cygpath; then + dir_docker="$(cygpath -w "$dir_posix")" + fi + fi + + # Plan/name/tag should be based on the real project identity (POSIX dir) + IFS='|' read -r def_name def_tag _def_dir < <(run_plan "$dir_posix") + name="${name:-$def_name}" + + # Tag rules: + # - Default tag is ":local" (from run_plan) + # - If user passes --tag without ":", append ":local" + if [[ -n "${tag:-}" ]]; then + if [[ "$tag" != *:* ]]; then + tag="${tag}:local" + fi + else + tag="$def_tag" + fi + + _find_for_dir() { + local found + found="$(run_find_container "$dir_posix" || true)" + if [[ -n "$found" ]]; then + printf '%s' "$found" + return 0 + fi + if docker inspect "$name" >/dev/null 2>&1; then + printf '%s' "$name" + return 0 + fi + return 1 + } + + _run_build_summary() { + local img="$1" build_dir="$2" cname="$3" + local tag_only="${img##*:}" + + printf "\n%b[run]%b Build summary\n" "$CYAN" "$NC" + printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" + printf " %bTag:%b %s\n" "$BOLD" "$NC" "$tag_only" + printf " %bDir:%b %s\n" "$BOLD" "$NC" "$build_dir" + printf " %bName:%b %s\n" "$BOLD" "$NC" "$cname" + printf " %bKeepalive:%b %s\n" "$BOLD" "$NC" "$keepalive" + printf " %bSock:%b %s\n" "$BOLD" "$NC" "$sock" + printf " %bHost OS:%b %s\n" "$BOLD" "$NC" "$host_os" + + if ((${#publish[@]})); then + printf " %bPublish:%b %s\n" "$BOLD" "$NC" "${publish[*]}" + else + printf " %bPublish:%b (none)\n" "$BOLD" "$NC" + fi + + if ((${#mounts[@]})); then + printf " %bMounts:%b %s\n" "$BOLD" "$NC" "${mounts[*]}" + else + printf " %bMounts:%b (none)\n" "$BOLD" "$NC" + fi + printf "\n" + } + + _run_runtime_summary() { + local cname="$1" + local id img state ports + id="$(docker inspect -f '{{.Id}}' "$cname" 2>/dev/null | cut -c1-12 || true)" + img="$(docker inspect -f '{{.Config.Image}}' "$cname" 2>/dev/null || true)" + state="$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || true)" + ports="$(docker port "$cname" 2>/dev/null | sed '/^[[:space:]]*$/d' | tr '\n' '; ' | sed 's/; $//' || true)" + + printf "%b[run]%b Runtime summary\n" "$CYAN" "$NC" + printf " %bContainer:%b %s\n" "$BOLD" "$NC" "${cname}${id:+ ($id)}" + [[ -n "$img" ]] && printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" + [[ -n "$state" ]] && printf " %bState:%b %s\n" "$BOLD" "$NC" "$state" + if [[ -n "$ports" ]]; then + printf " %bPorts:%b %s\n" "$BOLD" "$NC" "$ports" + else + printf " %bPorts:%b (none published)\n" "$BOLD" "$NC" + fi + printf "%b\n[run]%b Example Usage (in Composer)\n" "$CYAN" "$NC" + printf " %bimage:%b %s\n" "$BOLD" "$NC" "$img" + printf " %bpull_policy:%b never\n" "$BOLD" "$NC" + printf "\n" + } + + case "$action" in + ps) + docker ps -a --filter "label=com.infocyph.lds.run=1" \ + --format 'table {{.Names}} {{.Image}} {{.Status}} {{.Labels}}' + return 0 + ;; + stop) + local existing + existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" + docker stop "$existing" >/dev/null + printf "%b[run]%b Stopped %s\n" "$GREEN" "$NC" "$existing" + return 0 + ;; + logs) + local existing + existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" + exec docker logs -f "$existing" + ;; + open) + local existing line addr hp url + existing="$(_find_for_dir)" || die "no run container found for: $dir_posix" + + [[ -n "$open_path" ]] || open_path="/" + [[ "$open_path" == /* ]] || open_path="/$open_path" + + if [[ -n "$open_port" ]]; then + line="$(docker port "$existing" "$open_port" 2>/dev/null | head -n 1 || true)" + [[ -n "$line" ]] || line="$(docker port "$existing" "${open_port}/tcp" 2>/dev/null | head -n 1 || true)" + else + line="$(docker port "$existing" 2>/dev/null | head -n 1 || true)" + fi + + if [[ -z "$line" ]]; then + printf "%b[run]%b No published ports found.\n" "$YELLOW" "$NC" + printf "%b[run]%b Tip: start with %blds run --publish 8025:8025%b then %blds run open%b\n" \ + "$YELLOW" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" + return 1 + fi + + addr="${line##*-> }" + hp="${addr##*:}" + url="${open_proto}://localhost:${hp}${open_path}" + open_url "$url" + printf "%b[run]%b Opened: %s\n" "$GREEN" "$NC" "$url" + return 0 + ;; + rm) + local existing img + existing="$(_find_for_dir)" || true + if [[ -n "${existing:-}" ]]; then + img="$(docker inspect -f '{{.Config.Image}}' "$existing" 2>/dev/null || true)" + docker stop "$existing" >/dev/null 2>&1 || true + docker rm "$existing" >/dev/null 2>&1 || true + printf "%b[run]%b Removed container %s\n" "$GREEN" "$NC" "$existing" + if [[ -n "${img:-}" ]]; then + docker rmi -f "$img" >/dev/null 2>&1 || true + printf "%b[run]%b Removed image %s\n" "$GREEN" "$NC" "$img" + fi + else + printf "%b[run]%b No container found for %s\n" "$YELLOW" "$NC" "$dir_posix" + fi + return 0 + ;; + shell | "*") + if ((nobuild == 0)); then + # Build needs docker.exe-friendly path on Windows + run_build "$tag" "$dir_docker" + else + printf "%b[run]%b Skipping build (--no-build)\n" "$YELLOW" "$NC" + fi + + _run_build_summary "$tag" "$dir_posix" "$name" + + if docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then + printf "%b[run]%b Container already running: %s\n\n" "$GREEN" "$NC" "$name" + else + if docker inspect "$name" >/dev/null 2>&1; then + docker rm -f "$name" >/dev/null 2>&1 || true + fi + + # Keep mounts as user gave them (POSIX/relative); run_start should validate POSIX + # and convert host-side to Windows only at docker run time. + run_start "$name" "$tag" "$dir_docker" "$keepalive" "$sock" "$host_os" \ + "${publish[@]}" -- "${mounts[@]}" + fi + + _run_runtime_summary "$name" + + # "shell" enters the container; "*" / "build" does not. + if [[ "$action" == "shell" ]]; then + run_exec_shell "$name" + else + printf "%b[run]%b Built/started. Use %blds run shell%b to enter, %blds run logs%b to follow logs.\n" \ + "$GREEN" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" + return 0 + fi + ;; + esac +} + diff --git a/lib/platform.sh b/lib/platform.sh new file mode 100644 index 00000000..13e0ddae --- /dev/null +++ b/lib/platform.sh @@ -0,0 +1,203 @@ +# shellcheck shell=bash + +# Detect a FastFlow-supported AMD XDNA2 NPU. +# FastFlowLM currently identifies XDNA2 as AMD PCI vendor/device 1022:17f0. +# The paths are injectable so the contract can be exercised without NPU hardware. +fastflow_npu_supported() { + local accel="${LDS_AI_ACCEL_DEVICE:-/dev/accel/accel0}" + local sysfs="${LDS_AI_ACCEL_SYSFS:-/sys/class/accel/accel0/device}" + local vendor="" device="" driver="" + + [[ -e "$accel" ]] || return 1 + [[ -r "$sysfs/vendor" && -r "$sysfs/device" ]] || return 1 + + vendor="$(tr '[:upper:]' '[:lower:]' <"$sysfs/vendor" | tr -d '[:space:]')" + device="$(tr '[:upper:]' '[:lower:]' <"$sysfs/device" | tr -d '[:space:]')" + if [[ -L "$sysfs/driver" ]]; then + driver="$(basename "$(readlink -f "$sysfs/driver" 2>/dev/null || true)")" + fi + + [[ "$vendor" == "0x1022" && "$device" == "0x17f0" ]] || return 1 + [[ -z "$driver" || "$driver" == "amdxdna" ]] || return 1 +} + +# Detect the preferred local-AI runtime from host accelerator capability. +# A supported XDNA2 NPU wins because FastFlowLM can use it directly. NVIDIA is +# next, then ROCm-capable AMD GPU, with CPU as the portable fallback. +detect_ai_runtime() { + if fastflow_npu_supported; then + printf '%s' npu + return 0 + fi + + if has_cmd nvidia-smi && nvidia-smi -L >/dev/null 2>&1; then + printf '%s' nvidia + return 0 + fi + if has_cmd nvidia-smi.exe && nvidia-smi.exe -L >/dev/null 2>&1; then + printf '%s' nvidia + return 0 + fi + + # The AMD Compose override requires the Linux ROCm device nodes, so merely + # having an AMD CPU/GPU name is not sufficient. + if [[ -e /dev/kfd && -d /dev/dri ]]; then + printf '%s' amd + return 0 + fi + + printf '%s' cpu +} + +ai_provider_for_runtime() { + case "${1,,}" in + npu) printf '%s' fastflow ;; + "" | cpu | nvidia | amd) printf '%s' ollama ;; + *) return 1 ;; + esac +} + +ai_service_for_runtime() { + case "$(ai_provider_for_runtime "${1:-}")" in + fastflow) printf '%s' llm-fastflow ;; + ollama) printf '%s' llm-ollama ;; + *) return 1 ;; + esac +} + +ai_model_default_for_runtime() { + case "${1,,}" in + npu) printf '%s' 'qwen3.5:9b' ;; + "" | cpu | nvidia | amd) printf '%s' 'qwen3:14b' ;; + *) return 1 ;; + esac +} + +effective_ai_runtime() { + local runtime + runtime="$(compose_control_value LDS_AI_RUNTIME "")" + [[ -n "$runtime" ]] || runtime="$(detect_ai_runtime)" + printf '%s' "${runtime,,}" +} + +effective_ai_model() { + local runtime="${1:-}" configured + [[ -n "$runtime" ]] || runtime="$(effective_ai_runtime)" + configured="$(compose_control_value LDS_AI_MODEL "")" + if [[ -n "$configured" ]]; then + printf '%s' "$configured" + else + ai_model_default_for_runtime "$runtime" + fi +} + +host_cpu_is_amd() { + if [[ -r /proc/cpuinfo ]] && + grep -qiE '^[[:space:]]*vendor_id[[:space:]]*:[[:space:]]*AuthenticAMD([[:space:]]|$)' /proc/cpuinfo; then + return 0 + fi + + if has_cmd lscpu && + lscpu 2>/dev/null | grep -qiE '^Vendor ID:[[:space:]]*AuthenticAMD([[:space:]]|$)'; then + return 0 + fi + + return 1 +} + +ai_igpu_default_for_runtime() { + if [[ "${1,,}" == "amd" ]] && host_cpu_is_amd; then + printf '%s' 1 + else + printf '%s' 0 + fi +} + +############################################################################### +# 2. INSTALL / PERMISSIONS (HOST) +############################################################################### +add_to_windows_path() { + [[ "$OSTYPE" =~ (msys|cygwin) ]] || return 0 + has_cmd cygpath || return 0 + + # Only add if lds.bat exists where we think it is + [[ -f "$DIR/lds.bat" ]] || return 0 + + local win_repo + win_repo="$(cygpath -w "$DIR")" + + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command " + \$t = '$win_repo' + \$cur = [Environment]::GetEnvironmentVariable('Path','User') + if ([string]::IsNullOrWhiteSpace(\$cur)) { \$cur = '' } + + # Normalize (trim + case-insensitive compare) to avoid duplicates + \$parts = \$cur -split ';' | ForEach-Object { \$_.Trim() } | Where-Object { \$_ } + \$exists = \$false + foreach (\$p in \$parts) { if (\$p.ToLowerInvariant() -eq \$t.ToLowerInvariant()) { \$exists = \$true; break } } + + if (-not \$exists) { + \$new = (\$parts + \$t) -join ';' + [Environment]::SetEnvironmentVariable('Path', \$new, 'User') + } + " >/dev/null 2>&1 || true +} + +fix_perms() { + if [[ "$OSTYPE" =~ (msys|cygwin) ]]; then + add_to_windows_path + printf "%bWindows PATH configured.%b\n" "$GREEN" "$NC" + return 0 + fi + + ((EUID == 0)) || die "Please run with sudo." + + local owner group + owner="${SUDO_USER:-${USER:-}}" + [[ -n "$owner" ]] || owner="$(id -un)" + id "$owner" >/dev/null 2>&1 || die "Cannot resolve permission owner: $owner" + + if getent group docker >/dev/null 2>&1; then + group=docker + else + group="$(id -gn "$owner")" + fi + + chmod 755 "$DIR" + + chown -R "$owner:$group" "$DIR/configuration" "$DIR/logs" + find "$DIR/configuration" -type d -exec chmod 2775 {} + + find "$DIR/configuration" -type f -exec chmod 0664 {} + + find "$DIR/logs" -type d -exec chmod 2775 {} + + find "$DIR/logs" -type f -exec chmod 0664 {} + + + # Secret-bearing host directories stay private to the workstation owner. + for private_dir in "$DIR/configuration/ssh" "$DIR/configuration/sops/keys"; do + [[ -d "$private_dir" ]] || continue + chown -R "$owner:$group" "$private_dir" + find "$private_dir" -type d -exec chmod 0700 {} + + find "$private_dir" -type f -exec chmod 0600 {} + + done + + # Public CA exports may stay readable, but password-protected/user key + # artifacts must retain the restrictive mode Tools assigns to them. + if [[ -d "$DIR/configuration/ssl" ]]; then + find "$DIR/configuration/ssl" -type d -exec chmod 0755 {} + + find "$DIR/configuration/ssl" -type f -exec chmod 0644 {} + + find "$DIR/configuration/ssl" -type f \( -name '*.p12' -o -name '*.pfx' -o -name '*.key' -o -name '*-key.pem' \) -exec chmod 0600 {} + + fi + + find "$DIR/docker" -type d -exec chmod 0755 {} + + find "$DIR/docker" -type f -exec chmod 0644 {} + + + chmod 0755 "$DIR/bin" + find "$DIR/bin" -type f -exec chmod 0755 {} + + if [[ -d "$DIR/lib" ]]; then + find "$DIR/lib" -type d -exec chmod 0755 {} + + find "$DIR/lib" -type f -exec chmod 0644 {} + + fi + chmod 0755 "$DIR/lds" + + ln -fs "$DIR/lds" /usr/local/bin/lds + printf "%bPermissions assigned to %s:%s.%b\n" "$GREEN" "$owner" "$group" "$NC" +} diff --git a/lib/profiles.sh b/lib/profiles.sh new file mode 100644 index 00000000..8211f859 --- /dev/null +++ b/lib/profiles.sh @@ -0,0 +1,303 @@ +# shellcheck shell=bash +############################################################################### +# 3a. PROFILES: DEFINITIONS + SETUP FLOW +############################################################################### + +CATALOG_FILE="$CFG/catalog/services.psv" + +declare -A SERVICES=() +declare -A SERVICE_DISPLAY=() +declare -A SERVICE_KEY=() +declare -A SERVICE_VERSION_ENV=() +declare -A PROFILE_ENV=() +declare -A PROFILE_PROMPTS=() +declare -A SERVICE_ADMIN_CLIENT=() +declare -A SERVICE_VOLUME=() +declare -A SERVICE_URL=() +declare -A SERVICE_CATEGORY=() +declare -A SERVICE_OPTIONAL=() +declare -A SERVICE_DEFAULT_ENABLED=() +declare -A SERVICE_RUNTIME_MODES=() +declare -a SERVICE_ORDER=() + +load_service_catalog() { + [[ -r "$CATALOG_FILE" ]] || die "Missing service catalog: $CATALOG_FILE" + + local key profile display service_key version_env defaults prompts + local admin_client volume url category optional default_enabled runtime_modes + + while IFS='|' read -r key profile display service_key version_env defaults prompts admin_client volume url category optional default_enabled runtime_modes; do + [[ -n "$key" && "$key" != \#* ]] || continue + [[ -n "$profile" && -n "$display" && -n "$service_key" ]] || + die "Invalid service catalog row for: $key" + [[ -z "${SERVICES[$key]+x}" ]] || die "Duplicate service catalog key: $key" + [[ -z "${PROFILE_ENV[$profile]+x}" ]] || die "Duplicate service catalog profile: $profile" + + SERVICE_ORDER+=("$key") + SERVICES["$key"]="$profile" + SERVICE_DISPLAY["$key"]="$display" + SERVICE_KEY["$key"]="$service_key" + SERVICE_VERSION_ENV["$key"]="$version_env" + PROFILE_ENV["$profile"]="$defaults" + PROFILE_PROMPTS["$profile"]="$prompts" + SERVICE_ADMIN_CLIENT["$key"]="$admin_client" + SERVICE_VOLUME["$key"]="$volume" + SERVICE_URL["$key"]="$url" + SERVICE_CATEGORY["$key"]="$category" + SERVICE_OPTIONAL["$key"]="$optional" + SERVICE_DEFAULT_ENABLED["$key"]="$default_enabled" + SERVICE_RUNTIME_MODES["$key"]="$runtime_modes" + done <"$CATALOG_FILE" + + (("${#SERVICE_ORDER[@]}" > 0)) || die "Service catalog is empty: $CATALOG_FILE" +} + +load_service_catalog + +declare -a PENDING_ENVS=() +declare -a PENDING_PROFILES=() + +queue_env() { PENDING_ENVS+=("$1"); } +queue_profile() { PENDING_PROFILES+=("$1"); } + +flush_envs() { + local env_file="$ENV_DOCKER" kv key val + for kv in "${PENDING_ENVS[@]}"; do + IFS='=' read -r key val <<<"$kv" + update_env "$env_file" "$key" "$val" + done +} + +flush_profiles() { + local current key profile + local -A managed=() seen=() + local -a existing=() updated=() + + # The setup wizard owns only catalog-managed service profiles. Generated + # runtime/domain profiles (for example apache/php/node fragments) must survive + # a service-profile reselection. + for key in "${SERVICE_ORDER[@]}"; do + profile="${SERVICES[$key]:-}" + [[ -n "$profile" ]] && managed["$profile"]=1 + done + + for profile in "${PENDING_PROFILES[@]}"; do + [[ -n "$profile" && -z "${seen[$profile]:-}" ]] || continue + updated+=("$profile") + seen["$profile"]=1 + done + + current="$(dotenv_value "$ENV_DOCKER" COMPOSE_PROFILES 2>/dev/null || true)" + IFS=',' read -r -a existing <<<"$current" + for profile in "${existing[@]}"; do + profile="${profile//[[:space:]]/}" + [[ -n "$profile" ]] || continue + [[ -n "${managed[$profile]:-}" ]] && continue + [[ -n "${seen[$profile]:-}" ]] && continue + updated+=("$profile") + seen["$profile"]=1 + done + + local joined="" + if (("${#updated[@]}" > 0)); then + joined="$(IFS=,; printf '%s' "${updated[*]}")" + fi + update_env "$ENV_DOCKER" COMPOSE_PROFILES "$joined" +} + +# ── setup menu (selection-first) ────────────────────────────────────────────── + +setup_menu_print() { + # Print menu to stderr to avoid stdout buffering in some Windows wrappers. + { + printf "\n%bSetup profiles%b (replaces catalog-managed service profiles; generated runtime/domain profiles are preserved):\n\n" "$CYAN" "$NC" + local i=1 key slug display + for key in "${SERVICE_ORDER[@]}"; do + slug="${SERVICES[$key]}" + display="${SERVICE_DISPLAY[$key]}" + printf " %2d) %-16s (%s)\n" "$i" "$display" "$slug" + i=$((i + 1)) + done + printf "\n a) ALL\n" + printf " n) NONE\n" + printf " q) CANCEL / Back\n\n" + } >&2 +} + +# Parse user selection into indices or ALL/NONE/CANCEL (prints one token per line) +setup_menu_parse() { + local input="${1//[[:space:]]/}" + [[ -n "$input" ]] || return 1 + input="${input//;/,}" + + echo "$input" | tr ',' '\n' | awk ' + BEGIN { ok=1 } + /^[0-9]+-[0-9]+$/ { + split($0,a,"-") + if (a[1] > a[2]) { t=a[1]; a[1]=a[2]; a[2]=t } + for (i=a[1]; i<=a[2]; i++) print i + next + } + /^[0-9]+$/ { print $0; next } + /^[aA]$/ { print "ALL"; next } + /^[nN]$/ { print "NONE"; next } + /^[qQbB]$/ { print "CANCEL"; next } + { ok=0 } + END { if (!ok) exit 2 } + ' +} + +# Outputs: newline-separated service KEYS from SERVICE_ORDER (e.g. MYSQL, REDIS) +setup_choose_services() { + local ans parsed + while :; do + setup_menu_print + tty_readline ans "Select (e.g. 1,3,5 or 2-4 or a): " || return 1 + + if ! parsed="$(setup_menu_parse "$ans" 2>/dev/null)"; then + printf "%bInvalid selection.%b Try again.\n" "$YELLOW" "$NC" + continue + fi + + if grep -qx "CANCEL" <<<"$parsed"; then + return 1 + fi + + if grep -qx "NONE" <<<"$parsed"; then + printf '%s\n' "__NONE__" + return 0 + fi + + if grep -qx "ALL" <<<"$parsed"; then + printf "%s\n" "${SERVICE_ORDER[@]}" + return 0 + fi + + # Indices -> keys (de-dupe, preserve order) + local -A seen=() + local out=() + local idx key + while IFS= read -r idx; do + [[ "$idx" =~ ^[0-9]+$ ]] || continue + ((idx >= 1 && idx <= ${#SERVICE_ORDER[@]})) || continue + key="${SERVICE_ORDER[idx - 1]}" + [[ -n "${seen[$key]:-}" ]] && continue + seen[$key]=1 + out+=("$key") + done <<<"$parsed" + + if ((${#out[@]} == 0)); then + printf "%bNo valid items selected.%b\n" "$YELLOW" "$NC" + continue + fi + + printf "%s\n" "${out[@]}" + return 0 + done +} + +setup_service() { + local service="$1" + local profile="${SERVICES[$service]:-}" + local display="${SERVICE_DISPLAY[$service]:-$service}" + [[ -n "$profile" ]] || die "Unknown service: $service" + + printf "\n%b→ %s%b\n" "$YELLOW" "$display" "$NC" + queue_profile "$profile" + + if [[ "$service" == "AI" ]]; then + local detected_runtime detected_arch detected_provider detected_model detected_image + detected_runtime="$(compose_control_value LDS_AI_RUNTIME "$(detect_ai_runtime)")" + detected_provider="$(ai_provider_for_runtime "$detected_runtime")" + detected_model="$(effective_ai_model "$detected_runtime")" + if [[ "$detected_provider" == "fastflow" ]]; then + detected_image="infocyph/llm-fastflow:latest" + else + detected_arch="$(llm_arch_for_runtime "$detected_runtime")" + detected_image="infocyph/llm-ollama:$detected_arch" + fi + printf "%bDetected local-AI runtime:%b %s (%s, %s, model %s)\n" \ + "$CYAN" "$NC" "$detected_runtime" "$detected_provider" "$detected_image" "$detected_model" + fi + + local defaults="${PROFILE_ENV[$profile]:-}" + [[ -n "$defaults" ]] || return 0 + + printf "%bEnter value(s) for %s:%b\n" "$BLUE" "$display" "$NC" + + local -a pairs=() prompts=() + IFS=';' read -r -a pairs <<<"$defaults" + IFS=';' read -r -a prompts <<<"${PROFILE_PROMPTS[$profile]:-}" + + local i pair key def val prompt current input + for i in "${!pairs[@]}"; do + pair="${pairs[$i]}" + [[ -n "$pair" ]] || continue + IFS='=' read -r key def <<<"$pair" + prompt="${prompts[$i]:-$key}" + current="$(dotenv_value "$ENV_DOCKER" "$key" 2>/dev/null || true)" + + # Re-running setup must not reset a user's selected versions or credentials. + # Secret-like values are preserved without printing their current/default value. + case "$key" in + *PASSWORD* | *SECRET* | *TOKEN* | *PRIVATE_KEY* | *API_KEY* | *ACCESS_KEY*) + if [[ -n "$current" ]]; then + tty_readline input "$(printf '%b%s [configured; Enter keeps current]:%b ' "$CYAN" "$prompt" "$NC")" || return 1 + val="${input:-$current}" + else + tty_readline input "$(printf '%b%s [Enter uses catalog default]:%b ' "$CYAN" "$prompt" "$NC")" || return 1 + val="${input:-$def}" + fi + ;; + *) + val="$(read_default "$prompt" "${current:-$def}")" + ;; + esac + + case "$key" in + LDS_AI_RUNTIME) + val="${val,,}" + case "$val" in + "" | auto | cpu | nvidia | amd | npu) ;; + *) die "AI runtime must be auto, cpu, nvidia, amd, or npu" ;; + esac + [[ "$val" == "auto" ]] && val="" + ;; + esac + + queue_env "$key=$val" + done +} + +process_all() { + local selected + PENDING_ENVS=() + PENDING_PROFILES=() + + if ! selected="$(setup_choose_services)"; then + printf "\n%bSetup cancelled.%b\n" "$YELLOW" "$NC" + return 0 + fi + + if [[ "$selected" == "__NONE__" ]]; then + flush_profiles + printf "\n%b✅ Catalog-managed service profiles cleared; generated runtime/domain profiles preserved.%b\n" "$GREEN" "$NC" + return 0 + fi + + printf "\n%bWill configure:%b\n" "$CYAN" "$NC" + while IFS= read -r svc; do + printf " - %s (%s)\n" "${SERVICE_DISPLAY[$svc]:-$svc}" "${SERVICES[$svc]}" + done <<<"$selected" + echo + + local svc + while IFS= read -r svc; do + setup_service "$svc" + done <<<"$selected" + + flush_envs + flush_profiles + printf "\n%b✅ Selected services configured!%b\n" "$GREEN" "$NC" +} + diff --git a/lib/services.sh b/lib/services.sh new file mode 100644 index 00000000..c5a4de5c --- /dev/null +++ b/lib/services.sh @@ -0,0 +1,1179 @@ +# shellcheck shell=bash +############################################################################### +# Compose helpers for rebuild (robust: supports service key OR container name) +############################################################################### +__COMPOSE_CFG_JSON="" +__COMPOSE_CFG_YAML="" +__COMPOSE_SVCS_LOADED=0 +declare -a __COMPOSE_SVCS=() + +compose_cfg_json() { + if [[ -z "${__COMPOSE_CFG_JSON}" ]]; then + __COMPOSE_CFG_JSON="$(docker_compose config --format json 2>/dev/null || true)" + fi + printf '%s' "${__COMPOSE_CFG_JSON}" +} + +compose_cfg_yaml() { + if [[ -z "${__COMPOSE_CFG_YAML}" ]]; then + __COMPOSE_CFG_YAML="$(docker_compose config 2>/dev/null || true)" + fi + printf '%s' "${__COMPOSE_CFG_YAML}" +} + +compose_services_load() { + ((__COMPOSE_SVCS_LOADED)) && return 0 + mapfile -t __COMPOSE_SVCS < <(docker_compose config --services 2>/dev/null || true) + __COMPOSE_SVCS_LOADED=1 +} + +compose_service_exists() { + local want="${1:-}" s + [[ -n "$want" ]] || return 1 + compose_services_load + for s in "${__COMPOSE_SVCS[@]}"; do + [[ "$s" == "$want" ]] && return 0 + done + return 1 +} + +resolve_service() { + local raw="${1:-}" norm svc + raw="${raw//[[:space:]]/}" + [[ -n "$raw" ]] || { + printf '' + return 0 + } + + compose_service_exists "$raw" && { + printf '%s' "$raw" + return 0 + } + + # "llm" is the stable operational alias for whichever mutually-exclusive + # provider is active for the effective runtime. + if [[ "${raw,,}" == "llm" ]]; then + norm="$(ai_service_for_runtime "$(effective_ai_runtime)")" || + die "Unable to resolve the active LLM provider service" + compose_service_exists "$norm" || die "Active LLM provider service is unavailable: $norm" + printf '%s' "$norm" + return 0 + fi + + norm="$(normalize_service "$raw")" + compose_service_exists "$norm" && { + printf '%s' "$norm" + return 0 + } + + if docker inspect "$raw" >/dev/null 2>&1; then + svc="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.service" }}' "$raw" 2>/dev/null || true)" + if [[ -n "$svc" ]] && compose_service_exists "$svc"; then + printf '%s' "$svc" + return 0 + fi + fi + + printf '%s' "$norm" +} + +compose_has_build() { + local svc="$1" json + json="$(compose_cfg_json)" + if [[ -n "$json" ]]; then + if has_tool jq; then + jq -e --arg s "$svc" '.services[$s].build != null' >/dev/null <<<"$json" + return $? + fi + fi + + compose_cfg_yaml | awk -v s="$svc" ' + $1=="services:" {in_services=1; next} + in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} + in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 1} + in_svc && $0 ~ /^ build:/ {exit 0} + END {exit 1} + ' +} + +compose_image_for_service() { + local svc="$1" json + json="$(compose_cfg_json)" + if [[ -n "$json" ]]; then + if has_tool jq; then + jq -r --arg s "$svc" '.services[$s].image // empty' <<<"$json" + return 0 + fi + fi + + compose_cfg_yaml | awk -v s="$svc" ' + $1=="services:" {in_services=1; next} + in_services && $0 ~ ("^ " s ":$") {in_svc=1; next} + in_svc && $0 ~ /^ [A-Za-z0-9_.-]+:$/ {exit 0} + in_svc && $0 ~ /^ image:/ { + sub(/^ image:[[:space:]]*/, "", $0) + print $0 + exit 0 + } + ' +} + +############################################################################### +# 6. STACK COMMANDS (CLI) +############################################################################### + +# One-time migration from the historical fixed 172.28/29/30 /24 networks. +# Only known LocalDevStack-owned networks are touched, and named volumes are +# never removed. New networks carry com.infocyph.network-schema=dynamic-v1. +declare -a __LDS_LEGACY_NETWORK_NAMES=(Frontend Backend DataStore) + +legacy_network_expected_subnet() { + case "${1:-}" in + Frontend) printf '%s' '172.28.0.0/24' ;; + Backend) printf '%s' '172.29.0.0/24' ;; + DataStore) printf '%s' '172.30.0.0/24' ;; + *) return 1 ;; + esac +} + +migrate_legacy_networks() { + local network expected subnets stack_label project_label schema_label ctr ctr_project attachments project + local -a legacy=() + project="$(lds_project)" + + for network in "${__LDS_LEGACY_NETWORK_NAMES[@]}"; do + docker network inspect "$network" >/dev/null 2>&1 || continue + + schema_label="$(docker network inspect -f '{{index .Labels "com.infocyph.network-schema"}}' "$network" 2>/dev/null || true)" + [[ "$schema_label" != "dynamic-v1" ]] || continue + + expected="$(legacy_network_expected_subnet "$network")" + subnets="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "$network" 2>/dev/null || true)" + grep -Fxq "$expected" <<<"$subnets" || continue + + stack_label="$(docker network inspect -f '{{index .Labels "com.infocyph.stack"}}' "$network" 2>/dev/null || true)" + project_label="$(docker network inspect -f '{{index .Labels "com.docker.compose.project"}}' "$network" 2>/dev/null || true)" + if [[ "$stack_label" != "LocalDevStack" || "$project_label" != "$project" ]]; then + die "Legacy subnet detected on '$network', but ownership labels do not prove it belongs to LocalDevStack project '$project'. Remove or rename that network manually." + fi + + while IFS= read -r ctr; do + [[ -n "$ctr" ]] || continue + ctr_project="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}' "$ctr" 2>/dev/null || true)" + if [[ "$ctr_project" != "$project" ]]; then + die "Refusing to migrate '$network': container '$ctr' is not owned by LocalDevStack project '$project'." + fi + done < <(docker network inspect -f '{{range .Containers}}{{println .Name}}{{end}}' "$network" 2>/dev/null || true) + + legacy+=("$network") + done + + (("${#legacy[@]}" > 0)) || return 0 + + warn "Legacy fixed LocalDevStack network(s) detected: ${legacy[*]}" + warn "Recreating stack networks dynamically; named volumes and persisted data are preserved." + + # Stop/remove only LocalDevStack Compose containers and networks. Never use -v. + docker_compose down --remove-orphans + + for network in "${legacy[@]}"; do + docker network inspect "$network" >/dev/null 2>&1 || continue + attachments="$(docker network inspect -f '{{range .Containers}}{{println .Name}}{{end}}' "$network" 2>/dev/null || true)" + [[ -z "$attachments" ]] || + die "Cannot remove legacy network '$network': attached container(s) remain: $(tr '\n' ' ' <<<"$attachments")" + docker network rm "$network" >/dev/null + done + + ok "Legacy fixed networks removed; Compose will recreate dynamic bridge networks." +} + +cmd_vpn_fix() { + warn "vpn-fix is deprecated: LocalDevStack no longer owns fixed Docker subnets." + warn "If a VPN conflict remains after dynamic-network migration, diagnose the VPN/Docker route directly." +} + +cmd_up() { + migrate_legacy_networks + dc_up "$@" +} + +cmd_start() { + migrate_legacy_networks + dc_up -d "$@" + http_reload +} + +cmd_stop() { docker_compose down; } + +cmd_down() { + # Safety rails: + # lds down --volumes requires --yes + local yes=0 vols=0 + local -a args=() + while [[ "${1:-}" ]]; do + case "$1" in + --yes | -y) + yes=1 + shift + ;; + --volumes | -v) + vols=1 + args+=("--volumes") + shift + ;; + --remove-orphans) + args+=("--remove-orphans") + shift + ;; + *) + args+=("$1") + shift + ;; + esac + done + if ((vols)) && ((yes == 0)); then + die "Refusing: down --volumes requires --yes" + fi + docker_compose down "${args[@]}" +} + +cmd_restart() { + if (($# == 0)); then + cmd_stop + cmd_start + return 0 + fi + + local arg svc + local -a services=() + for arg in "$@"; do + svc="$(resolve_service "$arg")" + compose_service_exists "$svc" || die "Unknown service: $arg" + services+=("$svc") + done + + docker_compose restart "${services[@]}" +} +cmd_reboot() { cmd_restart; } + +# ───────────────────────────────────────────────────────────────────────────── +# 6a. STATUS / PS / STATS +# ───────────────────────────────────────────────────────────────────────────── +cmd_ps() { + if (($#)); then + docker_compose ps "$@" + else + docker_compose ps + fi +} + +cmd_status() { + local ctr project + project="$(lds_project)" + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container not found or not running for project: $project" + + local -a flags=() + [[ -t 1 ]] && flags+=(-t) + docker exec "${flags[@]}" "$ctr" status "$@" +} +# ───────────────────────────────────────────────────────────────────────────── +# 6b. LOGS / OPEN +# ───────────────────────────────────────────────────────────────────────────── +cmd_logs() { + local svc="" follow=0 since="" grep_pat="" + while [[ "${1:-}" ]]; do + case "$1" in + -f | --follow) + follow=1 + shift + ;; + --since) + since="${2:-}" + shift 2 + ;; + --grep) + grep_pat="${2:-}" + shift 2 + ;; + *) + svc="${1:-}" + shift + ;; + esac + done + + local -a args=() + ((follow)) && args+=("-f") + [[ -n "$since" ]] && args+=("--since" "$since") + + if [[ -n "$svc" ]]; then + local s + s="$(resolve_service "$svc" || true)" + [[ -n "$s" ]] || die "Unknown service: $svc" + if [[ -n "$grep_pat" ]]; then + docker_compose logs "${args[@]}" "$s" 2>&1 | text_grep "$grep_pat" + else + docker_compose logs "${args[@]}" "$s" + fi + else + if [[ -n "$grep_pat" ]]; then + docker_compose logs "${args[@]}" 2>&1 | text_grep "$grep_pat" + else + docker_compose logs "${args[@]}" + fi + fi +} + +_enabled_profiles_csv() { + compose_control_value COMPOSE_PROFILES "" +} + +profile_enabled() { + local wanted="${1:-}" csv p + [[ -n "$wanted" ]] || return 1 + csv="$(_enabled_profiles_csv)" + IFS=',' read -r -a __lds_profiles <<<"$csv" + for p in "${__lds_profiles[@]}"; do + p="${p//[[:space:]]/}" + [[ "$p" == "$wanted" ]] && return 0 + done + return 1 +} + +cmd_urls() { + local -A seen=() + local url key profile + + _print_url() { + local value="${1:-}" + [[ -n "$value" ]] || return 0 + [[ -z "${seen[$value]:-}" ]] || return 0 + seen["$value"]=1 + printf '%s\n' "$value" + } + + _print_url "https://admin.localhost" + _print_url "https://webmail.localhost" + + for key in "${SERVICE_ORDER[@]}"; do + profile="${SERVICES[$key]:-}" + url="${SERVICE_URL[$key]:-}" + [[ -n "$profile" && -n "$url" ]] || continue + profile_enabled "$profile" || continue + _print_url "$url" + done +} + +cmd_open() { + local target="${1:-}" + [[ -n "$target" ]] || die "open " + local url="" + case "${target,,}" in + http://* | https://*) url="$target" ;; + admin | tools) url="https://admin.localhost" ;; + mail | mailpit | webmail) url="https://webmail.localhost" ;; + db | cloudbeaver) url="https://db.localhost" ;; + redis | redisinsight | redis-insight | rds) url="https://ri.localhost" ;; + mongo | me | mongoexpress | mongo-express) url="https://me.localhost" ;; + kibana | kbn) url="https://kibana.localhost" ;; + ai | llm) url="https://llm.localhost" ;; + llm-ollama | ollama) url="https://llm-ollama.localhost" ;; + llm-fastflow | fastflow) url="https://llm-fastflow.localhost" ;; + *) + url="https://${target}" + ;; + esac + open_url "$url" +} + +# ───────────────────────────────────────────────────────────────────────────── +# 6c. PROFILES +# ───────────────────────────────────────────────────────────────────────────── +_known_profile() { + local p="${1:-}" + [[ -n "$p" ]] || return 1 + + # Prefer compose-config JSON for exact profile membership. + local json + json="$(compose_cfg_json)" + if [[ -n "$json" ]] && has_tool jq; then + printf '%s' "$json" | jq -e --arg p "$p" ' + [ .services[]? | (.profiles // [])[] ] | index($p) != null + ' >/dev/null 2>&1 + return $? + fi + + # Fallback: text scan when jq/json path is unavailable. + local f + for f in "$COMPOSE_FILE" "${__EXTRA_FILES[@]:-}"; do + [[ -r "$f" ]] || continue + grep -Fq -- "$p" "$f" && return 0 + done + return 1 +} + +cmd_profiles() { + local action="${1:-list}" + shift || true + case "${action,,}" in + list | "") + local cur="" + [[ -r "$ENV_DOCKER" ]] && cur="$(grep -E '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)" + printf "%bEnabled profiles:%b %s +" "$CYAN" "$NC" "${cur:-}" + printf "%bAvailable profiles:%b +" "$CYAN" "$NC" + local available + available="$(docker_compose config --profiles 2>/dev/null || true)" + if [[ -n "$available" ]]; then + printf '%s\n' "$available" | sed '/^[[:space:]]*$/d' | LC_ALL=C sort -fu | sed 's/^/ - /' + else + printf ' %b%b\n' "$DIM" "$NC" + fi + # warn if enabled profile has no mention in compose + if [[ -n "$cur" ]]; then + local p + IFS=',' read -r -a __ps <<<"$cur" + for p in "${__ps[@]}"; do + p="${p//[[:space:]]/}" + [[ -n "$p" ]] || continue + _known_profile "$p" || printf "%b[warn]%b enabled profile '%s' has no matching services in compose +" "$YELLOW" "$NC" "$p" + done + fi + ;; + add) + [[ $# -gt 0 ]] || die "profiles add " + local p + for p in "$@"; do + _known_profile "$p" || die "Unknown profile: $p" + modify_profiles add "$p" + done + ;; + remove | rm | del) + [[ $# -gt 0 ]] || die "profiles remove " + modify_profiles remove "$@" + ;; + *) + die "profiles " + ;; + esac +} + + +# ───────────────────────────────────────────────────────────────────────────── +# 6e. SECRETS / CERT / HOST / UI +# ───────────────────────────────────────────────────────────────────────────── +cmd_secrets() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" senv "$@" +} + +cmd_cert() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" certify "$@" +} + +cmd_host() { + local sub="${1:-}" + shift || true + case "${sub,,}" in + add) + setup_domain + ;; + rm | remove | del | delete) + delete_domain "$@" + ;; + list) + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" sh -lc ' + for f in /etc/share/vhosts/nginx/*.conf; do + [ -e "$f" ] || continue + basename "$f" .conf + done + ' | LC_ALL=C sort + ;; + *) + die "host " + ;; + esac +} + +cmd_ui() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" lazydocker +} + +# ───────────────────────────────────────────────────────────────────────────── +# 6f. EXEC / EVENTS / CLEAN / DISK +# ───────────────────────────────────────────────────────────────────────────── +cmd_exec() { + local svc="${1:-}" + shift || true + [[ -n "$svc" ]] || die "exec [cmd...]" + local s + s="$(resolve_service "$svc" || true)" + [[ -n "$s" ]] || die "Unknown service: $svc" + if [[ $# -gt 0 ]]; then + docker_compose exec "$s" "$@" + else + docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' + fi +} + +cmd_events() { + local since="${1:-1h}" + local project + project="$(lds_project)" + docker events --since "$since" --filter "label=com.docker.compose.project=$project" +} + +cmd_clean() { + local yes=0 vols=0 global=0 + while [[ "${1:-}" ]]; do + case "$1" in + --yes | -y) + yes=1 + shift + ;; + --volumes | -v) + vols=1 + shift + ;; + --global) + global=1 + shift + ;; + *) + die "clean [--yes|-y] [--volumes|-v] [--global]" + ;; + esac + done + + ((yes)) || die "clean requires --yes" + + if ((global)); then + warn "Global Docker cleanup requested; unrelated stopped containers, images, networks, build cache, and optionally volumes may be removed." + + printf "%b[clean]%b globally pruning stopped containers...\n" "$CYAN" "$NC" + docker container prune -f >/dev/null 2>&1 || true + printf "%b[clean]%b globally pruning unused networks...\n" "$CYAN" "$NC" + docker network prune -f >/dev/null 2>&1 || true + printf "%b[clean]%b globally pruning unused images...\n" "$CYAN" "$NC" + docker image prune -a -f >/dev/null 2>&1 || true + printf "%b[clean]%b globally pruning build cache...\n" "$CYAN" "$NC" + docker builder prune -a -f >/dev/null 2>&1 || true + if ((vols)); then + printf "%b[clean]%b globally pruning unused volumes...\n" "$CYAN" "$NC" + docker volume prune -f >/dev/null 2>&1 || true + fi + printf "%b[clean]%b global cleanup done\n" "$GREEN" "$NC" + return 0 + fi + + local project id net refs image + local -a ids=() networks=() volumes=() images=() + project="$(lds_project)" + + mapfile -t ids < <( + { + docker ps -aq --filter "label=com.docker.compose.project=$project" --filter status=created + docker ps -aq --filter "label=com.docker.compose.project=$project" --filter status=exited + docker ps -aq --filter "label=com.docker.compose.project=$project" --filter status=dead + } 2>/dev/null | awk 'NF' | sort -u + ) + if (("${#ids[@]}" > 0)); then + printf "%b[clean]%b removing stopped LocalDevStack containers...\n" "$CYAN" "$NC" + docker rm "${ids[@]}" >/dev/null 2>&1 || true + fi + + mapfile -t networks < <( + docker network ls -q --filter "label=com.infocyph.stack=LocalDevStack" --filter "label=com.docker.compose.project=$project" 2>/dev/null || true + ) + for net in "${networks[@]}"; do + [[ -n "$net" ]] || continue + refs="$(docker network inspect -f '{{len .Containers}}' "$net" 2>/dev/null || printf '1')" + [[ "$refs" == "0" ]] || continue + docker network rm "$net" >/dev/null 2>&1 || true + done + + mapfile -t images < <( + { + docker images -q --filter 'reference=localdevstack-php:*' + docker images -q --filter 'reference=localdevstack-node:*' + } 2>/dev/null | awk 'NF' | sort -u + ) + for image in "${images[@]}"; do + docker image rm "$image" >/dev/null 2>&1 || true + done + + if ((vols)); then + mapfile -t volumes < <( + docker volume ls -q --filter "label=com.infocyph.lds=1" --filter "label=com.infocyph.stack=LocalDevStack" 2>/dev/null || true + ) + for id in "${volumes[@]}"; do + [[ -n "$id" ]] || continue + docker volume rm "$id" >/dev/null 2>&1 || true + done + fi + + printf "%b[clean]%b LocalDevStack-scoped cleanup done\n" "$GREEN" "$NC" + warn "Docker build cache is intentionally untouched by scoped cleanup; use --global for host-wide pruning." +} + + +normalize_service() { + local raw="${1:-}" + local s="${raw//[[:space:]]/}" + [[ -n "$s" ]] || { + printf '%s' "" + return 0 + } + + local low="${s,,}" + + local key="${low//_/}" + key="${key//-/}" + if [[ "$key" =~ ^php ]]; then + local ver="${key#php}" + ver="${ver//[^0-9]/}" + if [[ "$ver" =~ ^([0-9])([0-9]).* ]]; then + printf 'php%s%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + return 0 + fi + printf 'php' + return 0 + fi + + low="${low//_/-}" + while [[ "$low" == *"--"* ]]; do low="${low//--/-}"; done + printf '%s' "$low" +} + +cmd_rebuild() { + local -a targets=() all_svcs=() + local arg svc img + declare -A seen=() + + # ----------------------------- + # helper: add a service once + # ----------------------------- + _add_target() { + local s="$1" + [[ -n "$s" ]] || return 0 + [[ -n "${seen[$s]:-}" ]] && return 0 + seen[$s]=1 + targets+=("$s") + } + + # ----------------------------- + # helper: trim + # ----------------------------- + _trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" + } + + # ----------------------------- + # helper: interactive selection (comma separated, supports ranges) + # accepts: "all" or "1,3,5-7" or mix with names "nginx,2,5-6" + # ----------------------------- + _pick_targets_interactive() { + compose_services_load + all_svcs=("${__COMPOSE_SVCS[@]}") + if ((${#all_svcs[@]})); then + mapfile -t all_svcs < <(printf '%s\n' "${all_svcs[@]}" | LC_ALL=C sort -f -u) + fi + [[ ${#all_svcs[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" + + echo + echo "Select services to rebuild (comma separated; ranges allowed)." + echo "Examples: 1,3,5-7 | nginx,2,5-6 | all" + echo + + local i + for i in "${!all_svcs[@]}"; do + printf " %2d) %s\n" "$((i + 1))" "${all_svcs[$i]}" + done + + echo + local sel + read -r -p "Pick: " sel + sel="$(_trim "${sel:-}")" + [[ -n "$sel" ]] || die "No selection provided." + + if [[ "${sel,,}" == "all" ]]; then + for svc in "${all_svcs[@]}"; do _add_target "$svc"; done + return 0 + fi + + # split by comma + local IFS=, + for arg in $sel; do + arg="$(_trim "$arg")" + [[ -n "$arg" ]] || continue + + # range like 3-7 + if [[ "$arg" =~ ^[0-9]+-[0-9]+$ ]]; then + local a b + a="${arg%-*}" + b="${arg#*-}" + ((a >= 1)) || continue + ((b >= 1)) || continue + ((a <= b)) || { + local t="$a" + a="$b" + b="$t" + } + + local n + for ((n = a; n <= b; n++)); do + ((n >= 1 && n <= ${#all_svcs[@]})) || continue + _add_target "${all_svcs[$((n - 1))]}" + done + continue + fi + + # single index + if [[ "$arg" =~ ^[0-9]+$ ]]; then + local n="$arg" + ((n >= 1 && n <= ${#all_svcs[@]})) || continue + _add_target "${all_svcs[$((n - 1))]}" + continue + fi + + # treat as service/container name + svc="$(resolve_service "$arg")" + [[ -n "$svc" ]] && _add_target "$svc" + done + + [[ ${#targets[@]} -gt 0 ]] || die "No valid services selected." + } + + # ----------------------------- + # build target list + # ----------------------------- + if (($# == 0)); then + _pick_targets_interactive + elif [[ "${1,,}" == "all" ]]; then + compose_services_load + targets=("${__COMPOSE_SVCS[@]}") + [[ ${#targets[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" + else + for arg in "$@"; do + svc="$(resolve_service "$arg")" + [[ -n "$svc" ]] || continue + _add_target "$svc" + done + [[ ${#targets[@]} -gt 0 ]] || die "No valid services provided." + fi + + # ----------------------------- + # rebuild each target + # ----------------------------- + for svc in "${targets[@]}"; do + [[ -n "$svc" ]] || continue + compose_service_exists "$svc" || die "Unknown service/container: '$svc'" + + if compose_has_build "$svc"; then + logq rebuild "build/recreate $svc" + dc_build --pull "$svc" + dc_up -d --no-deps --force-recreate "$svc" + continue + fi + + img="$(compose_image_for_service "$svc")" + logq rebuild "pull/recreate $svc${img:+ ($img)}" + + docker_compose rm -sf "$svc" >/dev/null 2>&1 || true + + if [[ -n "${img:-}" ]]; then + docker rmi -f "$img" >/dev/null 2>&1 || true + dc_pull "$svc" || true + else + dc_build --pull "$svc" >/dev/null 2>&1 || true + fi + + dc_up -d --no-deps --force-recreate "$svc" + done + logq reboot "Rebooting stacks" + cmd_reboot +} + + +docker_shell() { + local c="${1:-}" + [[ -n "$c" ]] || die "container name required" + if docker exec "$c" sh -lc 'command -v bash >/dev/null 2>&1' >/dev/null 2>&1; then + exec docker exec -it "$c" bash + else + exec docker exec -it "$c" sh + fi +} +cmd_tools() { + local sub="${1:-sh}" + shift || true + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + case "${sub,,}" in + sh | shell | "") + docker_shell "$ctr" + ;; + exec) + [[ $# -gt 0 ]] || die "tools exec " + docker exec -it "$ctr" sh -lc "$*" + ;; + file) + local p="${1:-}" + [[ -n "$p" ]] || die "tools file " + docker exec -it "$ctr" sh -lc "ls -la -- \"$p\" 2>/dev/null || true; echo; sed -n '1,200p' -- \"$p\" 2>/dev/null || true" + ;; + *) + die "tools " + ;; + esac +} +cmd_http() { [[ ${1:-} == reload ]] && http_reload; } +cmd_cli() { + local ctr="${1:-}" + shift || true + + [[ -n "$ctr" ]] || die "Usage: lds cli [cmd...]" + + docker inspect "$ctr" >/dev/null 2>&1 || die "Container not found: $ctr" + docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" + + # If user provided a command, run it; otherwise open an interactive shell. + if [[ "$#" -gt 0 ]]; then + local cmd="$*" + docker exec -it "$ctr" sh -lc ' + if command -v bash >/dev/null 2>&1; then + exec bash --login -lc "$1" + fi + exec sh -lc "$1" + ' sh "$cmd" + return + fi + + docker exec -it "$ctr" sh -lc ' + if command -v bash >/dev/null 2>&1; then + exec bash --login + fi + exec sh + ' +} + +cmd_core() { + # Usage: + # lds core -> open correct container for that domain (PHP/Node) + # lds core -> open a shell in that container + # lds core -> list domains and let user pick + + local target="${1:-}" + + # domain regex (same as domain-which/mkhost family) + local re='^([a-zA-Z0-9]([-a-zA-Z0-9]{0,61}[a-zA-Z0-9])?\.)+(localhost|local|test|loc|[a-zA-Z]{2,})$' + + # If no target -> prompt from domain-which list + if [[ -z "$target" ]]; then + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + + local -a domains=() + mapfile -t domains < <(docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | sed '/^[[:space:]]*$/d' || true) + + ((${#domains[@]} > 0)) || die "No domains found" + + # stable ordering + IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) + + if ((${#domains[@]} == 1)); then + target="${domains[0]}" + else + if [[ ! -t 0 ]]; then + printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 + local i=1 + local d + for d in "${domains[@]}"; do + printf " %2d) %s\n" "$i" "$d" >&2 + ((i++)) + done + die "No TTY to prompt. Use: lds core " + fi + + printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 + local i=1 d + for d in "${domains[@]}"; do + printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$d" >&2 + ((i++)) + done + + local ans="" + while true; do + read -r -p "Enter number (1-${#domains[@]}): " ans + ans="$(echo "$ans" | xargs)" + [[ "$ans" =~ ^[0-9]+$ ]] || { + printf "%bInvalid input.%b\n" "$YELLOW" "$NC" >&2 + continue + } + ((ans >= 1 && ans <= ${#domains[@]})) || { + printf "%bOut of range.%b\n" "$YELLOW" "$NC" >&2 + continue + } + target="${domains[$((ans - 1))]}" + break + done + fi + fi + + # If target looks like a domain -> resolve via domain-which then shell in + if [[ "$target" =~ $re ]]; then + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + + local app container wd + app="$(docker exec "$tools_ctr" domain-which --app --quiet "$target" 2>/dev/null)" || die "Unknown domain: $target" + container="$(docker exec "$tools_ctr" domain-which --container --quiet "$target" 2>/dev/null)" || die "No container resolved for: $target" + wd="$(docker exec "$tools_ctr" domain-which --docroot --quiet "$target" 2>/dev/null)" || true + [[ -n "${container:-}" ]] || die "No container resolved for: $target" + + # Node apps should always land at /app. Others follow resolved docroot. + if [[ "${app:-}" == "node" ]]; then + wd="/app" + fi + [[ -n "${wd:-}" ]] || wd="/app" + + docker exec -it "$container" bash -lc "cd \"$wd\" 2>/dev/null || cd /app 2>/dev/null || cd /; exec bash" + return 0 + fi + + # Otherwise treat target as a container name + docker exec -it "$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" sh -lc 'exec bash -i || exec sh' +} + +cmd_setup() { + add_required_env + case ${1:-} in + init) env_init ;; + permission | permissions | perms | perm) fix_perms ;; + domain) setup_domain ;; + profiles | profile) process_all ;; + *) die "setup " ;; + esac +} + +cmd_certificate() { + case ${1:-} in + install) + shift || true + install_ca + ;; + uninstall | remove | rm) + shift || true + uninstall_ca "${@:-}" + ;; + *) + die "certificate " + ;; + esac +} + +############################################################################### +# NOTIFY +############################################################################### +notify_watch() { + local container="${1:-}" + if [[ -z "$container" ]]; then + container="$(_project_tools_container_running || true)" + [[ -n "$container" ]] || die "server-tools container is not running for project: $(lds_project)" + fi + local prefix="__HOST_NOTIFY__" + + need docker + + local _disp="${DISPLAY-}" + local _dbus="${DBUS_SESSION_BUS_ADDRESS-}" + + # Args: timeout(ms) urgency title body + _host_notify() { + local timeout="${1:-2500}" urgency="${2:-normal}" title="${3:-Notification}" body="${4:-}" + + # Linux desktop (or WSLg) + if has_cmd notify-send; then + (env DISPLAY="${_disp-}" DBUS_SESSION_BUS_ADDRESS="${_dbus-}" \ + setsid -f notify-send -u "$urgency" -t "$timeout" "$title" "$body" \ + >/dev/null 2>&1 || true) & + return 0 + fi + + # Windows toast (Git Bash) / WSL-on-Windows + if has_cmd powershell.exe; then + # Pass values as args to avoid quoting issues entirely. + # Note: urgency/timeout not used by toast api here; kept for parity. + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \ + 'param([string]$t,[string]$b) + try { + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null + + function Esc([string]$s) { + if ($null -eq $s) { return "" } + return ($s -replace "&","&" -replace "<","<" -replace ">",">" -replace "\"",""" -replace "'\''","'") + } + + $title = Esc $t + $body = Esc $b + + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml("$title$body") + $toast = New-Object Windows.UI.Notifications.ToastNotification $xml + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Devtainer").Show($toast) + } catch { }' \ + --% "$title" "$body" >/dev/null 2>&1 || true + + return 0 + fi + + # Fallback + printf "%s [%s] %s - %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$urgency" "$title" "$body" >&2 + return 0 + } + + trap - ERR + set +e + set +o pipefail + + local _stop=0 + + _watcher_notify() { + local urgency="${1:-critical}" title="${2:-Notifier}" body="${3:-Watcher event}" + _host_notify 2500 "$urgency" "$title" "$body" + } + + _watcher_int_term() { + _stop=1 + _watcher_notify critical "Notifier" "Notification watcher interrupted/exiting" + printf "%b[watcher]%b Notification watcher interrupted/exiting\n" "$RED" "$NC" >&2 + } + trap _watcher_int_term INT TERM + + local grep_cmd=(grep -a --line-buffered -E "^${prefix}([[:space:]]|$)") + has_cmd stdbuf && grep_cmd=(stdbuf -oL -eL "${grep_cmd[@]}") + + printf "%bNotify Watch:%b monitoring is active. Ctrl+C to stop.\n" "$GREEN" "$NC" + + while ((_stop == 0)); do + if ! docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + _watcher_notify critical "Notifier" "Watcher stopped: $container is not running" + printf "%b[watcher]%b %s is not running; exiting.\n" "$RED" "$NC" "$container" >&2 + break + fi + + docker logs -f --tail 0 "$container" 2>&1 | + ("${grep_cmd[@]}" || true) | + while IFS=$'\t' read -r _ f1 f2 f3 f4 rest; do + local timeout urgency title body + + if [[ "${f1:-}" =~ ^[0-9]{1,6}$ ]]; then + timeout="$f1" + urgency="${f2:-normal}" + title="${f3:-Notification}" + body="${f4:-}" + else + timeout="2500" + urgency="${f1:-normal}" + title="${f2:-Notification}" + body="${f3:-}" + fi + + [[ -n "${rest:-}" ]] && body+=$'\t'"${rest}" + case "$urgency" in low | normal | critical) ;; *) urgency="normal" ;; esac + + _host_notify "$timeout" "$urgency" "$title" "$body" + printf "%s [%s] %s - %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$urgency" "$title" "$body" >&2 + done + + ((_stop)) && break + + if docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + _watcher_notify critical "Notifier" "Watcher lost log stream (docker logs ended). Reconnecting…" + printf "%b[watcher]%b docker logs ended; reconnecting...\n" "$YELLOW" "$NC" >&2 + sleep 1 + continue + fi + + _watcher_notify critical "Notifier" "Watcher stopped: $container stopped" + printf "%b[watcher]%b %s stopped; exiting.\n" "$RED" "$NC" "$container" >&2 + break + done + + trap - INT TERM + set -euo pipefail + + ((_stop)) && return 130 + return 0 +} + +notify_test() { + local title="${1:-Notifier OK}" + local body="${2:-Hello from host via project server-tools container}" + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" notify -t 2500 -u normal "$title" "$body" +} + +cmd_notify() { + case ${1:-watch} in + watch) notify_watch "${2:-}" ;; + test) notify_test "${2:-Notifier OK}" "${3:-Hello from host}" ;; + *) die "notify " ;; + esac +} + +open_url() { + local url="${1:-}" + [[ -n "$url" ]] || return 0 + + # WSL/Windows helpers first when available + if grep -qi microsoft /proc/version 2>/dev/null; then + if has_cmd powershell.exe; then + powershell.exe -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 || true + return 0 + fi + if has_cmd cmd.exe; then + cmd.exe /c start "" "$url" >/dev/null 2>&1 || true + return 0 + fi + fi + + if has_cmd xdg-open; then + (xdg-open "$url" >/dev/null 2>&1 &) + return 0 + fi + if has_cmd open; then + (open "$url" >/dev/null 2>&1 &) + return 0 + fi + if has_cmd powershell; then + (powershell -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 &) + return 0 + fi + + printf "%bINFO%b: open this URL manually → %s\n" "$YELLOW" "$NC" "$url" +} + diff --git a/scripts/graphify-diagnostic-proxy.py b/scripts/graphify-diagnostic-proxy.py new file mode 100644 index 00000000..7c9947a5 --- /dev/null +++ b/scripts/graphify-diagnostic-proxy.py @@ -0,0 +1,924 @@ +#!/usr/bin/env python3 +"""Local Graphify diagnostic reverse proxy. + +Forwards OpenAI-compatible Graphify traffic to the active LocalDevStack LLM +without modifying request bodies. Only suspect chat-completion responses are +reported: empty content, malformed/non-graph JSON, or graph arrays containing +no usable object entries. Request prompts/source content are never logged. +""" +from __future__ import annotations + +import argparse +import json +import re +import socket +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +_GRAPH_KEYS = ("nodes", "edges", "hyperedges") +_STRUCTURED_MAX_TOKENS = 2048 + +_GRAPH_SCHEMA = { + "type": "object", + "required": ["nodes", "edges", "hyperedges"], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "label", "file_type", "source_file"], + "properties": { + "id": {"type": "string"}, + "label": {"type": "string"}, + "file_type": { + "type": "string", + "enum": ["code", "document", "paper", "image", "rationale", "concept"], + }, + "source_file": {"type": "string"}, + "source_location": {"type": ["string", "null"]}, + "source_url": {"type": ["string", "null"]}, + "captured_at": {"type": ["string", "null"]}, + "author": {"type": ["string", "null"]}, + "contributor": {"type": ["string", "null"]}, + "rationale": {"type": ["string", "null"]}, + }, + }, + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source", "target", "relation", "confidence", + "confidence_score", "source_file", "weight", + ], + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "relation": { + "type": "string", + "enum": [ + "calls", "implements", "references", "cites", + "conceptually_related_to", "shares_data_with", + "semantically_similar_to", + ], + }, + "confidence": { + "type": "string", + "enum": ["EXTRACTED", "INFERRED", "AMBIGUOUS"], + }, + "confidence_score": {"type": "number"}, + "source_file": {"type": "string"}, + "source_location": {"type": ["string", "null"]}, + "weight": {"type": "number"}, + }, + }, + }, + "hyperedges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", "label", "nodes", "relation", "confidence", + "confidence_score", "source_file", + ], + "properties": { + "id": {"type": "string"}, + "label": {"type": "string"}, + "nodes": {"type": "array", "items": {"type": "string"}}, + "relation": { + "type": "string", + "enum": ["participate_in", "implement", "form"], + }, + "confidence": { + "type": "string", + "enum": ["EXTRACTED", "INFERRED"], + }, + "confidence_score": {"type": "number"}, + "source_file": {"type": "string"}, + }, + }, + }, + }, +} + +_FENCE_RE = re.compile(r"~~~[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n(.*?)~~~", re.S) + + +def _balanced_object(text: str, start: int) -> str | None: + depth = 0 + in_string = False + escape = False + for index in range(start, len(text)): + char = text[index] + if escape: + escape = False + continue + if char == "\\": + escape = True + continue + if char == '"': + in_string = not in_string + continue + if in_string: + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start:index + 1] + return None + + +def _json_candidates(content: str): + stripped = content.strip() + if stripped: + yield stripped + # Avoid embedding Markdown fence delimiters in this helper's own source + # contract; normalize them before matching. + fenced = content.replace(chr(96) * 3, "~~~") + for _lang, body in _FENCE_RE.findall(fenced): + body = body.strip() + if body: + yield body + start = content.find("{") + seen = 0 + while start != -1 and seen < 128: + candidate = _balanced_object(content, start) + if candidate is not None: + yield candidate + seen += 1 + start = content.find("{", start + 1) + + +def parse_graph_content(content: str | None) -> dict[str, list[dict[str, Any]]] | None: + """Return a structurally valid graph fragment, including an all-empty fragment.""" + if content is None or not content.strip(): + return None + for candidate in _json_candidates(content): + try: + parsed = json.loads(candidate) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(parsed, dict): + continue + + graph: dict[str, list[dict[str, Any]]] = {} + for key in _GRAPH_KEYS: + value = parsed.get(key) + if not isinstance(value, list) or any(not isinstance(entry, dict) for entry in value): + break + graph[key] = value + else: + return graph + return None + + +def classify_graph_content(content: str | None) -> tuple[bool, str]: + """Return (suspect, reason), mirroring Graphify's hollow decision closely.""" + if content is None or not content.strip(): + return True, "empty assistant content" + + saw_json = False + saw_graph_shape = False + saw_wrong_shape = False + + for candidate in _json_candidates(content): + try: + parsed = json.loads(candidate) + except (json.JSONDecodeError, TypeError): + continue + if not isinstance(parsed, dict): + continue + + saw_json = True + if not any(key in parsed for key in _GRAPH_KEYS): + continue + + saw_graph_shape = True + usable = False + wrong_shape = False + for key in _GRAPH_KEYS: + value = parsed.get(key) + if value is None: + continue + if not isinstance(value, list): + wrong_shape = True + continue + if any(isinstance(entry, dict) for entry in value): + usable = True + if value and not any(isinstance(entry, dict) for entry in value): + wrong_shape = True + + if usable: + return False, "usable graph fragment" + saw_wrong_shape = saw_wrong_shape or wrong_shape + + if saw_wrong_shape: + return True, "graph arrays contain no usable object entries" + if saw_graph_shape: + return True, "valid but empty graph fragment" + if any(f'"{key}"' in content for key in _GRAPH_KEYS): + return True, "malformed graph JSON" + if saw_json: + return True, "JSON response has no graph fragment keys" + return True, "response is not parseable as a graph JSON object" + + + +_GRAPH_TOOL = { + "type": "function", + "function": { + "name": "submit_graph", + "description": "Submit the extracted Graphify knowledge-graph fragment. Call exactly once.", + "parameters": _GRAPH_SCHEMA, + }, +} + + +def _build_ollama_schema_request(body: bytes) -> bytes | None: + try: + request = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(request, dict): + return None + + request = dict(request) + request["stream"] = False + request["temperature"] = 0 + request.pop("max_tokens", None) + request["max_completion_tokens"] = _bounded_completion_cap(request) + request["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "graphify_fragment", + "strict": True, + "schema": _GRAPH_SCHEMA, + }, + } + return json.dumps(request, ensure_ascii=False).encode("utf-8") + + +def _bounded_completion_cap(request: dict[str, Any]) -> int: + raw = request.get("max_completion_tokens", request.get("max_tokens", _STRUCTURED_MAX_TOKENS)) + try: + cap = int(raw) + except (TypeError, ValueError): + cap = _STRUCTURED_MAX_TOKENS + return max(1, min(cap, _STRUCTURED_MAX_TOKENS)) + + +def _build_tool_recovery_request(body: bytes) -> bytes | None: + try: + request = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(request, dict): + return None + + messages = request.get("messages") + if not isinstance(messages, list): + return None + + request = dict(request) + request["tools"] = [_GRAPH_TOOL] + # FastFlow currently supports tool_choice=auto|none only. The explicit + # system instruction is therefore what makes submit_graph mandatory. + request["tool_choice"] = "auto" + # FastFlow's Qwen3.5 streaming parser emits a complete tool-call delta as + # soon as / is parsed. Using streaming here avoids + # waiting for model EOS on the non-stream path. + request["stream"] = True + request["temperature"] = 0 + request.pop("max_tokens", None) + request["max_completion_tokens"] = _bounded_completion_cap(request) + + amended = [] + recovery_suffix = ( + "\n\nSTRUCTURED OUTPUT: Do not emit the graph as ordinary assistant text. " + "Call the submit_graph tool exactly once. Put the complete extraction " + "fragment into its nodes, edges, and hyperedges arguments. Do not add " + "new facts; follow the original Graphify schema and source_file rules." + ) + injected = False + for message in messages: + if not isinstance(message, dict): + amended.append(message) + continue + copied = dict(message) + if ( + not injected + and copied.get("role") == "system" + and isinstance(copied.get("content"), str) + and "graphify semantic extraction agent" in copied["content"] + ): + copied["content"] += recovery_suffix + injected = True + amended.append(copied) + if not injected: + amended.insert(0, {"role": "system", "content": recovery_suffix.strip()}) + request["messages"] = amended + return json.dumps(request, ensure_ascii=False).encode("utf-8") + + +def _decode_jsonish(value: Any) -> Any: + """Decode nested JSON strings produced by OpenAI-compatible tool adapters.""" + current = value + for _ in range(3): + if not isinstance(current, str): + break + stripped = current.strip() + if not stripped: + break + try: + current = json.loads(stripped) + except json.JSONDecodeError: + break + return current + + +def _coerce_graph_array(value: Any) -> list[dict[str, Any]] | None: + value = _decode_jsonish(value) + if not isinstance(value, list): + return None + if any(not isinstance(entry, dict) for entry in value): + return None + return value + + +def _coerce_graph_object(value: Any) -> dict[str, Any] | None: + value = _decode_jsonish(value) + if not isinstance(value, dict): + return None + + # Some OpenAI-compatible servers wrap the function payload one level deeper. + for wrapper in ("arguments", "graph", "payload", "data"): + if wrapper in value and not all(key in value for key in _GRAPH_KEYS): + nested = _decode_jsonish(value.get(wrapper)) + if isinstance(nested, dict): + value = nested + break + + graph: dict[str, Any] = {} + for key in _GRAPH_KEYS: + array = _coerce_graph_array(value.get(key)) + if array is None: + return None + graph[key] = array + return graph + + +def _tool_call_candidates(message: dict[str, Any]) -> list[dict[str, Any]]: + calls = message.get("tool_calls") + if isinstance(calls, list): + return [call for call in calls if isinstance(call, dict)] + + single = message.get("tool_call") + if isinstance(single, dict): + return [single] + return [] + + +def _extract_graph_tool_result(body: bytes) -> dict[str, Any] | None: + try: + response = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(response, dict): + return None + + choices = response.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + return None + message = choices[0].get("message") + if not isinstance(message, dict): + return None + + for call in _tool_call_candidates(message): + function = call.get("function") + if isinstance(function, dict): + name = str(function.get("name", "")).strip() + arguments = function.get("arguments") + else: + # Tolerate flatter adapters that place name/arguments on the call. + name = str(call.get("name", "")).strip() + arguments = call.get("arguments") + + graph = _coerce_graph_object(arguments) + if graph is None: + continue + + # submit_graph is the only tool we provide. Accept a structurally valid + # graph even if FastFlow/Qwen adds harmless whitespace/name drift. + if not name or name == "submit_graph" or len(_tool_call_candidates(message)) == 1: + return graph + + return None + + +def _extract_fastflow_structured_graph(body: bytes) -> dict[str, Any] | None: + graph = _extract_graph_tool_result(body) + if graph is not None: + return graph + _meta, content = _response_metadata(body) + return parse_graph_content(content) + + +def _fastflow_stream_completion(upstream_response, model: str | None) -> bytes: + """Collapse FastFlow SSE into one OpenAI completion, returning on a tool call.""" + content_parts: list[str] = [] + last_id = "chatcmpl-lds-fastflow" + finish_reason = "stop" + usage: dict[str, Any] = {} + + while True: + raw_line = upstream_response.readline() + if not raw_line: + break + try: + line = raw_line.decode("utf-8", errors="replace").strip() + except AttributeError: + line = str(raw_line).strip() + if not line.startswith("data:"): + continue + + payload = line[5:].strip() + if not payload: + continue + if payload == "[DONE]": + break + + try: + event = json.loads(payload) + except json.JSONDecodeError: + continue + if not isinstance(event, dict): + continue + + if isinstance(event.get("id"), str) and event["id"]: + last_id = event["id"] + if isinstance(event.get("usage"), dict): + usage = event["usage"] + + choices = event.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + continue + choice = choices[0] + if isinstance(choice.get("finish_reason"), str): + finish_reason = choice["finish_reason"] + + delta = choice.get("delta") + if not isinstance(delta, dict): + continue + + content = delta.get("content") + if isinstance(content, str) and content: + content_parts.append(content) + + calls = delta.get("tool_calls") + if isinstance(calls, list) and calls: + normalized_calls = [call for call in calls if isinstance(call, dict)] + if normalized_calls: + # FastFlow emits the complete function arguments in TOOL_DONE, + # not incremental argument fragments. Stop reading immediately + # so a model that fails to emit EOS cannot hold Graphify open. + response = { + "id": last_id, + "object": "chat.completion", + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "".join(content_parts) or None, + "tool_calls": normalized_calls, + }, + "finish_reason": "tool_calls", + }], + "usage": usage, + } + return json.dumps(response, ensure_ascii=False).encode("utf-8") + + response = { + "id": last_id, + "object": "chat.completion", + "model": model, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "".join(content_parts), + }, + "finish_reason": finish_reason, + }], + "usage": usage, + } + return json.dumps(response, ensure_ascii=False).encode("utf-8") + + +def _graphify_split_response(body: bytes, model: str | None) -> bytes: + """Return a standard completion that makes Graphify bisect the current chunk.""" + try: + response = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + response = {} + if not isinstance(response, dict): + response = {} + + out = dict(response) + out.setdefault("id", "chatcmpl-lds-graphify-retry") + out.setdefault("object", "chat.completion") + if model: + out["model"] = model + out["choices"] = [{ + "index": 0, + "finish_reason": "length", + "message": { + "role": "assistant", + "content": '{"nodes":[],"edges":[],"hyperedges":[]}', + }, + }] + return json.dumps(out, ensure_ascii=False).encode("utf-8") + + +def _is_timeout_error(exc: BaseException) -> bool: + if isinstance(exc, (TimeoutError, socket.timeout)): + return True + reason = getattr(exc, "reason", None) + if isinstance(reason, (TimeoutError, socket.timeout)): + return True + return "timed out" in str(exc).lower() or "timeout" in str(exc).lower() + + +def _replace_response_content(body: bytes, graph: dict[str, Any]) -> bytes | None: + try: + response = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(response, dict): + return None + choices = response.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + return None + + response = dict(response) + choices = [dict(choice) if isinstance(choice, dict) else choice for choice in choices] + first = choices[0] + message = first.get("message") + if not isinstance(message, dict): + message = {} + else: + message = dict(message) + message["role"] = "assistant" + message["content"] = json.dumps(graph, ensure_ascii=False, separators=(",", ":")) + message.pop("tool_calls", None) + message.pop("reasoning_content", None) + message.pop("reasoning", None) + message.pop("thinking", None) + first["message"] = message + # Preserve an upstream/synthetic length signal so Graphify's adaptive + # retry layer bisects the offending chunk. Successful tool-call responses + # are normalized to a regular completed assistant response. + if first.get("finish_reason") != "length": + first["finish_reason"] = "stop" + choices[0] = first + response["choices"] = choices + return json.dumps(response, ensure_ascii=False).encode("utf-8") + + +def _request_metadata(body: bytes) -> dict[str, Any]: + try: + request = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {} + if not isinstance(request, dict): + return {} + + messages = request.get("messages") + extraction_request = False + if isinstance(messages, list): + for message in messages: + if not isinstance(message, dict) or message.get("role") != "system": + continue + content = message.get("content") + if isinstance(content, str) and "graphify semantic extraction agent" in content: + extraction_request = True + break + + return { + "_extraction_request": extraction_request, + "model": request.get("model"), + "think": request.get("think", ""), + "reasoning_effort": request.get("reasoning_effort", ""), + "max_completion_tokens": request.get("max_completion_tokens", request.get("max_tokens")), + "stream": request.get("stream"), + } + + +def _tool_call_summary(body: bytes) -> str: + try: + response = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return "unparseable-response" + if not isinstance(response, dict): + return "non-object-response" + + choices = response.get("choices") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + return "no-choice" + message = choices[0].get("message") + if not isinstance(message, dict): + return "no-message" + + summaries = [] + for call in _tool_call_candidates(message): + function = call.get("function") + if isinstance(function, dict): + name = str(function.get("name", "")).strip() or "" + args = _decode_jsonish(function.get("arguments")) + else: + name = str(call.get("name", "")).strip() or "" + args = _decode_jsonish(call.get("arguments")) + + if isinstance(args, dict): + summaries.append(f"{name}:keys={sorted(args.keys())}") + else: + summaries.append(f"{name}:args_type={type(args).__name__}") + + return "; ".join(summaries) if summaries else "no-tool-calls" + + +def _response_metadata(body: bytes) -> tuple[dict[str, Any], str | None]: + try: + response = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {}, None + if not isinstance(response, dict): + return {}, None + + choices = response.get("choices") + choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {} + message = choice.get("message") if isinstance(choice.get("message"), dict) else {} + usage = response.get("usage") if isinstance(response.get("usage"), dict) else {} + + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, str): + content = None + + return { + "finish_reason": choice.get("finish_reason"), + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "total_tokens": usage.get("total_tokens"), + }, content + + +class DiagnosticHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + upstream: str = "" + provider: str = "" + diagnostics: bool = False + upstream_timeout: int = 1800 + structured_timeout: int = 120 + log_file: Path + preview_chars: int = 4096 + + def log_message(self, _format: str, *_args: object) -> None: + return + + def _forward(self) -> None: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length else b"" + + request_headers = {} + for name in ("Authorization", "Content-Type", "Accept", "User-Agent"): + value = self.headers.get(name) + if value: + request_headers[name] = value + + is_chat = self.command == "POST" and self.path.rstrip("/").endswith("/v1/chat/completions") + metadata = _request_metadata(body) if is_chat else {} + extraction_request = bool(metadata.get("_extraction_request")) + + upstream_body = body + structured_primary = "" + if extraction_request and self.provider == "fastflow": + candidate = _build_tool_recovery_request(body) + if candidate is not None: + upstream_body = candidate + structured_primary = "fastflow-tool" + elif extraction_request and self.provider == "ollama": + candidate = _build_ollama_schema_request(body) + if candidate is not None: + upstream_body = candidate + structured_primary = "ollama-schema" + + upstream_url = self.upstream.rstrip("/") + self.path + if structured_primary == "fastflow-tool": + request_headers["Accept"] = "text/event-stream" + + request = urllib.request.Request( + upstream_url, + data=upstream_body if self.command not in ("GET", "HEAD") else None, + headers=request_headers, + method=self.command, + ) + + status = 502 + response_headers: dict[str, str] = {"Content-Type": "application/json"} + response_body = b'{"error":{"message":"LocalDevStack Graphify diagnostic proxy upstream failure"}}' + + try: + request_timeout = self.structured_timeout if structured_primary else self.upstream_timeout + with urllib.request.urlopen(request, timeout=request_timeout) as upstream_response: + status = upstream_response.status + if structured_primary == "fastflow-tool": + response_body = _fastflow_stream_completion( + upstream_response, metadata.get("model") + ) + response_headers["Content-Type"] = "application/json" + else: + response_body = upstream_response.read() + content_type = upstream_response.headers.get("Content-Type") + if content_type: + response_headers["Content-Type"] = content_type + except urllib.error.HTTPError as exc: + status = exc.code + response_body = exc.read() + content_type = exc.headers.get("Content-Type") if exc.headers else None + if content_type: + response_headers["Content-Type"] = content_type + except Exception as exc: + if structured_primary and _is_timeout_error(exc): + status = 200 + response_body = _graphify_split_response(b"", metadata.get("model")) + print( + "[lds graphify] structured provider request timed out after " + f"{self.structured_timeout}s; asking Graphify to split the chunk", + file=sys.stderr, + flush=True, + ) + else: + status = 502 + response_body = json.dumps( + {"error": {"message": f"LocalDevStack Graphify proxy upstream error: {exc}"}} + ).encode("utf-8") + + if is_chat and extraction_request and 200 <= status < 300: + if structured_primary == "fastflow-tool": + graph = _extract_fastflow_structured_graph(response_body) + if graph is not None: + replacement = _replace_response_content(response_body, graph) + if replacement is not None: + response_body = replacement + if self.diagnostics: + print( + "[lds graphify diagnostic] structured FastFlow graph via submit_graph tool: " + f"model={metadata.get('model')}; think={metadata.get('think')}; " + f"nodes={len(graph['nodes'])}; edges={len(graph['edges'])}; " + f"hyperedges={len(graph['hyperedges'])}", + file=sys.stderr, + flush=True, + ) + else: + self._diagnose_suspect_response(body, response_body, status) + response_body = _graphify_split_response(response_body, metadata.get("model")) + print( + "[lds graphify] FastFlow structured response unusable; " + "asking Graphify to split the chunk", + file=sys.stderr, + flush=True, + ) + elif structured_primary == "ollama-schema": + _meta, content = _response_metadata(response_body) + graph = parse_graph_content(content) + if graph is None: + self._diagnose_suspect_response(body, response_body, status) + response_body = _graphify_split_response(response_body, metadata.get("model")) + print( + "[lds graphify] Ollama structured response unusable; " + "asking Graphify to split the chunk", + file=sys.stderr, + flush=True, + ) + elif self.diagnostics: + print( + "[lds graphify diagnostic] structured Ollama graph via response_format schema: " + f"model={metadata.get('model')}; reasoning_effort={metadata.get('reasoning_effort')}; " + f"nodes={len(graph['nodes'])}; edges={len(graph['edges'])}; " + f"hyperedges={len(graph['hyperedges'])}", + file=sys.stderr, + flush=True, + ) + + self.send_response(status) + for name, value in response_headers.items(): + self.send_header(name, value) + self.send_header("Content-Length", str(len(response_body))) + self.send_header("Connection", "close") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(response_body) + + def _diagnose_suspect_response( + self, request_body: bytes, response_body: bytes, status: int + ) -> None: + req = _request_metadata(request_body) + req.pop("_extraction_request", None) + resp, content = _response_metadata(response_body) + suspect, reason = classify_graph_content(content) + if not suspect: + reason = "structured response did not contain a usable provider-native graph" + + tool_summary = _tool_call_summary(response_body) + print( + "[lds graphify] provider returned a suspect extraction response: " + f"reason={reason}; model={req.get('model')}; finish_reason={resp.get('finish_reason')}; " + f"tool_calls={tool_summary}", + file=sys.stderr, + flush=True, + ) + + if not self.diagnostics: + return + + record = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "status": status, + "reason": reason, + **req, + **resp, + "assistant_content": content, + } + self.log_file.parent.mkdir(parents=True, exist_ok=True) + with self.log_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + + preview = (content or "")[: self.preview_chars] + print( + "[lds graphify diagnostic] suspect LLM response: " + f"reason={reason}; model={req.get('model')}; " + f"think={req.get('think')}; reasoning_effort={req.get('reasoning_effort')}; " + f"finish_reason={resp.get('finish_reason')}; " + f"prompt_tokens={resp.get('prompt_tokens')}; " + f"completion_tokens={resp.get('completion_tokens')}", + file=sys.stderr, + flush=True, + ) + print( + f"[lds graphify diagnostic] assistant content preview ({len(preview)}/{len(content or '')} chars): " + f"{preview!r}", + file=sys.stderr, + flush=True, + ) + print( + f"[lds graphify diagnostic] full suspect response logged to {self.log_file}", + file=sys.stderr, + flush=True, + ) + + do_GET = _forward + do_HEAD = _forward + do_POST = _forward + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--upstream", required=True) + parser.add_argument("--provider", required=True, choices=("fastflow", "ollama")) + parser.add_argument("--diagnostics", choices=("on", "off"), default="off") + parser.add_argument("--timeout", type=int, default=1800) + parser.add_argument("--structured-timeout", type=int, default=120) + parser.add_argument("--ready-file", required=True) + parser.add_argument("--log-file", required=True) + parser.add_argument("--preview-chars", type=int, default=4096) + args = parser.parse_args() + + DiagnosticHandler.upstream = args.upstream + DiagnosticHandler.provider = args.provider + DiagnosticHandler.diagnostics = args.diagnostics == "on" + DiagnosticHandler.upstream_timeout = max(1, args.timeout) + DiagnosticHandler.structured_timeout = max(1, args.structured_timeout) + DiagnosticHandler.log_file = Path(args.log_file) + DiagnosticHandler.preview_chars = max(256, args.preview_chars) + + server = ThreadingHTTPServer(("127.0.0.1", 0), DiagnosticHandler) + ready = Path(args.ready_file) + ready.parent.mkdir(parents=True, exist_ok=True) + ready.write_text(str(server.server_address[1]), encoding="utf-8") + try: + server.serve_forever(poll_interval=0.2) + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/ai-contract.sh b/tests/ai-contract.sh new file mode 100755 index 00000000..14922801 --- /dev/null +++ b/tests/ai-contract.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +image="lds-fake-llm:ci" +container="lds-fake-llm-ci" +network="lds-ai-ci" + +cleanup() { + docker rm -f "$container" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +docker network create "$network" >/dev/null +docker build -q -t "$image" "$ROOT/tests/fixtures/fake-ollama" >/dev/null +docker run -d --name "$container" --network "$network" --network-alias llm --network-alias llm-ollama "$image" >/dev/null + +for _ in {1..20}; do + if docker exec "$container" python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:11434/v1/models", timeout=1).read()' >/dev/null 2>&1; then + break + fi + sleep 1 +done + +models="$( + docker exec "$container" python -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:11434/v1/models", timeout=2).read().decode())' +)" +assert_contains "$models" "qwen3:14b" + +tags="$( + docker exec "$container" python -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=2).read().decode())' +)" +assert_contains "$tags" "qwen3:14b" +pass "fake provider exposes common OpenAI API plus Ollama-native compatibility" + +docker pull infocyph/tools:latest >/dev/null +provider_status="$( + docker run --rm --network "$network" --entrypoint askai -e LDS_AI_ENABLED=1 -e LDS_AI_PROVIDER=llm -e LDS_AI_URL=http://llm:11434 -e LDS_AI_MODEL=qwen3:14b infocyph/tools:latest --status +)" +assert_contains "$provider_status" "provider=llm" +assert_contains "$provider_status" "available=1" +assert_contains "$provider_status" "model=qwen3:14b" +pass "latest Tools reaches the common LocalDevStack llm contract" + +[[ ! -e "$ROOT/docker/compose/ai.yaml" ]] || fail "base AI service must remain consolidated into companion.yaml" +if find "$ROOT/docker/compose" -maxdepth 1 -type f -name 'ai-*.yaml' -print -quit | grep -q .; then + fail "AI-specific Compose overlays must be generated ephemerally" +fi + +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'image: infocyph/llm-ollama:latest' +assert_file_contains "$ROOT/lib/compose.sh" 'image: infocyph/llm-ollama:amd-latest' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'image: infocyph/llm-fastflow:latest' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'profiles: ["${LDS_AI_OLLAMA_PROFILE:-ai}"]' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'profiles: ["${LDS_AI_FASTFLOW_PROFILE:-__lds-ai-disabled-fastflow}"]' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'aliases: [llm]' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'lds_llm:/root/.ollama' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'lds_llm_fastflow:/models' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LLM_OLLAMA_MODEL=${LDS_AI_MODEL:-qwen3:14b}' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LLM_FASTFLOW_MODEL=${LDS_AI_MODEL:-qwen3.5:9b}' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LLM_THINK=${LDS_AI_THINK:-}' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LDS_AI_THINK=${LDS_AI_THINK:-}' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'FLM_SERVE_PORT=11434' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LDS_AI_PROVIDER=${LDS_AI_PROVIDER:-llm}' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'LDS_AI_URL=${LDS_AI_URL:-http://llm:11434}' +assert_file_contains "$ROOT/docker/compose/http.yaml" 'LLM_PROXY_TIMEOUT_SECONDS=${LDS_AI_TIMEOUT:-1800}' +assert_file_contains "$ROOT/docker/compose/http.yaml" '"127.0.0.1:11434:11434"' +pass "companion defines mutually exclusive provider services behind common llm alias" + +assert_file_contains "$ROOT/lib/compose.sh" 'LDS_AI_PROVIDER=llm' +assert_file_contains "$ROOT/lib/compose.sh" 'LDS_AI_URL=http://llm:11434' +assert_file_contains "$ROOT/lib/compose.sh" 'ollama_profile=__lds-ai-disabled-ollama' +assert_file_contains "$ROOT/lib/compose.sh" 'fastflow_profile=ai' +assert_file_contains "$ROOT/lib/compose.sh" 'ollama_profile=ai' +assert_file_contains "$ROOT/lib/compose.sh" 'fastflow_profile=__lds-ai-disabled-fastflow' +assert_file_contains "$ROOT/lib/compose.sh" "' gpus: all'" +assert_file_contains "$ROOT/lib/compose.sh" "' - /dev/kfd:/dev/kfd'" +assert_file_contains "$ROOT/lib/compose.sh" "' - /dev/dri:/dev/dri'" +pass "Compose wrapper selects exactly one provider and augments only Ollama GPU modes" + +if grep -R -nF 'LDS_LLM_ARCH' "$ROOT/lib" "$ROOT/docker/compose" "$ROOT/docker/catalog"; then + fail "obsolete LDS_LLM_ARCH must not remain in active LocalDevStack code or catalog" +fi +pass "LLM image tags are direct and LDS_LLM_ARCH is removed" + +assert_file_contains "$ROOT/lib/ai.sh" 'http://llm.localhost:11434/v1' +assert_file_contains "$ROOT/lib/ai.sh" '/v1/models' +assert_file_contains "$ROOT/lib/ai.sh" 'index($model) != null' +assert_file_contains "$ROOT/lib/ai.sh" 'Run: lds llm pull $model' +assert_file_contains "$ROOT/lib/ai.sh" 'ai_service_for_runtime' +assert_file_contains "$ROOT/lib/ai.sh" 'llm-fastflow' +assert_file_contains "$ROOT/lib/ai.sh" 'llm-ollama' +assert_file_contains "$ROOT/lib/ai.sh" 'fastflow) printf '\''%s'\'' openai' +assert_file_contains "$ROOT/lib/ai.sh" 'ollama) printf '\''%s'\'' ollama' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_TOKEN_BUDGET:-4000' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_MAX_CONCURRENCY:-1' +assert_file_contains "$ROOT/lib/ai.sh" 'lds-fastflow' +assert_file_contains "$ROOT/lib/ai.sh" 'lds-ollama' +assert_file_contains "$ROOT/lib/ai.sh" 'extra_body: {think: false}' +assert_file_contains "$ROOT/lib/ai.sh" 'reasoning_effort: "none"' +assert_file_contains "$ROOT/lib/ai.sh" 'graphify-diagnostic-proxy.py' +assert_file_contains "$ROOT/lib/ai.sh" 'http://127.0.0.1:${proxy_port}/v1' +assert_file_contains "$ROOT/lib/ai.sh" '--provider "$provider"' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_DIAGNOSTICS:-0' +assert_file_contains "$ROOT/lib/ai.sh" '--diagnostics "$diagnostic_mode"' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_STRUCTURED_TIMEOUT:-120' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_SDK_RETRIES:-0' +assert_file_contains "$ROOT/lib/ai.sh" 'export GRAPHIFY_MAX_RETRIES="$graphify_sdk_retries"' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_MAX_RETRY_DEPTH:-1' +assert_file_contains "$ROOT/lib/ai.sh" 'export GRAPHIFY_MAX_RETRY_DEPTH="$graphify_retry_depth"' +assert_file_contains "$ROOT/lib/ai.sh" 'existing graph detected; using Graphify incremental update (changed files only)' +assert_file_contains "$ROOT/lib/ai.sh" 'existing graph detected; --force requested, performing a full rebuild' +assert_file_contains "$ROOT/lib/ai.sh" 'no existing graph detected; performing initial full build' +assert_file_contains "$ROOT/lib/ai.sh" '--structured-timeout "$structured_timeout"' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_THINK:-off' +assert_file_contains "$ROOT/lib/ai.sh" 'lds-graphify-diagnostics.jsonl' +assert_file_contains "$ROOT/lib/ai.sh" 'llm think ' +pass "LLM CLI and Graphify resolve through provider-aware common endpoint" + +( + set -euo pipefail + need_bin() { :; } + die() { return 1; } + curl() { + printf '%s\n' '{"object":"list","data":[{"id":"qwen3:14b"},{"id":"qwen3.5:9b"}]}' + } + # shellcheck source=lib/ai.sh + source "$ROOT/lib/ai.sh" + + _graphify_local_model_preflight qwen3:14b || + fail "Graphify rejected the Ollama default through common model catalog" + _graphify_local_model_preflight qwen3.5:9b || + fail "Graphify rejected the FastFlow default through common model catalog" + if _graphify_local_model_preflight missing-model; then + fail "Graphify accepted a model absent from the common model catalog" + fi +) +pass "Graphify validates either provider model through /v1/models" + +( + set -euo pipefail + # shellcheck source=lib/ai.sh + source "$ROOT/lib/ai.sh" + + [[ "$(_graphify_backend_for_provider fastflow)" == openai ]] || + fail "FastFlow Graphify backend did not resolve to openai" + [[ "$(_graphify_backend_for_provider ollama)" == ollama ]] || + fail "Ollama Graphify backend did not resolve to ollama" + if _graphify_backend_for_provider unknown >/dev/null 2>&1; then + fail "Unknown LLM provider unexpectedly resolved a Graphify backend" + fi +) +pass "Graphify backend selection follows active LLM provider" + +( + set -euo pipefail + # shellcheck source=lib/ai.sh + source "$ROOT/lib/ai.sh" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + [[ "$(_graphify_write_local_provider "$tmp" fastflow http://llm.localhost:11434/v1 qwen3.5:9b 4000 off)" == lds-fastflow ]] || + fail "FastFlow local Graphify provider name drifted" + jq -e '."lds-fastflow".extra_body.think == false and (."lds-fastflow" | has("reasoning_effort") | not)' "$tmp/.graphify/providers.json" >/dev/null || + fail "FastFlow local Graphify provider must default to no-thinking" + + [[ "$(_graphify_write_local_provider "$tmp" fastflow http://llm.localhost:11434/v1 qwen3.5:9b 4000 on)" == lds-fastflow ]] || + fail "FastFlow local Graphify thinking-on provider name drifted" + jq -e '."lds-fastflow".extra_body.think == true and ."lds-fastflow".reasoning_effort == "high"' "$tmp/.graphify/providers.json" >/dev/null || + fail "FastFlow Graphify thinking-on override did not map to think=true/high" + + [[ "$(_graphify_write_local_provider "$tmp" fastflow http://llm.localhost:11434/v1 qwen3.5:9b 4000 auto)" == lds-fastflow ]] || + fail "FastFlow local Graphify auto provider name drifted" + jq -e '(."lds-fastflow" | has("extra_body") | not) and (."lds-fastflow" | has("reasoning_effort") | not)' "$tmp/.graphify/providers.json" >/dev/null || + fail "FastFlow Graphify auto override must omit thinking controls" + + [[ "$(_graphify_write_local_provider "$tmp" ollama http://llm.localhost:11434/v1 qwen3:14b 4000)" == lds-ollama ]] || + fail "Ollama local Graphify provider name drifted" + jq -e '."lds-ollama".reasoning_effort == "none" and ."lds-ollama".extra_body.options.num_ctx >= 8192' "$tmp/.graphify/providers.json" >/dev/null || + fail "Ollama local Graphify provider must disable thinking and retain context headroom" +) +pass "Graphify local providers enforce structured no-thinking contracts" + +python3 -m py_compile "$ROOT/scripts/graphify-diagnostic-proxy.py" +python3 - "$ROOT/scripts/graphify-diagnostic-proxy.py" <<'PY' +import importlib.util +import io +import json +import sys + +path = sys.argv[1] +spec = importlib.util.spec_from_file_location("lds_graphify_diagnostic_proxy", path) +module = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(module) + +suspect, reason = module.classify_graph_content('{"nodes":[],"edges":[],"hyperedges":[]}') +assert suspect and reason == "valid but empty graph fragment" + +suspect, reason = module.classify_graph_content('{"nodes":["A","B"],"edges":[]}') +assert suspect and "no usable object entries" in reason + +suspect, _ = module.classify_graph_content( + '{"nodes":[{"id":"a","label":"A"}],"edges":[],"hyperedges":[]}' +) +assert not suspect + +fence = chr(96) * 3 +suspect, _ = module.classify_graph_content( + "answer follows\n" + fence + "json\n" + + '{"nodes":[{"id":"a"}],"edges":[]}' + "\n" + fence +) +assert not suspect + +suspect, reason = module.classify_graph_content("I found nothing useful in these documents.") +assert suspect and "not parseable" in reason + +suspect, reason = module.classify_graph_content( + '{"nodes":[{"id":"a","source_file:".github/x.md"}],"edges":[]}' +) +assert suspect and reason == "malformed graph JSON" + +assert module.parse_graph_content('{"nodes":[],"edges":[],"hyperedges":[]}') == { + "nodes": [], + "edges": [], + "hyperedges": [], +} +assert module.parse_graph_content('{"nodes":["bad"],"edges":[],"hyperedges":[]}') is None + +assert module.parse_graph_content( + '{"nodes":[{"id":"a"}],"edges":[],"hyperedges":[]}' +) == { + "nodes": [{"id": "a"}], + "edges": [], + "hyperedges": [], +} + +body = json.dumps({ + "model": "qwen3.5:9b", + "messages": [ + {"role": "system", "content": "You are a graphify semantic extraction agent."}, + {"role": "user", "content": "private corpus content"} + ], + "think": False, + "stream": False +}).encode() +metadata = module._request_metadata(body) +assert metadata["_extraction_request"] is True +assert metadata["think"] is False +assert "private corpus content" not in json.dumps(metadata) + +recovery = module._build_tool_recovery_request(body) +assert recovery is not None +recovery_json = json.loads(recovery) +assert recovery_json["tools"][0]["function"]["name"] == "submit_graph" +assert recovery_json["tool_choice"] == "auto" +assert recovery_json["stream"] is True +assert recovery_json["think"] is False +assert "STRUCTURED OUTPUT" in recovery_json["messages"][0]["content"] +assert recovery_json["temperature"] == 0 +assert recovery_json["max_completion_tokens"] == 2048 + +assert recovery_json["tools"][0]["function"]["parameters"] == module._GRAPH_SCHEMA +assert "rationale_for" not in module._GRAPH_SCHEMA["properties"]["edges"]["items"]["properties"]["relation"]["enum"] + +ollama_body = json.dumps({ + "model": "qwen3:14b", + "messages": [ + {"role": "system", "content": "You are a graphify semantic extraction agent."}, + {"role": "user", "content": "private corpus content"} + ], + "reasoning_effort": "none", + "stream": False, + "temperature": 0, + "options": {"num_ctx": 8192}, +}).encode() +ollama_request = module._build_ollama_schema_request(ollama_body) +assert ollama_request is not None +ollama_json = json.loads(ollama_request) +assert ollama_json["reasoning_effort"] == "none" +assert ollama_json["options"]["num_ctx"] == 8192 +assert ollama_json["temperature"] == 0 +assert ollama_json["max_completion_tokens"] == 2048 +assert ollama_json["response_format"]["type"] == "json_schema" +assert ollama_json["response_format"]["json_schema"]["strict"] is True +assert ollama_json["response_format"]["json_schema"]["schema"] == module._GRAPH_SCHEMA + +tool_response = { + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{ + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "reasoning_content": "private reasoning", + "content": "private reasoning", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "submit_graph", + "arguments": json.dumps({ + "nodes": [{"id": "readme_pathwise", "label": "Pathwise"}], + "edges": [], + "hyperedges": [], + }), + }, + }], + }, + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, +} +graph = module._extract_graph_tool_result(json.dumps(tool_response).encode()) +assert graph == { + "nodes": [{"id": "readme_pathwise", "label": "Pathwise"}], + "edges": [], + "hyperedges": [], +} + +assert module._extract_fastflow_structured_graph(json.dumps(tool_response).encode()) == graph + +sse_tool_event = { + "id": "chatcmpl-stream-test", + "object": "chat.completion.chunk", + "model": "qwen3.5:9b", + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_stream", + "type": "function", + "function": { + "name": "submit_graph", + "arguments": json.dumps(graph), + }, + }], + }, + "finish_reason": None, + }], +} +sse_tail = { + "id": "chatcmpl-stream-test", + "object": "chat.completion.chunk", + "model": "qwen3.5:9b", + "choices": [{ + "index": 0, + "delta": {"content": None}, + "finish_reason": "tool_calls", + }], + "usage": {"prompt_tokens": 100, "completion_tokens": 30, "total_tokens": 130}, +} +stream_bytes = ( + "data: " + json.dumps(sse_tool_event) + "\n\n" + + "data: " + json.dumps(sse_tail) + "\n\n" + + "data: [DONE]\n\n" +).encode() +collapsed = module._fastflow_stream_completion(io.BytesIO(stream_bytes), "qwen3.5:9b") +collapsed_json = json.loads(collapsed) +assert collapsed_json["choices"][0]["finish_reason"] == "tool_calls" +assert module._extract_fastflow_structured_graph(collapsed) == graph + +content_only_response = { + "choices": [{ + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": json.dumps(graph), + }, + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}, +} +assert module._extract_fastflow_structured_graph( + json.dumps(content_only_response).encode() +) == graph + +replacement = module._replace_response_content(json.dumps(tool_response).encode(), graph) +assert replacement is not None +replacement_json = json.loads(replacement) +message = replacement_json["choices"][0]["message"] +assert json.loads(message["content"]) == graph +assert "tool_calls" not in message +assert "reasoning_content" not in message +assert replacement_json["choices"][0]["finish_reason"] == "stop" + +split = module._graphify_split_response(json.dumps(tool_response).encode(), "qwen3.5:9b") +split_json = json.loads(split) +assert split_json["model"] == "qwen3.5:9b" +assert split_json["choices"][0]["finish_reason"] == "length" +assert json.loads(split_json["choices"][0]["message"]["content"]) == { + "nodes": [], + "edges": [], + "hyperedges": [], +} + +split_graph = module._extract_fastflow_structured_graph(split) +assert split_graph == {"nodes": [], "edges": [], "hyperedges": []} +split_replacement = module._replace_response_content(split, split_graph) +assert split_replacement is not None +split_replacement_json = json.loads(split_replacement) +assert split_replacement_json["choices"][0]["finish_reason"] == "length" + +string_array_response = json.loads(json.dumps(tool_response)) +string_array_response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = json.dumps({ + "nodes": json.dumps([{"id": "a"}]), + "edges": "[]", + "hyperedges": "[]", +}) +assert module._extract_graph_tool_result(json.dumps(string_array_response).encode()) == { + "nodes": [{"id": "a"}], + "edges": [], + "hyperedges": [], +} + +nested_arguments_response = json.loads(json.dumps(tool_response)) +nested_arguments_response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = json.dumps({ + "arguments": json.dumps({ + "nodes": [{"id": "nested"}], + "edges": [], + "hyperedges": [], + }) +}) +assert module._extract_graph_tool_result(json.dumps(nested_arguments_response).encode()) == { + "nodes": [{"id": "nested"}], + "edges": [], + "hyperedges": [], +} + +name_drift_response = json.loads(json.dumps(tool_response)) +name_drift_response["choices"][0]["message"]["tool_calls"][0]["function"]["name"] = " submit_graph " +assert module._extract_graph_tool_result(json.dumps(name_drift_response).encode()) == graph + +flat_call_response = json.loads(json.dumps(tool_response)) +call = flat_call_response["choices"][0]["message"]["tool_calls"][0] +flat_call_response["choices"][0]["message"]["tool_calls"][0] = { + "name": "submit_graph", + "arguments": call["function"]["arguments"], +} +assert module._extract_graph_tool_result(json.dumps(flat_call_response).encode()) == graph + +summary = module._tool_call_summary(json.dumps(tool_response).encode()) +assert "submit_graph:keys=" in summary +assert "nodes" in summary + +empty_tool_response = json.loads(json.dumps(tool_response)) +empty_tool_response["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"] = json.dumps({ + "nodes": [], + "edges": [], + "hyperedges": [], +}) +assert module._extract_graph_tool_result(json.dumps(empty_tool_response).encode()) == { + "nodes": [], + "edges": [], + "hyperedges": [], +} +PY +pass "Graphify diagnostic proxy identifies and structurally recovers suspect responses" + + +( + set -euo pipefail + # shellcheck source=lib/services.sh + source "$ROOT/lib/services.sh" + + normalize_service() { printf '%s' "${1,,}"; } + docker() { return 1; } + + __test_runtime=npu + effective_ai_runtime() { printf '%s' "$__test_runtime"; } + ai_service_for_runtime() { + case "${1,,}" in + npu) printf '%s' llm-fastflow ;; + *) printf '%s' llm-ollama ;; + esac + } + compose_service_exists() { + case "$1" in + llm-fastflow|llm-ollama) return 0 ;; + *) return 1 ;; + esac + } + + [[ "$(resolve_service llm)" == "llm-fastflow" ]] || + fail "common llm alias did not resolve FastFlow for NPU runtime" + __test_runtime=cpu + [[ "$(resolve_service llm)" == "llm-ollama" ]] || + fail "common llm alias did not resolve Ollama for CPU runtime" +) +pass "generic service commands resolve llm to the active provider" diff --git a/tests/catalog-contract.sh b/tests/catalog-contract.sh new file mode 100644 index 00000000..9d1aceb3 --- /dev/null +++ b/tests/catalog-contract.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +catalog="$ROOT/docker/catalog/services.psv" +assert_file "$catalog" + +declare -A seen_keys=() +declare -A seen_profiles=() +declare -a order=() + +while IFS='|' read -r key profile display service_key version_env defaults prompts admin_client volume url category optional default_enabled runtime_modes; do + [[ -n "$key" && "$key" != \#* ]] || continue + + [[ -n "$profile" ]] || fail "catalog row missing profile: $key" + [[ -n "$display" ]] || fail "catalog row missing display name: $key" + [[ -n "$service_key" ]] || fail "catalog row missing service key: $key" + [[ -n "$version_env" ]] || fail "catalog row missing version env: $key" + [[ -n "$category" ]] || fail "catalog row missing category: $key" + [[ -z "$admin_client" || "$admin_client" =~ ^[a-z0-9-]+$ ]] || + fail "catalog admin client invalid: $key" + [[ "$optional" =~ ^[01]$ ]] || fail "catalog optional flag invalid: $key" + [[ "$default_enabled" =~ ^[01]$ ]] || fail "catalog default_enabled flag invalid: $key" + [[ -z "${seen_keys[$key]:-}" ]] || fail "duplicate catalog key: $key" + [[ -z "${seen_profiles[$profile]:-}" ]] || fail "duplicate catalog profile: $profile" + + seen_keys["$key"]=1 + seen_profiles["$profile"]=1 + order+=("$key") + + [[ -n "$defaults" ]] || fail "catalog setup defaults missing: $key" + [[ -n "$prompts" ]] || fail "catalog setup prompts missing: $key" + + IFS=';' read -r -a default_items <<<"$defaults" + IFS=';' read -r -a prompt_items <<<"$prompts" + [[ "${#default_items[@]}" -eq "${#prompt_items[@]}" ]] || + fail "catalog prompts/defaults count mismatch: $key" + + if [[ -n "$url" && "$url" != https://*.localhost ]]; then + fail "catalog convenience URL must use local HTTPS: $key" + fi + + case "$key" in + POSTGRESQL|MYSQL|MARIADB|MONGODB|REDIS) + [[ -n "$volume" ]] || fail "persistent service missing volume metadata: $key" + ;; + AI) + [[ "$profile" == "ai" ]] || fail "AI profile must be ai" + [[ "$service_key" == "llm" ]] || fail "AI service identity must be provider-neutral llm" + [[ "$runtime_modes" == "cpu,nvidia,amd,npu" ]] || fail "AI runtime metadata drift" + [[ "$defaults" == "LDS_AI_MODEL=;LDS_AI_THINK=" ]] || fail "AI setup must leave provider model/thinking defaults automatic" + [[ "$version_env" == "LDS_AI_RUNTIME" ]] || fail "AI selector metadata must be LDS_AI_RUNTIME" + [[ "$url" == "https://llm.localhost" ]] || fail "AI convenience URL must use common llm identity" + ;; + ELASTICSEARCH) + [[ "$defaults" == *"ELASTICSEARCH_VERSION=9.5.4"* ]] || fail "Elastic current default drift" + ;; + esac +done <"$catalog" + +expected=(POSTGRESQL MYSQL MARIADB ELASTICSEARCH MONGODB REDIS AI) +[[ "${order[*]}" == "${expected[*]}" ]] || + fail "catalog order/coverage drift: ${order[*]}" + +pass "canonical host service catalog schema and coverage" diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh new file mode 100755 index 00000000..ef454afa --- /dev/null +++ b/tests/cli-contract.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +help_output="$("$ROOT/lds" help)" +assert_contains "$help_output" "LocalDevStack" +assert_contains "$help_output" "Stack" +assert_contains "$help_output" "Domain" +assert_contains "$help_output" "Setup" +pass "lds help" + +markdown_output="$("$ROOT/lds" help --markdown)" +assert_contains "$markdown_output" "# LocalDevStack" +assert_contains "$markdown_output" "lds stack up" +pass "lds markdown help" + +global_help="$("$ROOT/lds" --help)" +assert_contains "$global_help" "LocalDevStack" +pass "lds --help" + +code=0 +"$ROOT/lds" >/tmp/lds-noargs.out 2>/tmp/lds-noargs.err || code=$? +[[ "$code" -eq 1 ]] || fail "no-argument invocation must exit 1; got $code" +grep -q "LocalDevStack" /tmp/lds-noargs.out || fail "no-argument invocation must print help" +pass "no-argument behavior" + +tmpbin="$(mktemp -d)" +trap 'rm -rf "$tmpbin" /tmp/lds-noargs.out /tmp/lds-noargs.err' EXIT +cat >"$tmpbin/docker" <<'SH' +#!/usr/bin/env sh +exit 0 +SH +chmod +x "$tmpbin/docker" + +stack_help="$(PATH="$tmpbin:$PATH" "$ROOT/lds" stack help)" +assert_contains "$stack_help" "LocalDevStack" +pass "grouped stack help routing" +assert_contains "$help_output" "graphify [path]" +assert_contains "$markdown_output" "lds graphify" +pass "Graphify workflow help" + +graphify_log="$(mktemp)" +cat >"$tmpbin/graphify" <<'SH' +#!/usr/bin/env sh +printf 'ollama_base=%s ollama_model=%s openai_base=%s openai_model=%s timeout=%s args=%s\n' \ + "${OLLAMA_BASE_URL-}" "${OLLAMA_MODEL-}" "${OPENAI_BASE_URL-}" "${OPENAI_MODEL-}" \ + "${GRAPHIFY_API_TIMEOUT-}" "$*" >>"$GRAPHIFY_TEST_LOG" +SH +chmod +x "$tmpbin/graphify" + +# Ollama runtime keeps Graphify's native Ollama backend. +PATH="$tmpbin:$PATH" \ +LDS_AI_RUNTIME=cpu \ +OLLAMA_BASE_URL=http://custom-ollama.test:11434/v1 \ +OLLAMA_MODEL=test-ollama \ +LDS_AI_TIMEOUT=1800 \ +GRAPHIFY_TEST_LOG="$graphify_log" \ + "$ROOT/lds" graphify . --api-timeout 42 --mode deep >/dev/null + +grep -Fq 'ollama_base=http://custom-ollama.test:11434/v1 ollama_model=test-ollama openai_base= openai_model= timeout=42 args=extract . --backend ollama --no-cluster --api-timeout 42 --mode deep' "$graphify_log" || + fail "Graphify Ollama extract wrapper contract failed" +grep -Fq 'args=cluster-only . --backend ollama' "$graphify_log" || + fail "Graphify Ollama cluster wrapper contract failed" + +# FastFlow runtime uses Graphify's generic OpenAI backend and conservative local +# chunking defaults so qwen3.5:9b does not receive oversized semantic requests. +: >"$graphify_log" +PATH="$tmpbin:$PATH" \ +LDS_AI_RUNTIME=npu \ +OPENAI_BASE_URL=http://custom-fastflow.test:11434/v1 \ +OPENAI_MODEL=test-fastflow \ +LDS_AI_TIMEOUT=1800 \ +GRAPHIFY_TEST_LOG="$graphify_log" \ + "$ROOT/lds" graphify . --mode deep >/dev/null + +grep -Fq 'ollama_base= ollama_model= openai_base=http://custom-fastflow.test:11434/v1 openai_model=test-fastflow timeout=1800 args=extract . --backend openai --no-cluster --token-budget 4000 --max-concurrency 1 --mode deep' "$graphify_log" || + fail "Graphify FastFlow extract wrapper contract failed" +grep -Fq 'args=cluster-only . --backend openai' "$graphify_log" || + fail "Graphify FastFlow cluster wrapper contract failed" + +# Explicit Graphify resource controls always win over LocalDevStack defaults. +: >"$graphify_log" +PATH="$tmpbin:$PATH" \ +LDS_AI_RUNTIME=npu \ +OPENAI_BASE_URL=http://custom-fastflow.test:11434/v1 \ +OPENAI_MODEL=test-fastflow \ +GRAPHIFY_TEST_LOG="$graphify_log" \ + "$ROOT/lds" graphify . --token-budget 6000 --max-concurrency 2 >/dev/null + +grep -Fq 'args=extract . --backend openai --no-cluster --token-budget 6000 --max-concurrency 2' "$graphify_log" || + fail "Graphify FastFlow explicit resource overrides were not preserved" + +rm -f "$graphify_log" +assert_file_contains "$ROOT/lib/ai.sh" "http://llm.localhost:11434/v1" +assert_file_contains "$ROOT/lib/ai.sh" 'fastflow) printf '\''%s'\'' openai' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_TOKEN_BUDGET:-4000' +assert_file_contains "$ROOT/lib/ai.sh" 'LDS_GRAPHIFY_MAX_CONCURRENCY:-1' +pass "Graphify provider-aware host workflow wrapper" + +assert_file_contains "$ROOT/lds" 'exec "$DIR/bin/tool-runner" "$cmd" "$@"' +pass "unknown command fallback remains delegated to tool-runner" + +assert_file_contains "$ROOT/lds" 'local -a flags=(-i)' +assert_file_contains "$ROOT/lds" '[[ -t 0 && -t 1 ]] && flags+=(-t)' +pass "proxied host tools preserve piped stdin without forcing a TTY" diff --git a/tests/compose-contract.sh b/tests/compose-contract.sh new file mode 100755 index 00000000..4e51f9c9 --- /dev/null +++ b/tests/compose-contract.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +command -v docker >/dev/null 2>&1 || fail "docker is required" +docker compose version >/dev/null 2>&1 || fail "docker compose plugin is required" + +for compose_file in "$ROOT"/docker/compose/*.yaml; do + if awk ' + /^ volumes:[[:space:]]*$/ { in_service_volumes=1; next } + in_service_volumes && /^ - / { + entry=$0 + sub(/^[[:space:]]*-[[:space:]]*/, "", entry) + if (entry ~ /^["'\''"]/ ) exit 1 + next + } + in_service_volumes { in_service_volumes=0 } + ' "$compose_file"; then + : + else + fail "service volume mounts must use unquoted short-syntax scalars: $compose_file" + fi +done +pass "service volume mounts use one unquoted short-syntax style" + +release_env="$ROOT/docker/release.env" +user_env="$ROOT/docker/.env" +backup_env="" +had_user_env=0 + +if [[ -e "$user_env" ]]; then + had_user_env=1 + backup_env="$(mktemp)" + cp "$user_env" "$backup_env" +fi + +cleanup() { + if ((had_user_env)); then + cp "$backup_env" "$user_env" + rm -f "$backup_env" + else + rm -f "$user_env" + fi +} +trap cleanup EXIT + +cat >"$user_env" <>"$user_env" +fixed_images="$("${compose[@]}" config)" +assert_contains "$fixed_images" "image: infocyph/tools:latest" +if grep -Fq 'example.invalid/tools' <<<"$fixed_images"; then + fail "fixed infrastructure images must not be user-overridable" +fi +pass "fixed infrastructure images are declared directly in Compose" + +core_json="$("${compose[@]}" config --format json)" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +assert "apache" in d.get("services", {}) +assert "llm-ollama" not in d.get("services", {}) +assert d["volumes"]["lds_tools_state"]["name"] == "ToolsState" +tools=d["services"]["server-tools"] +targets={v["target"] for v in tools["volumes"]} +assert "/etc/share/state" in targets +' <<<"$core_json" +pass "Apache remains compatibility-safe for CLI/Admin hosts, AI stays optional, and Tools state is persistent" + +ai_json="$("${compose[@]}" --profile ai config --format json)" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +services=d["services"] +assert "llm-ollama" in services +assert "llm-fastflow" not in services +s=services["llm-ollama"] +assert s["image"] == "infocyph/llm-ollama:latest" +assert s["container_name"] == "LLM_OLLAMA" +assert not s.get("ports") +assert set(s["networks"]) == {"frontend","backend"} +assert {v["target"] for v in s["volumes"]} == {"/root/.ollama"} +assert d["volumes"]["lds_llm"]["name"] == "LLMModels" +env=s["environment"] +assert env["LLM_OLLAMA_MODEL"] == "qwen3:14b" +assert env["OLLAMA_NO_CLOUD"] == "1" +tools=services["server-tools"]["environment"] +assert tools["LDS_AI_ENABLED"] == "auto" +assert tools["LDS_AI_PROVIDER"] == "llm" +assert tools["LDS_AI_URL"] == "http://llm:11434" +assert tools["LDS_AI_MODEL"] == "qwen3:14b" +nginx=services["nginx"] +assert nginx["environment"]["LLM_PROXY_TIMEOUT_SECONDS"] == "1800" +native=[p for p in nginx.get("ports", []) if int(p["target"]) == 11434] +assert len(native) == 1 +assert native[0]["host_ip"] == "127.0.0.1" +assert int(native[0]["published"]) == 11434 +' <<<"$ai_json" +pass "bare Compose AI profile keeps Ollama compatibility fallback behind common llm identity" + +printf '%s\n' 'LDS_AI_MODEL=qwen2.5:1.5b' 'LLM_OLLAMA_PDF_MAX_PAGES=12' 'LLM_OLLAMA_SYSTEM=Answer briefly.' 'LDS_AI_TIMEOUT=2400' 'LDS_AI_IGPU_ENABLE=1' >>"$user_env" +ai_override_json="$("${compose[@]}" --profile ai config --format json)" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +llm=d["services"]["llm-ollama"]["environment"] +assert llm["LLM_OLLAMA_MODEL"] == "qwen2.5:1.5b" +assert llm["LLM_OLLAMA_PDF_MAX_PAGES"] == "12" +assert llm["LLM_OLLAMA_SYSTEM"] == "Answer briefly." +assert llm["OLLAMA_IGPU_ENABLE"] == "1" +tools=d["services"]["server-tools"]["environment"] +assert tools["LDS_AI_PROVIDER"] == "llm" +assert tools["LDS_AI_URL"] == "http://llm:11434" +assert tools["LDS_AI_TIMEOUT"] == "2400" +nginx=d["services"]["nginx"]["environment"] +assert nginx["LLM_PROXY_TIMEOUT_SECONDS"] == "2400" +' <<<"$ai_override_json" +pass "LocalDevStack forwards common Tools routing and Ollama-specific generation options" + +grep -v '^LDS_AI_MODEL=' "$user_env" >"$user_env.tmp" +mv "$user_env.tmp" "$user_env" + +npu_json="$(COMPOSE_PROFILES=ai LDS_AI_RUNTIME=npu "$ROOT/lds" --quiet config show --json --raw 2>/dev/null | sed -n '/^[[:space:]]*{/,$p')" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +services=d["services"] +assert "llm-fastflow" in services +assert "llm-ollama" not in services +s=services["llm-fastflow"] +assert s["image"] == "infocyph/llm-fastflow:latest" +assert s["container_name"] == "LLM_FASTFLOW" +assert not s.get("ports") +assert set(s["networks"]) == {"frontend","backend"} +assert s["environment"]["LLM_FASTFLOW_MODEL"] == "qwen3.5:9b" +assert s["environment"]["FLM_MODEL_PATH"] == "/models" +assert s["environment"]["FLM_SERVE_PORT"] == "11434" +assert s["environment"]["FLM_HOST"] == "0.0.0.0" +assert s["environment"]["FLM_CORS"] == "0" +assert "/dev/accel/accel0" in " ".join(str(x) for x in s.get("devices", [])) +assert s["ulimits"]["memlock"]["soft"] == -1 +assert s["ulimits"]["memlock"]["hard"] == -1 +assert {v["target"] for v in s["volumes"]} == {"/models"} +assert d["volumes"]["lds_llm_fastflow"]["name"] == "LLMFastFlowModels" +tools=services["server-tools"]["environment"] +assert tools["LDS_AI_PROVIDER"] == "llm" +assert tools["LDS_AI_URL"] == "http://llm:11434" +assert tools["LDS_AI_MODEL"] == "qwen3.5:9b" +' <<<"$npu_json" +pass "NPU runtime selects only FastFlow with its provider default model" + +amd_json="$(COMPOSE_PROFILES=ai LDS_AI_RUNTIME=amd "$ROOT/lds" --quiet config show --json --raw 2>/dev/null | sed -n '/^[[:space:]]*{/,$p')" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +services=d["services"] +assert "llm-ollama" in services +assert "llm-fastflow" not in services +s=services["llm-ollama"] +assert s["image"] == "infocyph/llm-ollama:amd-latest" +devices=" ".join(str(x) for x in s.get("devices", [])) +assert "/dev/kfd" in devices and "/dev/dri" in devices +assert s["environment"]["LLM_OLLAMA_MODEL"] == "qwen3:14b" +' <<<"$amd_json" +pass "AMD runtime selects only Ollama with generated ROCm devices" + +nvidia_json="$(COMPOSE_PROFILES=ai LDS_AI_RUNTIME=nvidia "$ROOT/lds" --quiet config show --json --raw 2>/dev/null | sed -n '/^[[:space:]]*{/,$p')" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +services=d["services"] +assert "llm-ollama" in services +assert "llm-fastflow" not in services +s=services["llm-ollama"] +assert s["image"] == "infocyph/llm-ollama:latest" +assert s.get("gpus") +assert s["environment"]["LLM_OLLAMA_MODEL"] == "qwen3:14b" +' <<<"$nvidia_json" +pass "NVIDIA runtime selects only Ollama with GPU augmentation" + +cpu_json="$(COMPOSE_PROFILES=ai LDS_AI_RUNTIME=cpu "$ROOT/lds" --quiet config show --json --raw 2>/dev/null | sed -n '/^[[:space:]]*{/,$p')" +python3 -c ' +import json,sys +d=json.load(sys.stdin) +services=d["services"] +assert "llm-ollama" in services +assert "llm-fastflow" not in services +assert services["llm-ollama"]["environment"]["LLM_OLLAMA_MODEL"] == "qwen3:14b" +' <<<"$cpu_json" +pass "CPU runtime selects only Ollama" + +if find "$ROOT/docker/compose" -maxdepth 1 -type f -name 'ai-*.yaml' -print -quit | grep -q .; then + fail "AI-specific Compose files must not exist" +fi +if [[ -d "$ROOT/docker/.runtime" ]] && find "$ROOT/docker/.runtime" -type f -print -quit | grep -q .; then + fail "temporary AI Compose overrides were not cleaned up" +fi +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'image: infocyph/llm-ollama:latest' +assert_file_contains "$ROOT/lib/compose.sh" 'image: infocyph/llm-ollama:amd-latest' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'image: infocyph/llm-fastflow:latest' +assert_file_contains "$ROOT/docker/compose/companion.yaml" 'aliases: [llm]' +pass "mutually exclusive LLM providers share one common llm network identity" diff --git a/tests/docs-contract.sh b/tests/docs-contract.sh new file mode 100644 index 00000000..3a6394b7 --- /dev/null +++ b/tests/docs-contract.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +index="$ROOT/docs/index.rst" +readme="$ROOT/README.md" +quick="$ROOT/docs/quickstart.rst" +arch="$ROOT/docs/concepts/architecture.rst" +profiles="$ROOT/docs/concepts/profiles-and-env.rst" +storage="$ROOT/docs/concepts/storage-layout.rst" +domain="$ROOT/docs/guides/domain-setup.rst" +tls="$ROOT/docs/guides/tls-and-certificates.rst" +ai="$ROOT/docs/guides/local-ai.rst" +databases="$ROOT/docs/guides/databases-and-clients.rst" +ops="$ROOT/docs/guides/operations-and-support.rst" +runner="$ROOT/docs/guides/ad-hoc-runner.rst" +notify="$ROOT/docs/guides/notifications.rst" +secrets="$ROOT/docs/guides/secrets-sops-age.rst" +cli="$ROOT/docs/reference/cli.rst" +plan="$ROOT/docs/plans/docker-ecosystem/07-localdevstack-integration-plan.md" + +for file in "$index" "$readme" "$quick" "$arch" "$profiles" "$storage" "$domain" "$tls" "$ai" "$databases" "$ops" "$runner" "$notify" "$secrets" "$cli" "$plan"; do + assert_file "$file" +done + +assert_file_contains "$index" 'guides/local-ai' +assert_file_contains "$index" 'guides/databases-and-clients' +assert_file_contains "$index" 'guides/operations-and-support' +assert_file_contains "$index" 'guides/ad-hoc-runner' +assert_file_contains "$index" 'reference/cli' +assert_file_contains "$readme" 'Dynamic Docker networking through service DNS' +assert_file_contains "$arch" 'Docker assigns their address ranges dynamically' +assert_file_contains "$domain" 'generated runtime Compose fragments are written under configuration/compose/' +assert_file_contains "$domain" 'Domain listing reads the NginxHosts named volume' +assert_file_contains "$storage" 'support traces, and support bundles' +assert_file_contains "$readme" 'lds clean --global --yes' +assert_file_contains "$arch" 'Apache' +assert_file_contains "$arch" 'COMPOSE_PROJECT_NAME' +assert_file_contains "$databases" 'CloudBeaver' +assert_file_contains "$ops" 'lds support bundle' +assert_file_contains "$ops" 'lds clean --global --yes' +assert_file_contains "$runner" 'lds run --sock' +assert_file_contains "$notify" 'Windows/Git Bash' +pass "docs describe current architecture, operations, and complete user surfaces" + +assert_file_contains "$profiles" 'docker/release.env' +assert_file_contains "$profiles" 'docker/.env' +assert_file_contains "$profiles" 'SCRIPTOMATIC_REF=main' +assert_file_contains "$profiles" 'localdevstack-php:' +assert_file_contains "$profiles" 'localdevstack-node:' +pass "docs preserve env precedence and runtime version selection" + +assert_file_contains "$storage" 'configuration/ssl/rootCA.pem' +assert_file_contains "$tls" 'configuration/ssl/rootCA.pem' +assert_file_contains "$tls" 'configuration/rootCA/rootCA.pem' +assert_file_contains "$storage" 'SSLKeys / SSLRootCA' +assert_file_contains "$storage" 'ToolsState' +pass "docs distinguish persistent runtime/control state and public TLS exports" + +assert_file_contains "$ai" 'http://llm:11434/v1' +assert_file_contains "$ai" 'https://llm.localhost/v1' +assert_file_contains "$ai" 'http://127.0.0.1:11434/v1' +assert_file_contains "$ai" 'llm-fastflow' +assert_file_contains "$ai" 'llm-ollama' +assert_file_contains "$ai" 'mutually exclusive' +assert_file_contains "$ai" 'qwen3.5:9b' +assert_file_contains "$ai" 'qwen3:14b' +assert_file_contains "$ai" 'LDS_AI_PROVIDER=llm' +assert_file_contains "$ai" 'LDS_AI_URL=http://llm:11434' +assert_file_contains "$ai" 'FLM_SERVE_PORT=11434' +assert_file_contains "$ai" '/dev/accel/accel0' +assert_file_contains "$ai" 'LLMFastFlowModels' +assert_file_contains "$ai" 'LLMModels' +assert_file_contains "$ai" 'no Docker socket' +assert_file_contains "$ai" 'no project/repository bind mount' +assert_file_contains "$ai" 'model-generated shell commands' +assert_file_contains "$ai" 'docker/compose/companion.yaml' +assert_file_contains "$ai" 'docker/.runtime/' +assert_file_contains "$ai" 'lds llm runtime npu' +assert_file_contains "$ai" 'lds logs llm' +assert_file_contains "$cli" 'lds restart llm' +assert_file_contains "$cli" 'lds exec llm' +assert_file_contains "$cli" 'lds rebuild llm' +assert_file_contains "$ai" 'backend=lds-fastflow' +assert_file_contains "$ai" 'backend=lds-ollama' +assert_file_contains "$ai" 'extra_body={"think": false}' +assert_file_contains "$ai" 'reasoning_effort=none' +assert_file_contains "$ai" 'lds llm think' +assert_file_contains "$ai" 'LDS_AI_THINK' +assert_file_contains "$ai" 'LDS_GRAPHIFY_DIAGNOSTICS=1' +assert_file_contains "$ai" 'submit_graph' +assert_file_contains "$ai" 'response_format.type=json_schema' +assert_file_contains "$ai" 'automatically switches to' +assert_file_contains "$ai" 'incremental mode' +assert_file_contains "$ai" 'Pass ``--force`` only when a' +assert_file_contains "$ai" 'lds-graphify-diagnostics.jsonl' +assert_file_contains "$ai" 'think' +assert_file_contains "$ai" 'reasoning_effort' +if grep -RqsF 'LDS_LLM_ARCH' "$ROOT/README.md" "$ROOT/docs" --exclude-dir=plans; then + fail "user-facing docs expose removed LDS_LLM_ARCH setting" +fi +assert_file_contains "$profiles" 'LDS_AI_PROVIDER=llm' +assert_file_contains "$profiles" 'LDS_AI_URL=http://llm:11434' +assert_file_contains "$profiles" 'infocyph/llm-fastflow:latest' +assert_file_contains "$profiles" 'LLM_OLLAMA_ALLOW_LARGE_INPUT=0' +assert_file_contains "$profiles" 'LLM_FASTFLOW_ALLOW_LARGE_INPUT=0' +assert_file_contains "$readme" 'lds llm ask "Explain dependency injection briefly"' +assert_file_contains "$readme" 'infocyph/llm-fastflow:latest' +assert_file_contains "$readme" 'https://llm.localhost' +pass "local AI common identity, mutually exclusive providers, trust boundary, and runtime defaults are documented" + +for stale in 'Scriptomatic/master' 'infocyph/tools:0.23.2' 'infocyph/runner:0.5' 'infocyph/nginx:0.4.1' 'infocyph/apache:0.4.2' 'infocyph/llm-ollama:0.03'; do + if grep -RqsF "$stale" "$ROOT/README.md" "$ROOT/docs" --exclude-dir=plans; then + fail "user-facing docs contain stale compatibility reference: $stale" + fi +done +pass "user-facing docs contain no superseded pinned infrastructure defaults" + +for subnet in 172.28.0.0 172.29.0.0 172.30.0.0; do + if grep -RqsF "$subnet" "$ROOT/README.md" "$ROOT/docs" --exclude-dir=plans; then + fail "user-facing docs contain legacy fixed subnet: $subnet" + fi +done +pass "user-facing docs contain no legacy fixed subnets" + +while IFS= read -r target; do + [[ -n "$target" ]] || continue + [[ -f "$ROOT/docs/$target.rst" ]] || + fail "docs/index.rst references missing page: $target.rst" +done < <( + awk ' + /^[[:space:]]{3}[A-Za-z0-9_./-]+$/ { + line=$0 + sub(/^[[:space:]]+/, "", line) + if (line !~ /^:/) print line + } + ' "$index" +) +pass "documentation toctree targets exist" + + +help_md="$("$ROOT/lds" help --markdown)" +for required in 'lds profiles add ' 'lds support trace ' 'lds support bundle [--redact|--full] [output.zip]' 'lds cli [cmd...]' 'lds run shell|ps|logs|stop|rm|open' 'MongoDB:' 'Elasticsearch:'; do + assert_contains "$help_md" "$required" +done +pass "embedded CLI help covers documented command groups" + + +for stale in LDS_TOOLS_IMAGE LDS_RUNNER_IMAGE LDS_NGINX_IMAGE LDS_APACHE_IMAGE; do + if grep -RqsF "$stale" "$ROOT/README.md" "$ROOT/docs" --exclude-dir=plans; then + fail "user-facing docs expose obsolete fixed-image variable: $stale" + fi +done +assert_file_contains "$ai" 'Both provider definitions live in ``docker/compose/companion.yaml``' +pass "docs reflect fixed infrastructure images and ephemeral AI overrides" + +assert_file_contains "$plan" 'Tools **0.25**' +assert_file_contains "$plan" 'Nginx **0.6**' +assert_file_contains "$plan" 'LLM-FastFlow **0.01.2**' +assert_file_contains "$plan" 'LLM-Ollama **0.05**' +assert_file_contains "$plan" 'current graph has **18** services' +assert_file_contains "$plan" 'current graph has **22** named volumes' +assert_file_contains "$plan" 'mutually exclusive' +assert_file_contains "$plan" 'llm:11434' +for stale in \ + '# Final implementation simplification — single LLM service' \ + '- automatic GPU detection;' \ + 'When AI is enabled, `llm-ollama` persists models'; do + if grep -Fq "$stale" "$plan"; then + fail "implementation plan contains superseded AI wording: $stale" + fi +done +pass "implementation plan matches the final provider architecture and current published baselines" diff --git a/tests/env-contract.sh b/tests/env-contract.sh new file mode 100755 index 00000000..8d7dbdd9 --- /dev/null +++ b/tests/env-contract.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +release_env="$ROOT/docker/release.env" + +assert_file "$release_env" +assert_file_contains "$ROOT/lds" 'ENV_MAIN="$DIR/.env"' +assert_file_contains "$ROOT/lds" 'ENV_DOCKER="$CFG/.env"' +assert_file_contains "$ROOT/lds" 'ENV_RELEASE="$CFG/release.env"' +assert_file_contains "$ROOT/lds" 'COMPOSE_FILE="$CFG/compose/main.yaml"' +assert_file_contains "$ROOT/lib/compose.sh" 'local -a env_files=(--env-file "$ENV_RELEASE")' +assert_file_contains "$ROOT/lib/compose.sh" 'env_files+=(--env-file "$ENV_DOCKER")' +assert_file_contains "$ROOT/lib/compose.sh" '"${env_files[@]}"' +assert_file_contains "$ROOT/lib/hosts.sh" 'var=COMPOSE_PROFILES' +assert_file_contains "$ROOT/lds" 'compose_control_value()' +assert_file_contains "$ROOT/lds" 'dotenv_value()' +assert_file_contains "$ROOT/lib/compose.sh" 'compose_control_value LDS_AI_RUNTIME ""' +if grep -RqsF 'LDS_LLM_ARCH' "$ROOT/lib" "$ROOT/docker/compose" "$ROOT/docker/catalog"; then + fail "obsolete LDS_LLM_ARCH remains in active environment wiring" +fi +pass "environment file and precedence wiring" + +git -C "$ROOT" check-ignore -q docker/.env || fail "docker/.env must remain ignored user state" +git -C "$ROOT" check-ignore -q .env || fail ".env must remain ignored user state" +if git -C "$ROOT" check-ignore -q docker/release.env; then + fail "docker/release.env must be tracked release state" +fi +pass "release and user env ownership boundaries" + +expected=( + 'SCRIPTOMATIC_REF=main' +) +for entry in "${expected[@]}"; do + assert_file_contains "$release_env" "$entry" +done +pass "release env contains only genuinely variable release defaults" + +assert_file_contains "$ROOT/lib/profiles.sh" 'CATALOG_FILE="$CFG/catalog/services.psv"' +assert_file_contains "$ROOT/lib/profiles.sh" 'load_service_catalog()' +assert_file_contains "$ROOT/lib/profiles.sh" 'load_service_catalog' +assert_file_contains "$ROOT/lib/profiles.sh" 'configured; Enter keeps current' +assert_file_contains "$ROOT/lib/services.sh" 'docker_compose config --profiles' +assert_file_contains "$ROOT/lib/services.sh" 'Unknown profile: $p' +pass "profile setup loads the catalog, preserves user state, and validates effective profiles" + +assert_file_contains "$ROOT/lib/ai.sh" 'cmd_ai()' +assert_file_contains "$ROOT/lib/ai.sh" 'cmd_llm()' +assert_file_contains "$ROOT/lib/ai.sh" '_tools_exec_argv()' +assert_file_contains "$ROOT/lds" 'ai) cmd_ai "$@"' +assert_file_contains "$ROOT/lds" 'llm) cmd_llm "$@"' +pass "AI/LLM CLI routing contract" + +assert_file_contains "$ROOT/lib/compose.sh" 'dc_cmd build --build-arg "SCRIPTOMATIC_REF=$scriptomatic_ref"' +if grep -R -Fq 'dc_build --no-cache' "$ROOT/lds" "$ROOT/lib"; then + fail "runtime rebuild path must preserve Docker build cache" +fi +assert_file_contains "$ROOT/lib/services.sh" 'dc_build --pull "$svc"' +pass "runtime rebuilds preserve cache while refreshing selected bases" + + +# Profile setup replaces catalog-managed service profiles while preserving +# generated runtime/domain profiles. +profile_tmp="$(mktemp -d)" +( + set -euo pipefail + CFG="$ROOT/docker" + ENV_DOCKER="$profile_tmp/docker.env" + CYAN="" NC="" BLUE="" YELLOW="" GREEN="" + die() { printf 'die: %s\n' "$*" >&2; exit 1; } + dotenv_value() { + local file="$1" key="$2" line + line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 || true)" + [[ -n "$line" ]] || return 1 + printf '%s' "${line#*=}" + } + # shellcheck source=lib/env.sh + source "$ROOT/lib/env.sh" + # shellcheck source=lib/profiles.sh + source "$ROOT/lib/profiles.sh" + printf '%s\n' 'COMPOSE_PROFILES=mysql,redis,ai,apache,php84' >"$ENV_DOCKER" + PENDING_PROFILES=(postgresql) + flush_profiles + actual="$(grep '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1)" + [[ "$actual" == 'COMPOSE_PROFILES=postgresql,apache,php84' ]] || + fail "profile reselection drifted: $actual" + + printf '%s\n' 'COMPOSE_PROFILES=mysql,redis,ai,apache,php84' >"$ENV_DOCKER" + PENDING_PROFILES=() + flush_profiles + actual="$(grep '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1)" + [[ "$actual" == 'COMPOSE_PROFILES=apache,php84' ]] || + fail "NONE selection did not clear only catalog-managed profiles: $actual" + + [[ "$(setup_menu_parse n)" == "NONE" ]] || fail "profile menu NONE token drifted" + [[ "$(setup_menu_parse q)" == "CANCEL" ]] || fail "profile menu CANCEL token drifted" + + printf '%s\n' 'REDIS_VERSION=7.4-alpine' >>"$ENV_DOCKER" + read_default() { printf '%s' "$2"; } + PENDING_ENVS=() + PENDING_PROFILES=() + setup_service REDIS >/dev/null + [[ "${PENDING_ENVS[0]:-}" == 'REDIS_VERSION=7.4-alpine' ]] || + fail "profile setup did not preserve configured value" +) +rm -rf "$profile_tmp" +pass "profile setup replaces/clears managed selections while preserving generated profiles and configured values" + +assert_file_contains "$ROOT/lib/compose.sh" 'compose_control_value COMPOSE_PROJECT_NAME LocalDevStack' +pass "CLI project identity follows the Compose project contract" + + +( + set -euo pipefail + has_cmd() { return 1; } + source "$ROOT/lib/platform.sh" + [[ "$(ai_provider_for_runtime cpu)" == "ollama" ]] || fail "CPU provider drift" + [[ "$(ai_provider_for_runtime nvidia)" == "ollama" ]] || fail "NVIDIA provider drift" + [[ "$(ai_provider_for_runtime amd)" == "ollama" ]] || fail "AMD provider drift" + [[ "$(ai_provider_for_runtime npu)" == "fastflow" ]] || fail "NPU provider drift" + [[ "$(ai_service_for_runtime npu)" == "llm-fastflow" ]] || fail "NPU service drift" + [[ "$(ai_service_for_runtime cpu)" == "llm-ollama" ]] || fail "Ollama service drift" + [[ "$(ai_model_default_for_runtime npu)" == "qwen3.5:9b" ]] || fail "FastFlow model default drift" + [[ "$(ai_model_default_for_runtime cpu)" == "qwen3:14b" ]] || fail "Ollama model default drift" + host_cpu_is_amd() { return 0; } + [[ "$(ai_igpu_default_for_runtime amd)" == "1" ]] || fail "AMD CPU + AMD runtime must enable iGPU" + [[ "$(ai_igpu_default_for_runtime cpu)" == "0" ]] || fail "CPU runtime must not enable iGPU" + [[ "$(ai_igpu_default_for_runtime nvidia)" == "0" ]] || fail "NVIDIA runtime must not enable AMD iGPU" + host_cpu_is_amd() { return 1; } + [[ "$(ai_igpu_default_for_runtime amd)" == "0" ]] || fail "non-AMD CPU must not auto-enable iGPU" +) +pass "LLM runtime maps mutually exclusive providers, models and AMD iGPU defaults deterministically" + +ai_env_tmp="$(mktemp -d)" +( + set -euo pipefail + DIR="$ROOT" + CFG="$ROOT/docker" + ENV_RELEASE="$ROOT/docker/release.env" + ENV_DOCKER="$ai_env_tmp/docker.env" + YELLOW="" NC="" + die() { printf "die: %s +" "$*" >&2; exit 1; } + dotenv_value() { + local file="$1" key="$2" line + line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 || true)" + [[ -n "$line" ]] || return 1 + printf "%s" "${line#*=}" + } + compose_control_value() { + local key="$1" fallback="${2:-}" value="" + if [[ -n "${!key+x}" ]]; then printf "%s" "${!key}"; return 0; fi + value="$(dotenv_value "$ENV_DOCKER" "$key" 2>/dev/null || true)" + [[ -n "$value" ]] && { printf "%s" "$value"; return 0; } + value="$(dotenv_value "$ENV_RELEASE" "$key" 2>/dev/null || true)" + [[ -n "$value" ]] && { printf "%s" "$value"; return 0; } + printf "%s" "$fallback" + } + source "$ROOT/lib/env.sh" + source "$ROOT/lib/platform.sh" + source "$ROOT/lib/certificates.sh" + detect_ai_runtime() { printf "%s" amd; } + host_cpu_is_amd() { return 0; } + add_required_env + grep -Fxq "LDS_AI_RUNTIME=amd" "$ENV_DOCKER" || fail "detected AI runtime was not persisted" + grep -Fxq "LDS_AI_IGPU_ENABLE=1" "$ENV_DOCKER" || fail "AMD CPU iGPU preference was not persisted" + + update_env "$ENV_DOCKER" LDS_AI_RUNTIME nvidia + update_env "$ENV_DOCKER" LDS_AI_IGPU_ENABLE 0 + detect_ai_runtime() { printf "%s" amd; } + add_required_env + grep -Fxq "LDS_AI_RUNTIME=nvidia" "$ENV_DOCKER" || fail "explicit runtime must win over detection" + grep -Fxq "LDS_AI_IGPU_ENABLE=0" "$ENV_DOCKER" || fail "explicit iGPU preference must be preserved" +) +rm -rf "$ai_env_tmp" +pass "setup bootstrap persists detection without overriding an explicit runtime" + +for stale in LDS_TOOLS_IMAGE LDS_RUNNER_IMAGE LDS_NGINX_IMAGE LDS_APACHE_IMAGE; do + if grep -RqsF "$stale" "$ROOT/docker/compose" "$ROOT/docker/release.env" "$ROOT/lib"; then + fail "fixed infrastructure image still has unnecessary variable: $stale" + fi +done +pass "fixed Tools/Runner/Nginx/Apache images have no env indirection" + +for stale in ai-nvidia.yaml ai-amd.yaml ai-host-port.yaml; do + [[ ! -e "$ROOT/docker/compose/$stale" ]] || fail "stale AI Compose overlay remains: $stale" +done +assert_file_contains "$ROOT/lib/compose.sh" 'mktemp "$CFG/.runtime/ai.XXXXXX"' +pass "AI runtime overrides are ephemeral rather than tracked Compose files" diff --git a/tests/fixtures/fake-ollama/Dockerfile b/tests/fixtures/fake-ollama/Dockerfile new file mode 100644 index 00000000..c1e6b1d1 --- /dev/null +++ b/tests/fixtures/fake-ollama/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.13-alpine + +WORKDIR /app +COPY server.py /app/server.py + +EXPOSE 11434 + +CMD ["python", "/app/server.py"] diff --git a/tests/fixtures/fake-ollama/server.py b/tests/fixtures/fake-ollama/server.py new file mode 100644 index 00000000..e0bff7e9 --- /dev/null +++ b/tests/fixtures/fake-ollama/server.py @@ -0,0 +1,98 @@ +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _json(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/api/version": + self._json(200, {"version": "0.0.0-ci"}) + return + + if self.path == "/api/tags": + self._json( + 200, + { + "models": [ + { + "name": "qwen3:14b", + "model": "qwen3:14b", + "size": 1, + "digest": "ci-fixture", + } + ] + }, + ) + return + + if self.path == "/v1/models": + self._json( + 200, + { + "object": "list", + "data": [ + { + "id": "qwen3:14b", + "object": "model", + "owned_by": "local", + } + ], + }, + ) + return + + self._json(404, {"error": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + + if self.path in ("/api/generate", "/api/chat"): + self._json( + 200, + { + "model": "qwen3:14b", + "response": "LocalDevStack CI", + "message": {"role": "assistant", "content": "LocalDevStack CI"}, + "done": True, + }, + ) + return + + if self.path == "/v1/chat/completions": + self._json( + 200, + { + "id": "chatcmpl-ci", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "LocalDevStack CI", + }, + "finish_reason": "stop", + } + ], + }, + ) + return + + self._json(404, {"error": "not found"}) + + def log_message(self, format, *args): + return + + +ThreadingHTTPServer(("0.0.0.0", 11434), Handler).serve_forever() diff --git a/tests/lib/assertions.sh b/tests/lib/assertions.sh new file mode 100644 index 00000000..24c8ff31 --- /dev/null +++ b/tests/lib/assertions.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +pass() { + printf 'PASS: %s\n' "$*" +} + +assert_file() { + local file="$1" + [[ -f "$file" ]] || fail "expected file: $file" +} + +assert_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" == *"$needle"* ]] || fail "expected output to contain: $needle" +} + +assert_file_contains() { + local file="$1" + local needle="$2" + grep -Fq -- "$needle" "$file" || fail "$file does not contain: $needle" +} + +assert_exit() { + local expected="$1" + shift + local code=0 + "$@" >/dev/null 2>&1 || code=$? + [[ "$code" -eq "$expected" ]] || fail "expected exit $expected, got $code: $*" +} diff --git a/tests/networking-contract.sh b/tests/networking-contract.sh new file mode 100755 index 00000000..e0d05dad --- /dev/null +++ b/tests/networking-contract.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +main="$ROOT/docker/compose/main.yaml" +db="$ROOT/docker/compose/db.yaml" +clients="$ROOT/docker/compose/db-client.yaml" +companion="$ROOT/docker/compose/companion.yaml" +http="$ROOT/docker/compose/http.yaml" +pghba="$ROOT/docker/conf/pg_hba.conf" + +for network in frontend backend datastore; do + grep -Eq "^ ${network}:" "$main" || fail "missing logical network: $network" +done +pass "logical network names" + +for runtime_file in "$ROOT"/docker/compose/*.yaml "$pghba"; do + if grep -Eq 'ipv4_address:|172\.28\.0\.|172\.29\.0\.|172\.30\.0\.' "$runtime_file"; then + fail "static LocalDevStack network dependency remains in $runtime_file" + fi +done +pass "fixed LocalDevStack addresses removed" + +schema_count="$(grep -c 'com.infocyph.network-schema: "dynamic-v1"' "$main")" +[[ "$schema_count" -eq 3 ]] || fail "expected dynamic-v1 schema label on all three networks" +if grep -q '^[[:space:]]*ipam:' "$main"; then + fail "main compose must not define fixed IPAM" +fi +pass "dynamic network schema" + +assert_file_contains "$clients" '@mongodb:' +assert_file_contains "$clients" 'ELASTICSEARCH_HOSTS=http://elasticsearch:9200' +assert_file_contains "$companion" 'hostname: runner' +assert_file_contains "$http" 'hostname: nginx' +assert_file_contains "$db" 'hostname: postgres' +assert_file_contains "$pghba" 'samenet' +pass "service-name/Docker-DNS contracts are present" + +if grep -Fq 'host.docker.internal:host-gateway' "$http"; then + fail "unused host-gateway mapping should not remain in the HTTP layer" +fi +assert_file_contains "$http" 'restart: unless-stopped' +pass "HTTP network/restart cleanup" + +assert_file_contains "$ROOT/lib/services.sh" 'migrate_legacy_networks()' +assert_file_contains "$ROOT/lib/services.sh" 'docker_compose down --remove-orphans' +assert_file_contains "$ROOT/lib/services.sh" 'com.infocyph.network-schema' +assert_file_contains "$ROOT/lib/services.sh" 'cmd_vpn_fix()' +assert_file_contains "$ROOT/lds" 'vpn-fix) cmd_vpn_fix "$@"' +assert_file_contains "$ROOT/lib/services.sh" 'migrate_legacy_networks' +pass "safe legacy-network migration and vpn-fix deprecation" + + +# Exercise the migration path rather than only checking for its source text. +migration_log="$(mktemp)" +( + set -euo pipefail + RED="" GREEN="" CYAN="" YELLOW="" NC="" + die() { printf 'die: %s\n' "$*" >&2; exit 1; } + warn() { :; } + ok() { :; } + lds_project() { printf '%s' "LocalDevStack"; } + docker_compose() { printf 'compose %s\n' "$*" >>"$migration_log"; } + docker() { + printf 'docker %s\n' "$*" >>"$migration_log" + if [[ "${1:-}" == "network" && "${2:-}" == "inspect" ]]; then + local network="${!#}" + [[ "$network" == "Frontend" ]] || return 1 + if [[ "${3:-}" != "-f" ]]; then + return 0 + fi + case "${4:-}" in + *com.infocyph.network-schema*) return 0 ;; + *Subnet*) printf '%s\n' '172.28.0.0/24' ;; + *com.infocyph.stack*) printf '%s\n' 'LocalDevStack' ;; + *com.docker.compose.project*) printf '%s\n' 'LocalDevStack' ;; + *Containers*) return 0 ;; + *) return 0 ;; + esac + return 0 + fi + if [[ "${1:-}" == "network" && "${2:-}" == "rm" ]]; then + return 0 + fi + return 0 + } + # shellcheck source=lib/services.sh + source "$ROOT/lib/services.sh" + migrate_legacy_networks +) +assert_file_contains "$migration_log" 'docker network inspect Frontend' +assert_file_contains "$migration_log" 'compose down --remove-orphans' +assert_file_contains "$migration_log" 'docker network rm Frontend' +rm -f "$migration_log" +if grep -Fq '\${' "$ROOT/lib/services.sh"; then + fail "legacy migration contains escaped parameter expansion" +fi +pass "legacy network migration executes against real network names" diff --git a/tests/permissions-contract.sh b/tests/permissions-contract.sh new file mode 100644 index 00000000..b0e09419 --- /dev/null +++ b/tests/permissions-contract.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +file="$ROOT/lib/platform.sh" +assert_file "$file" +if grep -Eq 'chmod[[:space:]]+-R[[:space:]]+777|chmod[[:space:]]+777' "$file"; then + fail "broad world-writable permission logic returned" +fi +assert_file_contains "$file" 'owner="${SUDO_USER:-${USER:-}}"' +assert_file_contains "$file" 'find "$DIR/configuration" -type d -exec chmod 2775 {} +' +assert_file_contains "$file" 'find "$DIR/logs" -type d -exec chmod 2775 {} +' +assert_file_contains "$file" 'find "$private_dir" -type d -exec chmod 0700 {} +' +assert_file_contains "$file" 'find "$private_dir" -type f -exec chmod 0600 {} +' +assert_file_contains "$file" '"$DIR/configuration/ssh" "$DIR/configuration/sops/keys"' +pass "host permissions are scoped and secret directories remain private" + +assert_file_contains "$file" 'find "$DIR/configuration/ssl" -type f -exec chmod 0644 {} +' +assert_file_contains "$file" "-name '*.p12'" +assert_file_contains "$file" "-name '*-key.pem'" +assert_file_contains "$file" ') -exec chmod 0600 {} +' +pass "TLS public exports remain readable while private/P12 artifacts stay 0600" diff --git a/tests/published-images.sh b/tests/published-images.sh new file mode 100755 index 00000000..6779764e --- /dev/null +++ b/tests/published-images.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +release_env="$ROOT/docker/release.env" +assert_file "$release_env" +assert_file_contains "$release_env" 'SCRIPTOMATIC_REF=main' + +tools_image="infocyph/tools:latest" +runner_image="infocyph/runner:latest" +nginx_image="infocyph/nginx:latest" +apache_image="infocyph/apache:latest" + +images=( + "$tools_image" + "$runner_image" + "$nginx_image" + "$apache_image" +) + +for image in "${images[@]}"; do + [[ -n "$image" ]] || fail "empty infrastructure image in release manifest" + printf 'Pulling %s\n' "$image" + docker pull "$image" + docker image inspect "$image" >/dev/null +done +pass "published infrastructure compatibility images exist" + +moving_images=( + "postgres:alpine" + "mysql:latest" + "mariadb:latest" + "mongo:latest" + "redis/redis-stack-server:latest" + "redis/redisinsight:latest" + "dbeaver/cloudbeaver:latest" + "mongo-express:latest" + "axllent/mailpit:latest" + "docker.elastic.co/elasticsearch/elasticsearch:9.5.4" + "docker.elastic.co/kibana/kibana:9.5.4" + "docker.elastic.co/beats/filebeat:9.5.4" + "infocyph/llm-ollama:latest" + "infocyph/llm-ollama:amd-latest" + "infocyph/llm-fastflow:latest" +) + +for image in "${moving_images[@]}"; do + printf 'Checking configured image reference %s\n' "$image" + docker buildx imagetools inspect "$image" >/dev/null +done +pass "all configured current image references resolve" + +for image in "$tools_image" "$runner_image"; do + health="$(docker image inspect "$image" --format '{{json .Config.Healthcheck}}')" + [[ -n "$health" && "$health" != "null" ]] || fail "$image must publish a healthcheck" +done +pass "Tools and Runner publish healthchecks" + +grep -Fq 'image: infocyph/llm-ollama:latest' "$ROOT/docker/compose/companion.yaml" || + fail "Ollama base service must use latest directly" +grep -Fq 'image: infocyph/llm-ollama:amd-latest' "$ROOT/lib/compose.sh" || + fail "AMD runtime override must select amd-latest directly" +grep -Fq 'image: infocyph/llm-fastflow:latest' "$ROOT/docker/compose/companion.yaml" || + fail "FastFlow service must use its published latest image" +grep -Fq "npu) printf '%s' fastflow" "$ROOT/lib/platform.sh" || + fail "NPU runtime must map to FastFlow" +pass "LLM image selection follows mutually exclusive provider contracts" + +tools_profile_chooser="$( + docker run --rm --entrypoint cat "$tools_image" /usr/local/bin/profile-chooser +)" +catalog="$ROOT/docker/catalog/services.psv" + +while IFS='|' read -r key profile _display _service_key version_env defaults _prompts _admin _volume _url _category _optional _default_enabled _runtime_modes; do + [[ -n "$key" && "$key" != \#* ]] || continue + [[ "$profile" != "ai" ]] || continue + + grep -Fq "[$key]=\"$profile\"" <<<"$tools_profile_chooser" || + fail "Tools profile-chooser service mapping drift: $key -> $profile" + + IFS=';' read -r -a catalog_defaults <<<"$defaults" + for kv in "${catalog_defaults[@]}"; do + [[ -n "$kv" ]] || continue + k="${kv%%=*}" + [[ "$k" == "$version_env" ]] && continue + grep -Fq "$kv" <<<"$tools_profile_chooser" || + fail "Tools profile-chooser non-version default drift for $profile: $kv" + done + + tools_version="$( + grep -oE "\[$profile\]=\"[^\"]+\"" <<<"$tools_profile_chooser" | + grep -oE "(${version_env})=[^ \"]+" | head -n1 || true + )" + catalog_version="$( + printf '%s\n' "${catalog_defaults[@]}" | grep -E "^${version_env}=" | head -n1 || true + )" + if [[ -n "$tools_version" && "$tools_version" != "$catalog_version" ]]; then + printf 'INFO: intentional image-version drift for %s: Tools=%s LocalDevStack=%s\n' "$profile" "$tools_version" "$catalog_version" + fi +done <"$catalog" +pass "LocalDevStack catalog matches latest Tools non-version profile contract" + + +docker run --rm --entrypoint sh "$tools_image" -lc ' + test -x /usr/local/bin/mkhost + test -s /etc/share/runtime-versions.json + jq -e ".php.active | type == \"array\" and length > 0" /etc/share/runtime-versions.json >/dev/null + jq -e ".node.active | type == \"array\" and length > 0" /etc/share/runtime-versions.json >/dev/null + grep -Fq "RUNTIME_VERSIONS_DB" /usr/local/bin/mkhost +' +pass "latest Tools preserves interactive PHP/Node runtime version catalog" + +php_template="$( + docker run --rm --entrypoint cat "$tools_image" /etc/docker-templates/php.compose.yaml +)" +node_template="$( + docker run --rm --entrypoint cat "$tools_image" /etc/docker-templates/node.compose.yaml +)" +assert_contains "$php_template" 'PHP_VERSION: {{PHP_VERSION}}' +assert_contains "$php_template" 'image: localdevstack-php:{{PHP_VERSION}}' +assert_contains "$node_template" 'NODE_VERSION: {{NODE_VERSION}}' +assert_contains "$node_template" 'image: localdevstack-node:{{NODE_VERSION}}' +pass "selected runtime versions remain build/image identity inputs" + +docker run --rm --entrypoint sh "$runner_image" -ec ' + test -x /usr/local/bin/logrotate-worker.sh + test -x /usr/local/bin/runner-healthcheck + test -f /etc/logrotate.d/daily + test -f /etc/logrotate.d/supervisord +' +pass "latest Runner preserves logrotate/health contract" + +assert_contains "$php_template" './docker/conf/www-php.conf:/usr/local/etc/php-fpm.d/www.conf' +pass "generated PHP runtime uses the maintained FPM pool config" + +tools_certify="$( + docker run --rm --entrypoint cat "$tools_image" /usr/local/bin/certify +)" +assert_contains "$tools_certify" 'EXPORT_DIR="${EXPORT_DIR:-/etc/share/certs}"' +assert_contains "$tools_certify" 'EXPORT_ROOTCA_NAME="${EXPORT_ROOTCA_NAME:-rootCA.pem}"' +assert_contains "$tools_certify" 'atomic_install 0644 "$root_ca" "$EXPORT_DIR/$EXPORT_ROOTCA_NAME"' +pass "latest Tools public TLS export contract" + +docker run --rm --entrypoint sh "$tools_image" -ec ' + test -d /etc/share/state + test -x /usr/local/bin/env-store + grep -Fq "/etc/share/state/env-store.json" /usr/local/bin/env-store + grep -Fq "/etc/share/state" /usr/local/bin/monitor-alerts + grep -Fq "/etc/share/state" /usr/local/bin/monitor-slo +' +pass "latest Tools durable state ABI" diff --git a/tests/qol-contract.sh b/tests/qol-contract.sh new file mode 100644 index 00000000..13c183da --- /dev/null +++ b/tests/qol-contract.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +user_env="$ROOT/docker/.env" +backup="" +had_env=0 +if [[ -e "$user_env" ]]; then + had_env=1 + backup="$(mktemp)" + cp "$user_env" "$backup" +fi +cleanup() { + if ((had_env)); then + cp "$backup" "$user_env" + rm -f "$backup" + else + rm -f "$user_env" + fi +} +trap cleanup EXIT + +cat >"$user_env" <"$tmpbin/docker" <<'SH' +#!/usr/bin/env sh +echo "docker should not be executed for this command" >&2 +exit 97 +SH +chmod +x "$tmpbin/docker" + +images="$(PATH="$tmpbin:$PATH" "$ROOT/lds" images)" +assert_contains "$images" "infocyph/tools:latest" +assert_contains "$images" "infocyph/runner:latest" +assert_contains "$images" "infocyph/nginx:latest" +assert_contains "$images" "infocyph/apache:latest" +assert_contains "$images" "postgres:alpine" +assert_contains "$images" "docker.elastic.co/elasticsearch/elasticsearch:9.5.4" +assert_contains "$images" "infocyph/llm-ollama:latest" +assert_contains "$images" "LLM provider" +assert_contains "$images" "ollama" +assert_contains "$images" "localdevstack-php: (Alpine)" +pass "images is offline-safe and reports fixed plus configurable image selections" + +urls="$(PATH="$tmpbin:$PATH" "$ROOT/lds" urls)" +assert_contains "$urls" "https://admin.localhost" +assert_contains "$urls" "https://webmail.localhost" +assert_contains "$urls" "https://db.localhost" +assert_contains "$urls" "https://ri.localhost" +assert_contains "$urls" "https://llm.localhost" +if grep -Fq "https://kibana.localhost" <<<"$urls"; then + fail "urls must not show disabled Elasticsearch profile URL" +fi +pass "urls is profile-aware and offline-safe" + +env_used="$(PATH="$tmpbin:$PATH" "$ROOT/lds" config env-used)" +assert_contains "$env_used" $'user\tCOMPOSE_PROFILES' +assert_contains "$env_used" $'release\tSCRIPTOMATIC_REF' +if grep -Fq "supersecret-ci-value" <<<"$env_used"; then + fail "config env-used leaked a value" +fi +pass "config env-used reports keys only" + +redacted="$("$ROOT/lds" config show)" +if grep -Fq "supersecret-ci-value" <<<"$redacted"; then + fail "config show leaked MYSQL_ROOT_PASSWORD" +fi +assert_contains "$redacted" "***REDACTED***" +pass "config show redacts effective secrets by default" + +bundle_dir="$(mktemp -d)" +bundle="$bundle_dir/bundle.zip" +"$ROOT/lds" support bundle --redact "$bundle" >/dev/null +python3 - "$bundle" "supersecret-ci-value" <<'PY' +import sys, zipfile +path, secret = sys.argv[1:] +with zipfile.ZipFile(path) as z: + for name in z.namelist(): + data = z.read(name) + if secret.encode() in data: + raise SystemExit(f"support bundle leaked secret in {name}") +PY +rm -rf "$bundle_dir" +pass "support bundle redacts interpolated secrets" + +default_bundle_dir="$(mktemp -d)" +( + cd "$default_bundle_dir" + "$ROOT/lds" support bundle --redact >/dev/null +) +default_bundle="$(find "$default_bundle_dir" -maxdepth 1 -type f -name 'lds_bundle_*.zip' -print -quit)" +[[ -n "$default_bundle" ]] || fail "support bundle option-only invocation did not create a default zip" +rm -rf "$default_bundle_dir" +pass "support bundle option-only invocation uses a generated filename" + +relative_bundle_dir="$(mktemp -d)" +( + cd "$relative_bundle_dir" + "$ROOT/lds" support bundle --redact relative-support.zip >/dev/null + [[ -s relative-support.zip ]] || fail "relative support bundle path was lost with staging cleanup" +) +rm -rf "$relative_bundle_dir" +pass "support bundle preserves explicit relative output paths" + +help="$("$ROOT/lds" help)" +assert_contains "$help" "doctor" +assert_contains "$help" "images" +assert_contains "$help" "urls" +assert_contains "$help" "support trace" +assert_contains "$help" "--global" +pass "QoL commands are discoverable" + +assert_file_contains "$ROOT/lds" 'images | urls | doctor)' +assert_file_contains "$ROOT/lib/diagnostics.sh" 'Docker daemon is unavailable.' +pass "doctor owns Docker availability diagnostics" + +assert_file_contains "$ROOT/lds" 'trace) cmd_support_trace "$@" ;;' +assert_file_contains "$ROOT/lib/services.sh" '/etc/share/vhosts/nginx/*.conf' +if grep -Fq '$DIR/configuration/nginx/' "$ROOT/lib/services.sh" "$ROOT/lib/diagnostics.sh"; then + fail "domain inspection must use persisted named-volume vhosts" +fi +assert_file_contains "$ROOT/lib/services.sh" 'docker_compose restart "${services[@]}"' +assert_file_contains "$ROOT/lib/services.sh" '--global' +assert_file_contains "$ROOT/lib/services.sh" 'label=com.docker.compose.project=$project' +pass "trace, domain listing, targeted restart, and scoped cleanup contracts" diff --git a/tests/release-gate.sh b/tests/release-gate.sh new file mode 100755 index 00000000..f339012a --- /dev/null +++ b/tests/release-gate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +"$ROOT/tests/static.sh" +"$ROOT/tests/cli-contract.sh" +"$ROOT/tests/env-contract.sh" +"$ROOT/tests/catalog-contract.sh" +"$ROOT/tests/compose-contract.sh" +"$ROOT/tests/networking-contract.sh" +"$ROOT/tests/wrappers-contract.sh" +bash "$ROOT/tests/service-hardening-contract.sh" +bash "$ROOT/tests/qol-contract.sh" +bash "$ROOT/tests/permissions-contract.sh" +bash "$ROOT/tests/docs-contract.sh" +"$ROOT/tests/runtime-php-contract.sh" +"$ROOT/tests/runtime-node-contract.sh" +"$ROOT/tests/ai-contract.sh" +"$ROOT/tests/published-images.sh" diff --git a/tests/runtime-build-smoke.sh b/tests/runtime-build-smoke.sh new file mode 100644 index 00000000..e857e9f0 --- /dev/null +++ b/tests/runtime-build-smoke.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tools_image="infocyph/tools:latest" + +docker pull "$tools_image" >/dev/null +versions="$(docker run --rm --entrypoint cat "$tools_image" /etc/share/runtime-versions.json)" + +php_version="$(jq -r '.php.active[0].version // empty' <<<"$versions")" +node_version="$(jq -r '.node.active[0].version // empty' <<<"$versions")" +[[ "$php_version" =~ ^[0-9]+\.[0-9]+$ ]] || { + printf 'Invalid active PHP version from Tools: %s\n' "$php_version" >&2 + exit 1 +} +[[ "$node_version" =~ ^[0-9]+([.][0-9]+([.][0-9]+)?)?$ ]] || { + printf 'Invalid active Node version from Tools: %s\n' "$node_version" >&2 + exit 1 +} + +php_key="${php_version/./}" +php_image="localdevstack-php:compat-smoke" +node_image="localdevstack-node:compat-smoke" + +printf 'Building selected PHP runtime: %s\n' "$php_version" +docker build --pull -f "$ROOT/docker/dockerfiles/php.Dockerfile" --build-arg "PHP_VERSION=$php_version" --build-arg "PHP_PROFILE_KEY=$php_key" --build-arg "UID=1000" --build-arg "GID=1000" --build-arg "USERNAME=dockery" --build-arg "SCRIPTOMATIC_REF=main" -t "$php_image" "$ROOT/docker/dockerfiles" + +docker run --rm --entrypoint sh "$php_image" -ec ' + php -v + php-fpm -t + command -v gitx >/dev/null + command -v chromacat >/dev/null + gitx --version + chromacat --version +' + +printf 'Building selected Node runtime: %s\n' "$node_version" +docker build --pull -f "$ROOT/docker/dockerfiles/node.Dockerfile" --build-arg "NODE_VERSION=$node_version" --build-arg "UID=1000" --build-arg "GID=1000" --build-arg "USERNAME=dockery" --build-arg "SCRIPTOMATIC_REF=main" -t "$node_image" "$ROOT/docker/dockerfiles" + +docker run --rm --entrypoint sh "$node_image" -ec ' + node --version + npm --version + command -v gitx >/dev/null + command -v chromacat >/dev/null + gitx --version + chromacat --version +' + +printf 'Runtime build smoke passed: PHP %s / Node %s\n' "$php_version" "$node_version" diff --git a/tests/runtime-node-contract.sh b/tests/runtime-node-contract.sh new file mode 100755 index 00000000..5f244031 --- /dev/null +++ b/tests/runtime-node-contract.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +file="$ROOT/docker/dockerfiles/node.Dockerfile" +assert_file "$file" +assert_file_contains "$file" 'ARG NODE_VERSION=current' +assert_file_contains "$file" 'FROM node:${NODE_VERSION}-alpine' +[[ "$(grep -c '^ARG NODE_VERSION' "$file")" -eq 2 ]] || + fail "NODE_VERSION must be redeclared inside the build stage" +assert_file_contains "$file" 'ARG LINUX_PKG=""' +assert_file_contains "$file" 'ARG LINUX_PKG_VERSIONED=""' +assert_file_contains "$file" 'ARG NODE_GLOBAL=""' +assert_file_contains "$file" 'ARG NODE_GLOBAL_VERSIONED=""' +assert_file_contains "$file" 'ARG UID=1000' +assert_file_contains "$file" 'ARG GID=1000' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_REF=main' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT=10' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_MAX_TIME=120' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_RETRIES=3' +assert_file_contains "$file" 'Scriptomatic/${SCRIPTOMATIC_REF}/bash/node-cli-setup.sh' +assert_file_contains "$file" '--connect-timeout "${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}"' +assert_file_contains "$file" '--max-time "${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}"' +assert_file_contains "$file" '--retry "${SCRIPTOMATIC_DOWNLOAD_RETRIES}"' +assert_file_contains "$file" 'test -s "$bootstrap"' +assert_file_contains "$file" 'bash -n "$bootstrap"' +assert_file_contains "$file" 'resolved_node_version="$(node -v' +assert_file_contains "$file" 'SCRIPTOMATIC_REF="${SCRIPTOMATIC_REF}"' +assert_file_contains "$file" 'bash "$bootstrap" "${USERNAME}" "$resolved_node_version"' +assert_file_contains "$file" 'EXPOSE 3000' +assert_file_contains "$file" 'ENTRYPOINT ["/usr/local/bin/node-entry"]' + +if grep -Eq '^ADD https?://|Scriptomatic/master/' "$file"; then + fail "Node runtime must not use remote ADD or the stale Scriptomatic master ref" +fi + +pass "Node selected-version Alpine runtime and Scriptomatic bootstrap contract" diff --git a/tests/runtime-php-contract.sh b/tests/runtime-php-contract.sh new file mode 100755 index 00000000..1e117c12 --- /dev/null +++ b/tests/runtime-php-contract.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +file="$ROOT/docker/dockerfiles/php.Dockerfile" +assert_file "$file" +assert_file_contains "$file" 'ARG PHP_VERSION=8.4' +assert_file_contains "$file" 'FROM php:${PHP_VERSION}-fpm-alpine' +[[ "$(grep -c '^ARG PHP_VERSION' "$file")" -eq 2 ]] || + fail "PHP_VERSION must be redeclared inside the build stage" +assert_file_contains "$file" 'ARG PHP_PROFILE_KEY=84' +assert_file_contains "$file" 'ARG LINUX_PKG=""' +assert_file_contains "$file" 'ARG LINUX_PKG_VERSIONED=""' +assert_file_contains "$file" 'ARG PHP_EXT=""' +assert_file_contains "$file" 'ARG PHP_EXT_VERSIONED=""' +assert_file_contains "$file" 'ARG UID=1000' +assert_file_contains "$file" 'ARG GID=1000' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_REF=main' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT=10' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_MAX_TIME=120' +assert_file_contains "$file" 'ARG SCRIPTOMATIC_DOWNLOAD_RETRIES=3' +assert_file_contains "$file" 'Scriptomatic/${SCRIPTOMATIC_REF}/bash/php-cli-setup.sh' +assert_file_contains "$file" '--connect-timeout "${SCRIPTOMATIC_DOWNLOAD_CONNECT_TIMEOUT}"' +assert_file_contains "$file" '--max-time "${SCRIPTOMATIC_DOWNLOAD_MAX_TIME}"' +assert_file_contains "$file" '--retry "${SCRIPTOMATIC_DOWNLOAD_RETRIES}"' +assert_file_contains "$file" 'test -s "$bootstrap"' +assert_file_contains "$file" 'bash -n "$bootstrap"' +assert_file_contains "$file" 'SCRIPTOMATIC_REF="${SCRIPTOMATIC_REF}"' +assert_file_contains "$file" 'bash "$bootstrap" "${USERNAME}" "${PHP_VERSION}"' +assert_file_contains "$file" 'ENTRYPOINT ["/usr/local/bin/php-entry"]' +assert_file_contains "$file" 'CMD ["php-fpm"]' + +if grep -Eq '^ADD https?://|Scriptomatic/master/' "$file"; then + fail "PHP runtime must not use remote ADD or the stale Scriptomatic master ref" +fi + +pass "PHP selected-version Alpine runtime and Scriptomatic bootstrap contract" diff --git a/tests/service-hardening-contract.sh b/tests/service-hardening-contract.sh new file mode 100644 index 00000000..b79c7e11 --- /dev/null +++ b/tests/service-hardening-contract.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +companion="$ROOT/docker/compose/companion.yaml" +http="$ROOT/docker/compose/http.yaml" +db="$ROOT/docker/compose/db.yaml" +clients="$ROOT/docker/compose/db-client.yaml" +filebeat="$ROOT/docker/conf/filebeat.yml" +pg_ref="$ROOT/docker/conf/postgresql.conf" + +socket_count="$(grep -RhsF '/var/run/docker.sock:/var/run/docker.sock' "$ROOT/docker/compose" | wc -l | tr -d ' ')" +[[ "$socket_count" -eq 2 ]] || fail "Docker socket must be mounted only by Tools and Runner; found $socket_count compose mounts" +assert_file_contains "$companion" '/var/run/docker.sock:/var/run/docker.sock' +if grep -RqsF '/var/run/docker.sock' "$ROOT/docker/compose/db.yaml" "$ROOT/docker/compose/db-client.yaml" "$ROOT/docker/compose/http.yaml"; then + fail "Docker socket escaped the trusted Tools/Runner boundary" +fi +pass "Docker socket trust boundary" + +assert_file_contains "$db" 'test: ["CMD", "pg_isready", "-h", "127.0.0.1"]' +assert_file_contains "$db" 'test: ["CMD", "mysqladmin", "ping", "-h127.0.0.1", "--silent"]' +assert_file_contains "$db" 'test: ["CMD", "mongosh", "--host", "127.0.0.1", "--quiet", "--eval", "db.adminCommand('\''ping'\'')"]' +if grep -Fq 'PGPASSWORD=' "$db" || grep -Fq ' --password ' "$db"; then + fail "database readiness probes must not embed credentials" +fi +pass "database health probes are credential-free local readiness checks" + +for file in "$companion" "$http" "$clients"; do + assert_file_contains "$file" 'condition: service_healthy' +done +pass "service consumers wait for declared dependency health" + +assert_file_contains "$filebeat" 'hosts: ["http://elasticsearch:9200"]' +assert_file_contains "$filebeat" 'host: "http://kibana:5601"' +pass "Filebeat uses service DNS" + +assert_file_contains "$pg_ref" '# INACTIVE REFERENCE CONFIGURATION' +if grep -Fq 'postgresql.conf:/etc/postgresql/postgresql.conf' "$db"; then + fail "inactive postgresql.conf must not be pseudo-wired in Compose" +fi +[[ ! -e "$ROOT/docker/conf/www.conf" ]] || fail "unused legacy docker/conf/www.conf must remain removed" +assert_file "$ROOT/docker/conf/www-php.conf" +pass "Docker config ownership is explicit" + +certs="$ROOT/lib/certificates.sh" +assert_file_contains "$companion" '../../configuration/ssl:/etc/share/certs' +assert_file_contains "$certs" 'local current="$DIR/configuration/ssl/rootCA.pem"' +assert_file_contains "$certs" 'local legacy="$DIR/configuration/rootCA/rootCA.pem"' +assert_file_contains "$certs" 'src_ca="$(host_root_ca_path || true)"' +pass "certificate export bridge uses the current public host path with legacy fallback" + +main="$ROOT/docker/compose/main.yaml" +assert_file_contains "$main" 'name: ToolsState' +assert_file_contains "$companion" 'lds_tools_state:/etc/share/state' +pass "Tools durable state persistence" + +assert_file_contains "$ROOT/lib/hosts.sh" 'modify_profiles add "$svr_prof"' +assert_file_contains "$ROOT/lib/hosts.sh" 'modify_profiles remove "$apache_cont"' +if grep -Fq 'profiles: [apache]' "$http"; then + fail "Apache cannot become profile-only until Admin Panel host lifecycle can manage the profile" +fi +pass "Apache remains always available so CLI and Admin Panel host creation retain parity" + +cert_helper_uses="$(grep -c 'src_ca="$(host_root_ca_path || true)"' "$certs")" +[[ "$cert_helper_uses" -ge 3 ]] || + fail "Windows install/uninstall and Unix install must all use host_root_ca_path" +if grep -Fq 'local src_ca="$DIR/configuration/rootCA/rootCA.pem"' "$certs"; then + fail "Unix CA install regressed to the legacy-only path" +fi +pass "all CA install paths use current export with legacy fallback" + +assert_file_contains "$companion" 'COMPOSE_PROFILES=${COMPOSE_PROFILES:-}' +pass "Tools profile visibility follows LocalDevStack profile selection" + +if awk '/^ llm-ollama:/ { in_llm=1; next } in_llm && /^ [a-zA-Z0-9_-]+:/ { in_llm=0 } in_llm { print }' "$companion" | grep -Eq '/var/run/docker.sock|PROJECT_DIR|/app'; then + fail "llm-ollama must not receive Docker socket or project mounts" +fi +pass "companion-owned mutually exclusive LLM providers keep the AI trust boundary" diff --git a/tests/static.sh b/tests/static.sh new file mode 100755 index 00000000..513a2849 --- /dev/null +++ b/tests/static.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +mapfile -t shell_files < <( + { + printf '%s\n' "$ROOT/lds" + find "$ROOT/bin" -maxdepth 1 -type f -print + find "$ROOT/lib" -type f -name '*.sh' -print + find "$ROOT/tests" -type f -name '*.sh' -print + } | sort -u +) + +(("${#shell_files[@]}" > 0)) || fail "no shell files found" + +for file in "${shell_files[@]}"; do + bash -n "$file" +done +pass "bash syntax" + +if command -v shellcheck >/dev/null 2>&1; then + shellcheck --severity=error -x "${shell_files[@]}" + pass "ShellCheck error-level gate" + + mapfile -t test_shell_files < <(find "$ROOT/tests" -type f -name '*.sh' -print | sort) + shellcheck --severity=warning -x "${test_shell_files[@]}" + pass "ShellCheck warning-level gate for tests" +fi + +while IFS= read -r file; do + if grep -Iq . "$file" && grep -q $'\r$' "$file"; then + fail "CRLF detected in shell file: $file" + fi +done < <(printf '%s\n' "${shell_files[@]}") + +pass "shell files use LF endings" diff --git a/tests/windows-bridge.ps1 b/tests/windows-bridge.ps1 new file mode 100644 index 00000000..6ead17b6 --- /dev/null +++ b/tests/windows-bridge.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$batPath = Join-Path $root "lds.bat" + +if (-not (Test-Path $batPath)) { + throw "lds.bat not found" +} + +$content = Get-Content -Raw -Path $batPath + +$required = @( + 'set "DEVHOME=%~dp0"', + 'set "WORKDIR=%CD%"', + 'where git.exe', + 'Get-Command git', + '\bin\bash.exe', + '\usr\bin\bash.exe', + 'cygpath -u', + '--__win_workdir', + '"%DEVHOME%" "%WORKDIR%" %*' +) + +foreach ($needle in $required) { + if (-not $content.Contains($needle)) { + throw "lds.bat is missing expected bridge contract: $needle" + } +} + +if ($content.Contains('docker info') -or $content.Contains('where docker.exe')) { + throw "lds.bat must not require Docker for offline-safe commands" +} +Write-Host "PASS: Windows bridge has no unconditional Docker preflight" + +$helpOutput = & cmd.exe /d /c ('"' + $batPath + '" help') 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "lds.bat help failed with exit code ${LASTEXITCODE}: $helpOutput" +} +if (($helpOutput -join "`n") -notmatch 'LocalDevStack') { + throw "lds.bat help did not reach the Bash CLI" +} +Write-Host "PASS: lds.bat help works without wrapper-level Docker checks" + +$tempParent = Join-Path $env:RUNNER_TEMP "Local Dev Stack" +New-Item -ItemType Directory -Force -Path $tempParent | Out-Null +Copy-Item -Path $batPath -Destination (Join-Path $tempParent "lds.bat") -Force + +$copied = Get-Content -Raw -Path (Join-Path $tempParent "lds.bat") +if (-not $copied.Contains('set "DEVHOME=%~dp0"')) { + throw "bridge contract was not preserved when copied under a path containing spaces" +} + +Write-Host "PASS: Windows bridge quoting/discovery contract" diff --git a/tests/wrappers-contract.sh b/tests/wrappers-contract.sh new file mode 100755 index 00000000..771364e9 --- /dev/null +++ b/tests/wrappers-contract.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/lib/assertions.sh +source "$ROOT/tests/lib/assertions.sh" + +wrappers=( + tool-runner php composer pg my maria mongo redis-cli es +) + +for name in "${wrappers[@]}"; do + file="$ROOT/bin/$name" + assert_file "$file" + bash -n "$file" + if grep -Eq '172\.(28|29|30)\.' "$file"; then + fail "$name contains a removed fixed-network address" + fi +done +pass "wrapper syntax and Docker-DNS independence" + +runner="$ROOT/bin/tool-runner" +assert_file_contains "$runner" '[[ -t 0 ]] && flags+=(-i)' +assert_file_contains "$runner" '[[ -t 1 ]] && flags+=(-t)' +assert_file_contains "$runner" 'MSYS_NO_PATHCONV=1' +assert_file_contains "$runner" 'MSYS2_ARG_CONV_EXCL=' +assert_file_contains "$runner" '--network "container:$SERVER_TOOLS_CONTAINER"' +assert_file_contains "$runner" '--volumes-from "$SERVER_TOOLS_CONTAINER"' +assert_file_contains "$runner" 'exec "$(bin_path docker)" run' +pass "tool-runner preserves TTY, path, namespace and exit-code contracts" + +php="$ROOT/bin/php" +assert_file_contains "$php" '-V|--v|--php)' +assert_file_contains "$php" 'pick_highest_php_container()' +assert_file_contains "$php" 'SERVER_TOOLS_VERSION="$(server_tools_php_version || true)"' +assert_file_contains "$php" '--network "container:${NETWORK_SOURCE_CONTAINER}"' +assert_file_contains "$php" 'MSYS_NO_PATHCONV=1' +pass "PHP wrapper preserves explicit/highest runtime selection" + +composer="$ROOT/bin/composer" +assert_file_contains "$composer" 'select_runtime()' +assert_file_contains "$composer" 'running_php_versions()' +assert_file_contains "$composer" 'server_tools_php_version()' +assert_file_contains "$composer" '--network "container:$TARGET_CONTAINER"' +assert_file_contains "$composer" 'MSYS_NO_PATHCONV=1' +pass "Composer wrapper preserves PHP runtime resolution" + +assert_file_contains "$ROOT/bin/pg" 'SERVICE="${POSTGRESQL_CONTAINER:-${POSTGRES_CONTAINER:-POSTGRESQL}}"' +assert_file_contains "$ROOT/bin/my" 'SERVICE="${MYSQL_CONTAINER:-MYSQL}"' +assert_file_contains "$ROOT/bin/maria" 'SERVICE="${MARIADB_CONTAINER:-${MYSQL_CONTAINER:-MARIADB}}"' +assert_file_contains "$ROOT/bin/mongo" 'SERVICE_DEFAULT="MONGODB"' +assert_file_contains "$ROOT/bin/redis-cli" 'SERVICE="${REDIS_CONTAINER:-REDIS}"' +assert_file_contains "$ROOT/bin/es" 'SERVICE="${ELASTICSEARCH_SERVICE:-ELASTICSEARCH}"' +pass "database wrappers resolve logical service/container identities" + +for name in pg my maria mongo redis-cli es; do + file="$ROOT/bin/$name" + if grep -Eq '172\.(28|29|30)\.|--host[= ]172\.' "$file"; then + fail "$name still assumes a LocalDevStack bridge address" + fi +done +pass "database wrappers have no static subnet assumptions" + +# Loopback inside the selected database/container namespace is intentional. +assert_file_contains "$ROOT/bin/pg" '-h127.0.0.1' +assert_file_contains "$ROOT/bin/my" 'host=127.0.0.1' +assert_file_contains "$ROOT/bin/maria" 'host=127.0.0.1' +assert_file_contains "$ROOT/bin/mongo" '@127.0.0.1:27017' +assert_file_contains "$ROOT/bin/es" 'ES_HOST="${ELASTICSEARCH_HOST:-127.0.0.1}"' +pass "database loopback use remains container-local, not bridge addressing"