Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# Build context is intentionally minimal — only the static binaries
# (staged by CI into bin/) and the Dockerfile itself need to be present.
*
!Dockerfile
!bin/
# Source-based image: COPY . brings the whole tree in, then this file strips
# anything not needed at runtime. Vendor is reinstalled inside the image, so
# host vendor/ never enters the context.
.git
.github
.idea
.worktrees
.claude
.DS_Store

# Reinstalled inside the image
vendor
node_modules

# Local build outputs
builds
bin
*.phar

# User config / secrets — must NEVER bake into the image
.env
clonio.json
clonio.pii-matchers.yaml
*.cloning.yaml

# Runtime state
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views

# Dev / test only
tests
docs
specs
phpstan.neon
phpunit.xml.dist
rector.php
box.json
.editorconfig
.gitignore
.gitattributes
.dockerignore
Makefile
README.md
CLAUDE.md
12 changes: 12 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev

- name: Strip unused vendor assets
# mpdf bundles ~87MB of TTF/OTF font families. AuditLogRenderer only uses
# the DejaVu family (default_font => DejaVuSans). Dropping the rest shaves
# ~38MB off the resulting PHAR / static binary with no runtime impact.
# Keep DejaVu* fonts plus DejaVuinfo.txt (licence/info read at runtime).
run: |
find vendor/mpdf/mpdf/ttfonts -mindepth 1 \
! -name 'DejaVu*' \
! -name 'DejaVuinfo.txt' \
-delete

- name: Stamp version from tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
Expand Down Expand Up @@ -438,6 +449,7 @@ jobs:
### Docker

```bash
docker pull ghcr.io/clonio-dev/clonio:${{ github.ref_name }}
docker run --rm -v "$(pwd)":/workspace \
ghcr.io/clonio-dev/clonio:${{ github.ref_name }} --version
```
Expand Down
79 changes: 70 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,78 @@
# syntax=docker/dockerfile:1.7
FROM alpine:3
#
# Source-based, multi-stage image. Same Dockerfile drives `make docker-build`
# locally and the multi-arch publish in CI. No static-binary dependency, so the
# docker job can run in parallel with the platform binary builds.
#
# Stage 1 (build) installs composer + git + unzip to resolve vendor; only the
# /app tree (source + slimmed vendor) is carried into the runtime image.
#
# Layout inside the final image:
# /app — application source + vendor (read-only at runtime)
# /workspace — WORKDIR; user mounts their project here so clonio.json /
# .env / .cloning.yaml resolve against getcwd()
#

# ── Stage 1: build vendor ────────────────────────────────────────────────────
FROM php:8.5-cli-alpine AS build

RUN apk add --no-cache git unzip

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /app

# Install prod deps first against lockfile only so the layer cache survives source edits.
# --no-scripts: skip Laravel package discovery until source is in place.
# --no-autoloader: defer optimized autoload until after COPY . so app/ is mapped.
COPY composer.json composer.lock ./
# --ignore-platform-reqs: composer install only unpacks files; the runtime
# stage installs gd / pcntl / pdo_mysql / pdo_pgsql. Skipping the platform
# check here keeps the build stage free of PHP extension libraries.
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-cache --ignore-platform-reqs

COPY . .

RUN composer dump-autoload --optimize --classmap-authoritative --no-dev

# Strip vendor dead weight (~10-30MB): per-package tests/docs trees and dev-only
# loose files. We keep .php and bundled assets; only artifacts a CLI never reads
# at runtime are removed.
RUN set -eux; \
find vendor -type d \( \
-name tests -o -name Tests -o -name test \
-o -name docs -o -name doc -o -name examples \
-o -name .github \
\) -prune -exec rm -rf {} +; \
find vendor -type f \( \
-name '*.md' -o -name '*.dist' \
-o -name 'phpunit.xml*' \
-o -name '.gitignore' -o -name '.gitattributes' -o -name '.editorconfig' \
\) -delete; \
# mpdf bundles ~87MB of TTF/OTF font families. Audit PDF renderer only uses
# the DejaVu family (see App\Services\Audit\AuditLogRenderer::renderPdf).
# Keep DejaVu* + the licence/info text files mpdf reads at runtime; drop the rest.
find vendor/mpdf/mpdf/ttfonts -mindepth 1 \
! -name 'DejaVu*' \
! -name 'DejaVuinfo.txt' \
-delete

# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM php:8.5-cli-alpine

# ca-certificates — Guzzle-based audit adapters (S3, webhook, ntfy) need a CA bundle.
# tzdata — correct timestamps in audit logs and cloning dumps when TZ is set.
RUN apk add --no-cache ca-certificates tzdata

# CI stages the binaries as:
# bin/clonio-linux-amd64 (from the x86_64 artifact)
# bin/clonio-linux-arm64 (from the aarch64 artifact)
# buildx sets TARGETARCH to "amd64" or "arm64" per platform.
ARG TARGETARCH
COPY bin/clonio-linux-${TARGETARCH} /usr/local/bin/clonio
RUN chmod +x /usr/local/bin/clonio
# Bundled in php:8.5-cli-alpine: ctype, curl, fileinfo, filter, iconv, mbstring,
# openssl, pdo, pdo_sqlite, phar, readline, session, sqlite3, tokenizer, zlib.
# install-php-extensions pulls in the matching system libs (libpng, libpq, etc.)
# and is removed after use to keep the runtime layer lean.
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions gd pcntl pdo_mysql pdo_pgsql \
&& rm /usr/local/bin/install-php-extensions

COPY --from=build /app /app

WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/clonio"]
ENTRYPOINT ["php", "/app/clonio"]
32 changes: 31 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.DEFAULT_GOAL := help
.PHONY: help current patch minor major alpha
.PHONY: help current patch minor major alpha docker-build docker-build-multiarch docker-test docker-shell

DOCKER_IMAGE ?= clonio:local

# ──────────────────────────────────────────────────────────────────────────────
# "latest stable" excludes pre-release tags (anything with a `-` suffix like
Expand All @@ -25,6 +27,12 @@ help:
printf " %-12s %s\n" "make patch" "Bump patch version ($$CURRENT → $$NEXT_PATCH)"; \
printf " %-12s %s\n" "make minor" "Bump minor version ($$CURRENT → v$$MAJOR.$$((MINOR+1)).0)"; \
printf " %-12s %s\n" "make major" "Bump major version ($$CURRENT → v$$((MAJOR+1)).0.0)"; \
echo ""; \
echo " Docker (local image: $(DOCKER_IMAGE)):"; \
printf " %-26s %s\n" "make docker-build" "Build image for host arch (fast)"; \
printf " %-26s %s\n" "make docker-build-multiarch" "Build image for linux/amd64 + linux/arm64 (slow, QEMU)"; \
printf " %-26s %s\n" "make docker-test" "Build image then run tests/smoke/run-smoke.sh against it"; \
printf " %-26s %s\n" "make docker-shell" "Drop into a shell inside the image (debug mounts)"; \
echo ""

current:
Expand Down Expand Up @@ -88,3 +96,25 @@ alpha:
git tag "$$NEW"; \
git push origin "$$NEW"; \
echo "Done — $$NEW pushed. CI will build a pre-release automatically."

# ──────────────────────────────────────────────────────────────────────────────
# Docker — source-based image (php:8.5-cli-alpine + composer install). Same
# Dockerfile is consumed locally and in CI; no static-binary dependency, so the
# image can be built before / in parallel with the binary build.
# ──────────────────────────────────────────────────────────────────────────────

docker-build:
@echo "Building $(DOCKER_IMAGE) for host arch..."
docker build -t $(DOCKER_IMAGE) .
@echo "Done — try: docker run --rm -v \"$$(pwd)\":/workspace $(DOCKER_IMAGE) --version"

docker-build-multiarch:
@echo "Building $(DOCKER_IMAGE) for linux/amd64 + linux/arm64 (QEMU emulation)..."
docker buildx build --platform linux/amd64,linux/arm64 -t $(DOCKER_IMAGE) .

docker-test: docker-build
@echo "Running smoke test against $(DOCKER_IMAGE)..."
./tests/smoke/run-smoke.sh docker $(DOCKER_IMAGE)

docker-shell: docker-build
docker run --rm -it -v "$$(pwd)":/workspace --entrypoint sh $(DOCKER_IMAGE)
33 changes: 32 additions & 1 deletion app/Services/Database/DatabaseConnectionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@

class DatabaseConnectionService
{
/** Hosts that mean "this machine" — rewritten to host.docker.internal inside a container. */
private const array LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1'];

private const string DOCKER_HOST_GATEWAY = 'host.docker.internal';

private readonly bool $inDocker;

public function __construct(?bool $inDocker = null)
{
// /.dockerenv is created by the Docker engine inside every container and
// is the most portable signal across Linux, macOS, and Windows daemons.
$this->inDocker = $inDocker ?? is_file('/.dockerenv');
}

/**
* Returns the plain-text password, decrypting the 'encrypted:' prefix if present.
*
Expand Down Expand Up @@ -51,7 +65,7 @@ public function buildConfig(ConnectionData $connection, string $password): array
/** @var array<string, mixed> $config */
$config = [
'driver' => $connection->type->value,
'host' => $connection->host,
'host' => $this->resolveHost($connection->host),
'port' => $connection->port,
'database' => $connection->database,
'username' => $connection->username,
Expand Down Expand Up @@ -79,6 +93,23 @@ public function buildConfig(ConnectionData $connection, string $password): array
return $config;
}

/**
* Rewrite loopback hostnames to host.docker.internal when running inside a
* container — inside the container, 127.0.0.1 is the container itself and
* cannot reach a database running on the host. Non-loopback hostnames are
* left untouched so user-specified addresses still win.
*/
private function resolveHost(?string $host): ?string
{
if (! $this->inDocker || $host === null) {
return $host;
}

return in_array(strtolower($host), self::LOOPBACK_HOSTS, true)
? self::DOCKER_HOST_GATEWAY
: $host;
}

/**
* Opens a live database connection and returns its dynamic name.
*
Expand Down
64 changes: 60 additions & 4 deletions docs/docker-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,66 @@ Prefer an exact `:1.2.3` tag in CI. `:latest` is safe for interactive use but dr

## Image internals

- **Base:** `alpine:3` with `ca-certificates` and `tzdata` installed.
- **Binary:** the same static `clonio-linux-x86_64` and `clonio-linux-aarch64` artifacts that are attached to each GitHub Release — placed at `/usr/local/bin/clonio` inside the image.
- **Entrypoint:** `/usr/local/bin/clonio`. Any arguments after `docker run … image` are passed straight through.
- **Workdir:** `/workspace`. Mount your project root here.
- **Base:** `php:8.5-cli-alpine` with `ca-certificates`, `tzdata`, and the PHP extensions Clonio needs (`gd`, `pcntl`, `pdo_mysql`, `pdo_pgsql` on top of the bundled set).
- **Source layout:** application code + vendor live at `/app`. The CLI entrypoint is `/app/clonio` (the same script you run as `php clonio` in development).
- **Entrypoint:** `php /app/clonio`. Any arguments after `docker run … image` are passed straight through.
- **Workdir:** `/workspace`. Mount your project root here so `clonio.json`, `.env`, and `.cloning.yaml` resolve against the same `getcwd()` Clonio uses on the host.
- **Build:** the image is built from source (no static binary dependency), so the publish step can run in parallel with the platform-specific binary builds.

### Mounting host files

Clonio reads `clonio.json` and `.env` from `getcwd()` — inside the container that's `/workspace`. The `.env` file holds `APP_KEY`, which is required to decrypt any `encrypted:…` connection passwords. **Both files must be on the mounted volume**, otherwise the container will either skip configuration entirely or fail to decrypt credentials.

```bash
docker run --rm -v "$(pwd)":/workspace \
ghcr.io/clonio-dev/clonio:latest connection:list
```

If you keep `clonio.json` / `.env` outside your project root, mount that directory instead and pass `-w /workspace` is unnecessary (`WORKDIR` already points there).

### Connecting to a database on the host

Inside the container, `127.0.0.1` and `localhost` refer to the container itself — not your host machine. Clonio detects when it is running inside a container (via `/.dockerenv`) and automatically rewrites loopback hostnames (`127.0.0.1`, `localhost`, `::1`) to `host.docker.internal` at connection time. Your `clonio.json` stays unchanged; the rewrite happens in memory only.

Non-loopback hostnames are passed through untouched, so explicit addresses still win.

```bash
docker run --rm -v "$(pwd)":/workspace ghcr.io/clonio-dev/clonio:latest \
connection:test source
```

**Linux** — `host.docker.internal` is not enabled by default. Either:

```bash
# Option A: add the gateway hostname explicitly
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$(pwd)":/workspace ghcr.io/clonio-dev/clonio:latest \
connection:test source

# Option B: share the host network (simplest, Linux-only)
docker run --rm --network=host \
-v "$(pwd)":/workspace ghcr.io/clonio-dev/clonio:latest \
connection:test source
```

**MySQL bind address.** Default MySQL listens on `127.0.0.1` only and will reject the container's bridge IP even after the hostname rewrite. Verify and adjust if needed:

```bash
mysql -uroot -e "SHOW VARIABLES LIKE 'bind_address';"
# if 127.0.0.1 only: set `bind-address = 0.0.0.0` in my.cnf and restart mysqld.
```

The matching MySQL user grant must allow non-loopback origins (`'root'@'%'` instead of `'root'@'localhost'`). Same applies to PostgreSQL's `listen_addresses` in `postgresql.conf`, plus a matching `pg_hba.conf` entry for the bridge subnet.

### Building locally

```bash
make docker-build # host arch only — fast
make docker-build-multiarch # linux/amd64 + linux/arm64 via QEMU
make docker-test # build + run the docker smoke test
make docker-shell # interactive sh inside the image, with $(pwd) mounted
```

---

Expand Down
Loading