mirror of
https://github.com/symfony/recipes-contrib.git
synced 2026-09-11 15:16:55 +03:00
2269 lines
134 KiB
JSON
2269 lines
134 KiB
JSON
{
|
|
"manifests": {
|
|
"fyrst/shopware-cd": {
|
|
"manifest": {
|
|
"copy-from-recipe": {
|
|
"root/": ""
|
|
},
|
|
"post-install-output": [
|
|
" * <fg=blue>fyrst/shopware-cd</>",
|
|
" Flex copied CI, <comment>.env.example</comment>, <comment>.dockerignore</comment>,",
|
|
" and <comment>deploy/</comment> (CD Compose + VPS helpers) into the shop root.",
|
|
"",
|
|
" This recipe requires <comment>shopware/docker</comment>, which copies",
|
|
" <comment>docker/Dockerfile</comment>. CI and Compose default to that file",
|
|
" (<comment>DOCKERFILE=docker/Dockerfile</comment>).",
|
|
"",
|
|
" It does not copy shop-root <comment>compose.yaml</comment>, <comment>.gitignore</comment>,",
|
|
" or <comment>.shopware-project.yaml</comment> \u2014 those come from",
|
|
" <comment>shopware-cli project create</comment>.",
|
|
"",
|
|
" CD Compose files live under <comment>deploy/</comment>:",
|
|
" <comment>deploy/compose.yaml</comment>, <comment>deploy/compose.prod.yaml</comment>,",
|
|
" <comment>deploy/compose.vps.yaml</comment>. From the shop root:",
|
|
" <comment>docker compose --project-directory . -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml</comment>",
|
|
"",
|
|
" VPS runtime copy (no S3): <comment>deploy/sync-runtime.sh</comment> plus",
|
|
" <comment>deploy/sync.env.example</comment> (see <comment>deploy/sync-runtime.md</comment>).",
|
|
" Bind-mounted host dirs default to",
|
|
" <comment>/var/lib/shopware/data/{files,media,thumbnail,theme,sitemap}</comment>",
|
|
" (<comment>SHOPWARE_DATA_ROOT</comment> in <comment>.env</comment>). On staging, copy to",
|
|
" <comment>deploy/sync.env</comment> and pull live \u2192 lower.",
|
|
"",
|
|
" Copy <comment>.env.example</comment> \u2192 <comment>.env</comment> yourself (Flex never writes <comment>.env</comment>).",
|
|
" Commit the copied files; <comment>vendor/</comment> is gitignored.",
|
|
"",
|
|
" GitHub Actions picks up",
|
|
" <comment>.github/workflows/cd.yaml</comment>. GitLab still defaults to",
|
|
" <comment>.gitlab-ci.yml</comment>: set Settings \u2192 CI/CD \u2192 CI/CD configuration file",
|
|
" to <comment>.gitlab-ci.yaml</comment> (or copy/symlink that name).",
|
|
"",
|
|
" Refresh later with <comment>composer recipes:update fyrst/shopware-cd</comment>.",
|
|
" See https://github.com/fyrst-dev/shopware-cd"
|
|
]
|
|
},
|
|
"files": {
|
|
"root/.dockerignore": {
|
|
"contents": [
|
|
"# Overlay .dockerignore \u2014 keep the Shopware project, drop secrets and",
|
|
"# paths that shopware-cli project ci rebuilds or that must never enter the image.",
|
|
"",
|
|
".git",
|
|
".gitattributes",
|
|
".github",
|
|
".gitlab-ci.yml",
|
|
".gitlab-ci.yaml",
|
|
".gitignore",
|
|
"",
|
|
"# Local / prod env and Composer auth (passed as BuildKit secrets instead)",
|
|
".env",
|
|
".env.*",
|
|
"!.env.example",
|
|
"auth.json",
|
|
"**/*.pem",
|
|
"**/*.key",
|
|
"",
|
|
"# Rebuilt during `shopware-cli project ci`",
|
|
"/vendor",
|
|
"/node_modules",
|
|
"",
|
|
"# Generated / state (runtime bind mounts on the host, not the image)",
|
|
"/var",
|
|
"/files",
|
|
"/public/bundles",
|
|
"/public/theme",
|
|
"/public/media",
|
|
"/public/thumbnail",
|
|
"/public/sitemap",
|
|
"/config/jwt",
|
|
"",
|
|
"# Editor / OS",
|
|
".idea",
|
|
".vscode",
|
|
".DS_Store",
|
|
"Thumbs.db",
|
|
"*.swp",
|
|
"*.bak",
|
|
"",
|
|
"# Overlay docs and VPS helpers are not needed inside the image",
|
|
"# (CD Compose lives under /deploy; shop-root compose.yaml is from project create)",
|
|
"*.md",
|
|
"/deploy",
|
|
"compose.yaml",
|
|
"compose.prod.yaml",
|
|
"compose.override.yaml",
|
|
"compose.override.yml",
|
|
"docker-compose*.yml",
|
|
"docker-compose*.yaml",
|
|
"Makefile",
|
|
"",
|
|
"# Tests (also list extra cleanup in shop-root .shopware-project.yaml if needed)",
|
|
"/tests",
|
|
".phpunit.result.cache",
|
|
".php-cs-fixer.cache",
|
|
".phpstan.cache",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/.env.example": {
|
|
"contents": [
|
|
"# Copy to `.env` (local) and to the VPS `.env` (mode 0600). Never commit filled values.",
|
|
"# Compose interpolates ${VAR} from this file. Shopware reads the same names at runtime.",
|
|
"#",
|
|
"# Generate APP_SECRET with: openssl rand -hex 32",
|
|
"# First-install admin vars are consumed by shopware-deployment-helper only on a fresh DB.",
|
|
"",
|
|
"# --- Image (Compose + CD) ----------------------------------------------------",
|
|
"# TODO: registry/repo for this shop, e.g. ghcr.io/fyrst-dev/shop-name",
|
|
"IMAGE=",
|
|
"IMAGE_TAG=latest",
|
|
"# From shopware/docker (Flex copies docker/Dockerfile)",
|
|
"DOCKERFILE=docker/Dockerfile",
|
|
"PHP_VERSION=8.3",
|
|
"",
|
|
"# --- Shopware runtime -----------------------------------------------------------",
|
|
"APP_ENV=prod",
|
|
"APP_URL=",
|
|
"APP_SECRET=",
|
|
"# Optional: INSTANCE_ID=",
|
|
"",
|
|
"# mysql://USER:PASSWORD@HOST:3306/DATABASE",
|
|
"# Bundled service hostname is `mysql`. For DBaaS, use the provider host and",
|
|
"# remove/disable the `mysql` service in deploy/compose.yaml.",
|
|
"DATABASE_URL=",
|
|
"# DATABASE_SSL_CA=",
|
|
"# DATABASE_SSL_CERT=",
|
|
"# DATABASE_SSL_KEY=",
|
|
"# DATABASE_SSL_DONT_VERIFY_SERVER_CERT=",
|
|
"",
|
|
"# --- Bundled MySQL service (compose service `mysql`) -----------------------",
|
|
"# Unused when DATABASE_URL points at an external database.",
|
|
"MYSQL_DATABASE=shopware",
|
|
"MYSQL_USER=",
|
|
"MYSQL_PASSWORD=",
|
|
"MYSQL_ROOT_PASSWORD=",
|
|
"",
|
|
"# --- First install (deployment helper; ignored on updates) --------------------",
|
|
"INSTALL_LOCALE=en-GB",
|
|
"INSTALL_CURRENCY=EUR",
|
|
"INSTALL_ADMIN_USERNAME=",
|
|
"INSTALL_ADMIN_PASSWORD=",
|
|
"INSTALL_ADMIN_EMAIL=",
|
|
"# Defaults to APP_URL when empty",
|
|
"SALES_CHANNEL_URL=",
|
|
"",
|
|
"# --- Optional Redis (compose profile `redis`) ------------------------------",
|
|
"# REDIS_URL=redis://redis:6379",
|
|
"# PHP_SESSION_HANDLER=redis",
|
|
"# PHP_SESSION_SAVE_PATH=tcp://redis:6379",
|
|
"",
|
|
"# --- Optional HTTP publish (local / VPS behind a reverse proxy) ---------------",
|
|
"HTTP_PORT=8000",
|
|
"",
|
|
"# --- Optional VPS smoke check (deploy/vps-release.sh) ------------------------",
|
|
"# SMOKE_URL=http://127.0.0.1:8000",
|
|
"",
|
|
"# --- Optional Compose profiles on the VPS (comma-separated) ------------------",
|
|
"# COMPOSE_PROFILES=redis,worker,scheduler",
|
|
"",
|
|
"# --- VPS runtime files (bind mounts; first-class default) --------------------",
|
|
"# Host dirs mapped into the container. Per-shop if several stacks share a VPS.",
|
|
"# Compose mounts {files,media,thumbnail,theme,sitemap} from this root.",
|
|
"SHOPWARE_DATA_ROOT=/var/lib/shopware/data",
|
|
"",
|
|
"# --- Optional VPS runtime data sync (deploy/sync-runtime.sh) -----------------",
|
|
"# Copy deploy/sync.env.example \u2192 deploy/sync.env (mode 0600). SSH and SYNC_ENV",
|
|
"# live there, not here. Object storage is out of scope for this VPS path.",
|
|
"# SYNC_ENV=staging",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/.github/workflows/cd.yaml": {
|
|
"contents": [
|
|
"# fyrst.dev Shopware CD \u2014 GitHub Actions",
|
|
"#",
|
|
"# Stages (same as .gitlab-ci.yaml): build \u2192 push extra tags \u2192 deploy.",
|
|
"# shopware-cli runs only inside docker/Dockerfile (shopware-cli image), not on the runner.",
|
|
"#",
|
|
"# Copied into shops by the Symfony Flex recipe (`copy-from-recipe` \u2192 shop root).",
|
|
"# Preflight runs CD only for a real Shopware shop (bin/console / shopware/*),",
|
|
"# never for the fyrst/shopware-cd Packagist library itself.",
|
|
"#",
|
|
"# ---------------------------------------------------------------------------",
|
|
"# Required secrets (Settings \u2192 Secrets and variables \u2192 Actions)",
|
|
"# ---------------------------------------------------------------------------",
|
|
"# SHOPWARE_PACKAGES_TOKEN packages.shopware.com (BuildKit secret packages_token)",
|
|
"# COMPOSER_AUTH optional JSON object for private Composer repos",
|
|
"#",
|
|
"# Registry \u2014 GHCR default (GITHUB_TOKEN). For another registry set:",
|
|
"# REGISTRY_USERNAME",
|
|
"# REGISTRY_PASSWORD",
|
|
"# Optional variables (not secrets):",
|
|
"# REGISTRY default ghcr.io",
|
|
"# REGISTRY_IMAGE default ghcr.io/<owner>/<repo>",
|
|
"# DOCKERFILE default docker/Dockerfile (from shopware/docker)",
|
|
"#",
|
|
"# Compose / VPS deploy (DEPLOY_TARGET empty or \"compose\"):",
|
|
"# SSH_PRIVATE_KEY deploy key (no passphrase)",
|
|
"# VPS_HOST hostname or IP \u2014 set per shop, do not hardcode here",
|
|
"# VPS_USER",
|
|
"# VPS_PATH absolute checkout path on the VPS",
|
|
"# SSH_KNOWN_HOSTS recommended (ssh-keyscan output). If unset, keyscan is used.",
|
|
"# VPS_SSH_PORT optional, default 22 (repository variable)",
|
|
"# Compose files (via deploy/vps-release.sh, --project-directory = shop root):",
|
|
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
|
|
"#",
|
|
"# Optional:",
|
|
"# DEPLOY_TARGET repository variable: \"managed\" skips SSH Compose",
|
|
"# (see deploy/managed/README.md)",
|
|
"# SMOKE_URL public URL CI can GET after deploy",
|
|
"#",
|
|
"# Never put real tokens, keys, or hostnames in this file.",
|
|
"",
|
|
"name: CD",
|
|
"",
|
|
"on:",
|
|
" push:",
|
|
" branches: [main]",
|
|
" tags: [\"v*\"]",
|
|
" pull_request:",
|
|
" branches: [main]",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" image_tag:",
|
|
" description: \"Override IMAGE_TAG (default: github.sha)\"",
|
|
" required: false",
|
|
"",
|
|
"concurrency:",
|
|
" group: cd-${{ github.workflow }}-${{ github.ref }}",
|
|
" cancel-in-progress: ${{ github.ref_name != 'main' }}",
|
|
"",
|
|
"permissions:",
|
|
" contents: read",
|
|
" packages: write",
|
|
"",
|
|
"env:",
|
|
" REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || 'ghcr.io' }}",
|
|
" IMAGE: ${{ vars.REGISTRY_IMAGE != '' && vars.REGISTRY_IMAGE || format('ghcr.io/{0}', github.repository) }}",
|
|
" DOCKERFILE: ${{ vars.DOCKERFILE != '' && vars.DOCKERFILE || 'docker/Dockerfile' }}",
|
|
" PHP_VERSION: \"8.3\"",
|
|
"",
|
|
"jobs:",
|
|
" preflight:",
|
|
" name: Preflight",
|
|
" runs-on: ubuntu-latest",
|
|
" outputs:",
|
|
" is_shop: ${{ steps.check.outputs.is_shop }}",
|
|
" steps:",
|
|
" - uses: actions/checkout@v4",
|
|
" - id: check",
|
|
" run: |",
|
|
" set -euo pipefail",
|
|
" is_shop=false",
|
|
" if [ -f composer.json ] && grep -q '\"name\": *\"fyrst/shopware-cd\"' composer.json; then",
|
|
" echo \"fyrst/shopware-cd package \u2014 skip shop CD.\"",
|
|
" elif [ -f bin/console ]; then",
|
|
" is_shop=true",
|
|
" elif [ -f composer.json ] && grep -Eq '\"shopware/' composer.json; then",
|
|
" is_shop=true",
|
|
" else",
|
|
" echo \"Not a Shopware shop \u2014 skip CD.\"",
|
|
" fi",
|
|
" echo \"is_shop=${is_shop}\" >> \"$GITHUB_OUTPUT\"",
|
|
"",
|
|
" build:",
|
|
" name: Build",
|
|
" needs: [preflight]",
|
|
" if: needs.preflight.outputs.is_shop == 'true'",
|
|
" runs-on: ubuntu-latest",
|
|
" timeout-minutes: 60",
|
|
" steps:",
|
|
" - name: Checkout",
|
|
" uses: actions/checkout@v4",
|
|
"",
|
|
" - name: Set up Docker Buildx",
|
|
" uses: docker/setup-buildx-action@v3",
|
|
"",
|
|
" - name: Log in to registry",
|
|
" if: github.event_name != 'pull_request'",
|
|
" uses: docker/login-action@v3",
|
|
" with:",
|
|
" registry: ${{ env.REGISTRY }}",
|
|
" username: ${{ secrets.REGISTRY_USERNAME || github.actor }}",
|
|
" password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}",
|
|
"",
|
|
" - name: Write Composer auth.json",
|
|
" env:",
|
|
" COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}",
|
|
" run: |",
|
|
" if [ -n \"$COMPOSER_AUTH\" ]; then",
|
|
" printf '%s\\n' \"$COMPOSER_AUTH\" > auth.json",
|
|
" else",
|
|
" printf '%s\\n' '{}' > auth.json",
|
|
" fi",
|
|
"",
|
|
" - name: Build and push :sha",
|
|
" uses: docker/build-push-action@v6",
|
|
" with:",
|
|
" context: .",
|
|
" file: ${{ env.DOCKERFILE }}",
|
|
" push: ${{ github.event_name != 'pull_request' }}",
|
|
" tags: ${{ env.IMAGE }}:${{ github.sha }}",
|
|
" platforms: linux/amd64",
|
|
" provenance: false",
|
|
" build-args: |",
|
|
" PHP_VERSION=${{ env.PHP_VERSION }}",
|
|
" secrets: |",
|
|
" packages_token=${{ secrets.SHOPWARE_PACKAGES_TOKEN }}",
|
|
" secret-files: |",
|
|
" composer_auth=auth.json",
|
|
" cache-from: type=gha",
|
|
" cache-to: type=gha,mode=max",
|
|
"",
|
|
" push:",
|
|
" name: Push",
|
|
" needs: [preflight, build]",
|
|
" if: needs.preflight.outputs.is_shop == 'true' && github.event_name != 'pull_request'",
|
|
" runs-on: ubuntu-latest",
|
|
" timeout-minutes: 15",
|
|
" steps:",
|
|
" - name: Log in to registry",
|
|
" uses: docker/login-action@v3",
|
|
" with:",
|
|
" registry: ${{ env.REGISTRY }}",
|
|
" username: ${{ secrets.REGISTRY_USERNAME || github.actor }}",
|
|
" password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}",
|
|
"",
|
|
" - name: Set up Docker Buildx",
|
|
" uses: docker/setup-buildx-action@v3",
|
|
"",
|
|
" - name: Tag :latest on main",
|
|
" if: github.ref == 'refs/heads/main'",
|
|
" run: docker buildx imagetools create -t \"${IMAGE}:latest\" \"${IMAGE}:${GITHUB_SHA}\"",
|
|
"",
|
|
" - name: Tag :semver on v* tags",
|
|
" if: startsWith(github.ref, 'refs/tags/v')",
|
|
" run: |",
|
|
" set -euo pipefail",
|
|
" VERSION=\"${GITHUB_REF_NAME#v}\"",
|
|
" docker buildx imagetools create -t \"${IMAGE}:${VERSION}\" \"${IMAGE}:${GITHUB_SHA}\"",
|
|
" MAJOR_MINOR=\"${VERSION%.*}\"",
|
|
" docker buildx imagetools create -t \"${IMAGE}:${MAJOR_MINOR}\" \"${IMAGE}:${GITHUB_SHA}\"",
|
|
"",
|
|
" deploy:",
|
|
" name: Deploy (Compose / VPS)",
|
|
" needs: [preflight, push]",
|
|
" if: >",
|
|
" needs.preflight.outputs.is_shop == 'true' &&",
|
|
" github.event_name != 'pull_request' &&",
|
|
" (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) &&",
|
|
" vars.DEPLOY_TARGET != 'managed'",
|
|
" runs-on: ubuntu-latest",
|
|
" timeout-minutes: 30",
|
|
" # TODO: rename or remove if the shop does not use GitHub Environments",
|
|
" environment: production",
|
|
" env:",
|
|
" IMAGE_TAG: ${{ github.event.inputs.image_tag || github.sha }}",
|
|
" VPS_HOST: ${{ secrets.VPS_HOST }}",
|
|
" VPS_USER: ${{ secrets.VPS_USER }}",
|
|
" VPS_PATH: ${{ secrets.VPS_PATH }}",
|
|
" VPS_SSH_PORT: ${{ vars.VPS_SSH_PORT != '' && vars.VPS_SSH_PORT || '22' }}",
|
|
" SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}",
|
|
" SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}",
|
|
" SMOKE_URL: ${{ secrets.SMOKE_URL }}",
|
|
" steps:",
|
|
" - name: Deploy over SSH",
|
|
" run: |",
|
|
" set -euo pipefail",
|
|
" : \"${VPS_HOST:?Set secret VPS_HOST}\"",
|
|
" : \"${VPS_USER:?Set secret VPS_USER}\"",
|
|
" : \"${VPS_PATH:?Set secret VPS_PATH}\"",
|
|
" : \"${SSH_PRIVATE_KEY:?Set secret SSH_PRIVATE_KEY}\"",
|
|
"",
|
|
" mkdir -p ~/.ssh",
|
|
" chmod 700 ~/.ssh",
|
|
" printf '%s\\n' \"$SSH_PRIVATE_KEY\" > ~/.ssh/id_deploy",
|
|
" chmod 600 ~/.ssh/id_deploy",
|
|
"",
|
|
" if [ -n \"${SSH_KNOWN_HOSTS:-}\" ]; then",
|
|
" printf '%s\\n' \"$SSH_KNOWN_HOSTS\" > ~/.ssh/known_hosts",
|
|
" chmod 600 ~/.ssh/known_hosts",
|
|
" else",
|
|
" echo \"SSH_KNOWN_HOSTS unset; using ssh-keyscan (TODO: pin known_hosts)\" >&2",
|
|
" ssh-keyscan -p \"$VPS_SSH_PORT\" -H \"$VPS_HOST\" >> ~/.ssh/known_hosts",
|
|
" fi",
|
|
"",
|
|
" ssh -i ~/.ssh/id_deploy -p \"$VPS_SSH_PORT\" -o IdentitiesOnly=yes \\",
|
|
" \"${VPS_USER}@${VPS_HOST}\" \\",
|
|
" \"set -euo pipefail",
|
|
" cd '${VPS_PATH}'",
|
|
" git fetch --quiet origin",
|
|
" git checkout --detach '${IMAGE_TAG}'",
|
|
" export IMAGE='${IMAGE}' IMAGE_TAG='${IMAGE_TAG}' COMPOSE_DIR='${VPS_PATH}' SMOKE_URL='${SMOKE_URL:-}'",
|
|
" bash ./deploy/vps-release.sh\"",
|
|
"",
|
|
" - name: Optional CI-side smoke",
|
|
" if: env.SMOKE_URL != ''",
|
|
" run: |",
|
|
" set -euo pipefail",
|
|
" for i in $(seq 1 20); do",
|
|
" if curl -fsS \"$SMOKE_URL\" >/dev/null; then",
|
|
" echo \"Smoke OK\"",
|
|
" exit 0",
|
|
" fi",
|
|
" sleep 3",
|
|
" done",
|
|
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
|
|
" exit 1",
|
|
"",
|
|
" deploy_managed:",
|
|
" name: Deploy (managed host)",
|
|
" needs: [preflight, push]",
|
|
" if: >",
|
|
" needs.preflight.outputs.is_shop == 'true' &&",
|
|
" github.event_name != 'pull_request' &&",
|
|
" (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) &&",
|
|
" vars.DEPLOY_TARGET == 'managed'",
|
|
" runs-on: ubuntu-latest",
|
|
" timeout-minutes: 15",
|
|
" environment: production",
|
|
" steps:",
|
|
" - name: Managed deploy stub",
|
|
" env:",
|
|
" IMAGE_TAG: ${{ github.event.inputs.image_tag || github.sha }}",
|
|
" run: |",
|
|
" echo \"DEPLOY_TARGET=managed \u2014 same image, different last mile.\"",
|
|
" echo \"Image: ${IMAGE}:${IMAGE_TAG}\"",
|
|
" echo \"Replace this job with the host CLI/API. See deploy/managed/README.md.\"",
|
|
" echo \"Setup command stays:\"",
|
|
" echo \" vendor/bin/shopware-deployment-helper run --skip-theme-compile --skip-assets-install\"",
|
|
" exit 1",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/.gitlab-ci.yaml": {
|
|
"contents": [
|
|
"# fyrst.dev Shopware CD \u2014 GitLab CI",
|
|
"#",
|
|
"# Stages (same as .github/workflows/cd.yaml): build \u2192 push extra tags \u2192 deploy.",
|
|
"# shopware-cli runs only inside docker/Dockerfile (shopware-cli image), not on the runner.",
|
|
"#",
|
|
"# Copied into shops by the Symfony Flex recipe (`copy-from-recipe` \u2192 shop root).",
|
|
"# Workflow rules require `bin/console` so this Packagist library never runs",
|
|
"# shop CD (it has composer.json but no Shopware console).",
|
|
"#",
|
|
"# GitLab still looks for `.gitlab-ci.yml` by default. After Flex copies this file,",
|
|
"# set Settings \u2192 CI/CD \u2192 CI/CD configuration file to `.gitlab-ci.yaml`.",
|
|
"#",
|
|
"# ---------------------------------------------------------------------------",
|
|
"# CI/CD variables (Settings \u2192 CI/CD \u2192 Variables). Mask tokens. Never commit them.",
|
|
"# ---------------------------------------------------------------------------",
|
|
"# SHOPWARE_PACKAGES_TOKEN packages.shopware.com (BuildKit secret packages_token)",
|
|
"# COMPOSER_AUTH optional JSON for private Composer repos",
|
|
"#",
|
|
"# Registry \u2014 GitLab Container Registry is the default (CI_REGISTRY_*).",
|
|
"# For GHCR or another registry, set:",
|
|
"# REGISTRY e.g. ghcr.io",
|
|
"# REGISTRY_IMAGE e.g. ghcr.io/fyrst-dev/shop-name",
|
|
"# REGISTRY_USERNAME",
|
|
"# REGISTRY_PASSWORD",
|
|
"# DOCKERFILE default docker/Dockerfile (from shopware/docker)",
|
|
"#",
|
|
"# Compose / VPS (DEPLOY_TARGET empty or \"compose\"):",
|
|
"# SSH_PRIVATE_KEY file or masked variable (PEM)",
|
|
"# VPS_HOST",
|
|
"# VPS_USER",
|
|
"# VPS_PATH",
|
|
"# SSH_KNOWN_HOSTS recommended",
|
|
"# VPS_SSH_PORT optional, default 22",
|
|
"# Compose files (via deploy/vps-release.sh, --project-directory = shop root):",
|
|
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
|
|
"#",
|
|
"# Optional:",
|
|
"# DEPLOY_TARGET \"managed\" skips SSH Compose (see deploy/managed/README.md)",
|
|
"# SMOKE_URL",
|
|
"#",
|
|
"# Runner: build uses Docker-in-Docker (privileged). Swap to kaniko/buildah",
|
|
"# if the runner cannot run dind \u2014 keep the same docker/Dockerfile and tags.",
|
|
"",
|
|
"workflow:",
|
|
" rules:",
|
|
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
|
|
" exists:",
|
|
" - bin/console",
|
|
" - if: $CI_COMMIT_TAG",
|
|
" exists:",
|
|
" - bin/console",
|
|
" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS",
|
|
" when: never",
|
|
" - if: $CI_COMMIT_BRANCH",
|
|
" exists:",
|
|
" - bin/console",
|
|
"",
|
|
"stages:",
|
|
" - build",
|
|
" - push",
|
|
" - deploy",
|
|
"",
|
|
"default:",
|
|
" interruptible: true",
|
|
"",
|
|
"variables:",
|
|
" DOCKER_TLS_CERTDIR: \"/certs\"",
|
|
" DOCKER_BUILDKIT: \"1\"",
|
|
" PHP_VERSION: \"8.3\"",
|
|
" DOCKERFILE: \"docker/Dockerfile\"",
|
|
"",
|
|
".registry_env:",
|
|
" before_script:",
|
|
" - |",
|
|
" set -eu",
|
|
" export REGISTRY=\"${REGISTRY:-${CI_REGISTRY:-}}\"",
|
|
" export IMAGE=\"${REGISTRY_IMAGE:-${CI_REGISTRY_IMAGE}}\"",
|
|
" export REGISTRY_USERNAME=\"${REGISTRY_USERNAME:-${CI_REGISTRY_USER:-}}\"",
|
|
" export REGISTRY_PASSWORD=\"${REGISTRY_PASSWORD:-${CI_REGISTRY_PASSWORD:-}}\"",
|
|
" export DOCKERFILE=\"${DOCKERFILE:-docker/Dockerfile}\"",
|
|
" export SHOPWARE_PACKAGES_TOKEN=\"${SHOPWARE_PACKAGES_TOKEN:-}\"",
|
|
" : \"${IMAGE:?Set REGISTRY_IMAGE or use GitLab Container Registry}\"",
|
|
"",
|
|
"build:",
|
|
" stage: build",
|
|
" image: docker:27",
|
|
" services:",
|
|
" - docker:27-dind",
|
|
" timeout: 60m",
|
|
" needs: []",
|
|
" rules:",
|
|
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
|
|
" - if: $CI_COMMIT_BRANCH",
|
|
" - if: $CI_COMMIT_TAG",
|
|
" before_script:",
|
|
" - !reference [.registry_env, before_script]",
|
|
" - docker info",
|
|
" - |",
|
|
" if [ -n \"${REGISTRY:-}\" ] && [ \"${CI_PIPELINE_SOURCE}\" != \"merge_request_event\" ]; then",
|
|
" echo \"$REGISTRY_PASSWORD\" | docker login -u \"$REGISTRY_USERNAME\" --password-stdin \"$REGISTRY\"",
|
|
" fi",
|
|
" - docker buildx create --use --name fyrst-ci --driver docker-container || docker buildx use fyrst-ci",
|
|
" - docker buildx inspect --bootstrap",
|
|
" - |",
|
|
" if [ -n \"${COMPOSER_AUTH:-}\" ]; then",
|
|
" printf '%s\\n' \"$COMPOSER_AUTH\" > auth.json",
|
|
" else",
|
|
" printf '%s\\n' '{}' > auth.json",
|
|
" fi",
|
|
" script:",
|
|
" - |",
|
|
" set -eu",
|
|
" PUSH_FLAG=\"\"",
|
|
" CACHE_TO=\"\"",
|
|
" if [ \"$CI_PIPELINE_SOURCE\" != \"merge_request_event\" ]; then",
|
|
" PUSH_FLAG=\"--push\"",
|
|
" CACHE_TO=\"--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max\"",
|
|
" fi",
|
|
" docker buildx build \\",
|
|
" --platform linux/amd64 \\",
|
|
" --file \"$DOCKERFILE\" \\",
|
|
" --build-arg PHP_VERSION=\"$PHP_VERSION\" \\",
|
|
" --secret id=packages_token,env=SHOPWARE_PACKAGES_TOKEN \\",
|
|
" --secret id=composer_auth,src=auth.json \\",
|
|
" --tag \"${IMAGE}:${CI_COMMIT_SHA}\" \\",
|
|
" --cache-from \"type=registry,ref=${IMAGE}:buildcache\" \\",
|
|
" $CACHE_TO \\",
|
|
" $PUSH_FLAG \\",
|
|
" .",
|
|
" after_script:",
|
|
" - rm -f auth.json",
|
|
"",
|
|
"push:",
|
|
" stage: push",
|
|
" image: docker:27",
|
|
" services:",
|
|
" - docker:27-dind",
|
|
" timeout: 15m",
|
|
" needs: [\"build\"]",
|
|
" rules:",
|
|
" - if: $CI_PIPELINE_SOURCE == \"merge_request_event\"",
|
|
" when: never",
|
|
" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
|
|
" - if: $CI_COMMIT_TAG",
|
|
" before_script:",
|
|
" - !reference [.registry_env, before_script]",
|
|
" - echo \"$REGISTRY_PASSWORD\" | docker login -u \"$REGISTRY_USERNAME\" --password-stdin \"$REGISTRY\"",
|
|
" - docker buildx create --use --name fyrst-ci --driver docker-container || docker buildx use fyrst-ci",
|
|
" script:",
|
|
" - |",
|
|
" set -eu",
|
|
" if [ \"${CI_COMMIT_BRANCH:-}\" = \"$CI_DEFAULT_BRANCH\" ]; then",
|
|
" docker buildx imagetools create -t \"${IMAGE}:latest\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
|
|
" fi",
|
|
" if [ -n \"${CI_COMMIT_TAG:-}\" ]; then",
|
|
" VERSION=\"${CI_COMMIT_TAG#v}\"",
|
|
" docker buildx imagetools create -t \"${IMAGE}:${VERSION}\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
|
|
" MAJOR_MINOR=\"${VERSION%.*}\"",
|
|
" docker buildx imagetools create -t \"${IMAGE}:${MAJOR_MINOR}\" \"${IMAGE}:${CI_COMMIT_SHA}\"",
|
|
" fi",
|
|
"",
|
|
"deploy:",
|
|
" stage: deploy",
|
|
" image: alpine:3.21",
|
|
" interruptible: false",
|
|
" timeout: 30m",
|
|
" needs: [\"push\"]",
|
|
" resource_group: production",
|
|
" rules:",
|
|
" - if: $DEPLOY_TARGET == \"managed\"",
|
|
" when: never",
|
|
" - if: $CI_COMMIT_TAG",
|
|
" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
|
|
" before_script:",
|
|
" - !reference [.registry_env, before_script]",
|
|
" - apk add --no-cache openssh-client bash",
|
|
" - |",
|
|
" set -eu",
|
|
" : \"${VPS_HOST:?Set CI variable VPS_HOST}\"",
|
|
" : \"${VPS_USER:?Set CI variable VPS_USER}\"",
|
|
" : \"${VPS_PATH:?Set CI variable VPS_PATH}\"",
|
|
" : \"${SSH_PRIVATE_KEY:?Set CI variable SSH_PRIVATE_KEY}\"",
|
|
" mkdir -p ~/.ssh",
|
|
" chmod 700 ~/.ssh",
|
|
" printf '%s\\n' \"$SSH_PRIVATE_KEY\" > ~/.ssh/id_deploy",
|
|
" chmod 600 ~/.ssh/id_deploy",
|
|
" PORT=\"${VPS_SSH_PORT:-22}\"",
|
|
" if [ -n \"${SSH_KNOWN_HOSTS:-}\" ]; then",
|
|
" printf '%s\\n' \"$SSH_KNOWN_HOSTS\" > ~/.ssh/known_hosts",
|
|
" chmod 600 ~/.ssh/known_hosts",
|
|
" else",
|
|
" echo \"SSH_KNOWN_HOSTS unset; using ssh-keyscan (TODO: pin known_hosts)\" >&2",
|
|
" ssh-keyscan -p \"$PORT\" -H \"$VPS_HOST\" >> ~/.ssh/known_hosts",
|
|
" fi",
|
|
" script:",
|
|
" - |",
|
|
" set -euo pipefail",
|
|
" PORT=\"${VPS_SSH_PORT:-22}\"",
|
|
" IMAGE_TAG=\"${CI_COMMIT_SHA}\"",
|
|
" ssh -i ~/.ssh/id_deploy -p \"$PORT\" -o IdentitiesOnly=yes \\",
|
|
" \"${VPS_USER}@${VPS_HOST}\" \\",
|
|
" \"set -euo pipefail",
|
|
" cd '${VPS_PATH}'",
|
|
" git fetch --quiet origin",
|
|
" git checkout --detach '${CI_COMMIT_SHA}'",
|
|
" export IMAGE='${IMAGE}' IMAGE_TAG='${IMAGE_TAG}' COMPOSE_DIR='${VPS_PATH}' SMOKE_URL='${SMOKE_URL:-}'",
|
|
" bash ./deploy/vps-release.sh\"",
|
|
" - |",
|
|
" if [ -n \"${SMOKE_URL:-}\" ]; then",
|
|
" apk add --no-cache curl",
|
|
" for i in $(seq 1 20); do",
|
|
" if curl -fsS \"$SMOKE_URL\" >/dev/null; then",
|
|
" echo \"Smoke OK\"",
|
|
" exit 0",
|
|
" fi",
|
|
" sleep 3",
|
|
" done",
|
|
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
|
|
" exit 1",
|
|
" fi",
|
|
"",
|
|
"deploy_managed:",
|
|
" stage: deploy",
|
|
" image: alpine:3.21",
|
|
" interruptible: false",
|
|
" timeout: 15m",
|
|
" needs: [\"push\"]",
|
|
" resource_group: production",
|
|
" rules:",
|
|
" - if: $DEPLOY_TARGET == \"managed\" && $CI_COMMIT_TAG",
|
|
" - if: $DEPLOY_TARGET == \"managed\" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
|
|
" before_script:",
|
|
" - !reference [.registry_env, before_script]",
|
|
" script:",
|
|
" - |",
|
|
" echo \"DEPLOY_TARGET=managed \u2014 same image, different last mile.\"",
|
|
" echo \"Image: ${IMAGE}:${CI_COMMIT_SHA}\"",
|
|
" echo \"Replace this job with the host CLI/API. See deploy/managed/README.md.\"",
|
|
" echo \"Setup command stays:\"",
|
|
" echo \" vendor/bin/shopware-deployment-helper run --skip-theme-compile --skip-assets-install\"",
|
|
" exit 1",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/README.md": {
|
|
"contents": [
|
|
"# fyrst.dev \u2014 primary deploy: Docker Compose on a VPS",
|
|
"#",
|
|
"# Locked process: https://app.clickup.com/90151931897/docs/2kyqjkzt-915",
|
|
"# Image is built in CI (`shopware-cli project ci`). This host only pulls and runs it.",
|
|
"",
|
|
"## Model",
|
|
"",
|
|
"- **web** \u2014 Shopware image (`ghcr.io/shopware/docker-base` + project artifact), port 8000",
|
|
"- **setup** \u2014 one-shot `shopware-deployment-helper` (profile `setup`)",
|
|
"- **mysql** \u2014 bundled in Compose, or delete the service and point `DATABASE_URL` at DBaaS",
|
|
"- **redis** / **worker** / **scheduler** \u2014 optional Compose profiles",
|
|
"",
|
|
"## One-time VPS bootstrap",
|
|
"",
|
|
"1. Install Docker Engine + Compose plugin. Do not install Shopware or PHP on the host.",
|
|
"2. Checkout this shop repo (read-only deploy key) to a path such as `/opt/shopware/<shop>`.",
|
|
" That path is `VPS_PATH` in CI.",
|
|
"3. Copy `.env.example` \u2192 `.env` and fill runtime secrets. `chmod 600 .env`.",
|
|
"4. Create `.env.prod` (may be empty) so `deploy/compose.prod.yaml` can mount it.",
|
|
"5. Set `IMAGE` to the registry repository CI pushes (example: `ghcr.io/fyrst-dev/shop-name`).",
|
|
"6. `docker login` to that registry on the VPS (or use a credential helper / `~/.docker/config.json`).",
|
|
"7. Put a reverse proxy in front of `HTTP_PORT` (TLS). Do not expose MySQL.",
|
|
"8. Store the previous image tag for rollback (the release script writes `.deployed-tag` / `.previous-tag`).",
|
|
"9. Runtime media/files bind-mount from `SHOPWARE_DATA_ROOT` (default `/var/lib/shopware/data`). Docker creates `{files,media,thumbnail,theme,sitemap}` on first up; `init-perm` chowns those dirs to uid 82.",
|
|
"",
|
|
"## CD sequence (what CI runs)",
|
|
"",
|
|
"`deploy/vps-release.sh` (from the checkout at `VPS_PATH`):",
|
|
"",
|
|
"1. Record the currently deployed tag as `.previous-tag`",
|
|
"2. `docker compose \u2026 pull` the new `:git-sha`",
|
|
"3. Start bundled `mysql` (if present) and optional profiles",
|
|
"4. Run setup **once**:",
|
|
"",
|
|
" ```bash",
|
|
" vendor/bin/shopware-deployment-helper run \\",
|
|
" --skip-theme-compile \\",
|
|
" --skip-assets-install",
|
|
" ```",
|
|
"",
|
|
" (via `docker compose --profile setup run --rm setup`)",
|
|
"5. Recreate `web` with `--no-build`",
|
|
"6. Optional `SMOKE_URL` check",
|
|
"",
|
|
"Manual equivalent:",
|
|
"",
|
|
"```bash",
|
|
"export IMAGE=ghcr.io/example-org/shop-name # TODO",
|
|
"export IMAGE_TAG=<full-git-sha>",
|
|
"",
|
|
"cd /opt/shopware/<shop> # TODO: VPS_PATH",
|
|
"git fetch --quiet origin",
|
|
"git checkout --quiet \"$IMAGE_TAG\"",
|
|
"",
|
|
"bash ./deploy/vps-release.sh",
|
|
"```",
|
|
"",
|
|
"Compose files used (from the shop root, with `--project-directory .`):",
|
|
"",
|
|
"- `deploy/compose.yaml`",
|
|
"- `deploy/compose.prod.yaml`",
|
|
"- `deploy/compose.vps.yaml`",
|
|
"- `deploy/sync-runtime.sh` / `deploy/sync.env.example` / `deploy/sync-runtime.md` \u2014 live \u2192 lower runtime copy (no S3)",
|
|
"",
|
|
"shopware-cli project create owns shop-root `compose.yaml` (local). Do not point CD at that file.",
|
|
"",
|
|
"## Why skip theme/assets on deploy",
|
|
"",
|
|
"`shopware-cli project ci` already compiled them into the image. Rebuilding on the VPS is an anti-pattern (time + drift).",
|
|
"",
|
|
"## Fresh install vs update",
|
|
"",
|
|
"The helper detects a fresh database vs an existing shop:",
|
|
"",
|
|
"- **Fresh:** schema, admin user from `INSTALL_ADMIN_*`, sales channel from `APP_URL` / `SALES_CHANNEL_URL`, extensions",
|
|
"- **Update:** migrations when the Shopware version changed, extension sync, hooks",
|
|
"",
|
|
"## Rollback",
|
|
"",
|
|
"```bash",
|
|
"export IMAGE_TAG=$(cat .previous-tag)",
|
|
"bash ./deploy/vps-release.sh",
|
|
"```",
|
|
"",
|
|
"Keep the previous image physically on the host (`docker image prune` with care).",
|
|
"",
|
|
"## Runtime data sync (VPS, no S3)",
|
|
"",
|
|
"DB + media/files are **not** in git and **not** in the app image. They live in MySQL and in **bind-mounted host directories** (default `/var/lib/shopware/data/{files,media,thumbnail,theme,sitemap}`). Set `SHOPWARE_DATA_ROOT` in shop-root `.env` if several shops share a VPS. `mysql_data` / `redis_data` stay named volumes (copy the database with mysqldump, not `mysql_data`).",
|
|
"",
|
|
"`deploy/sync-runtime.sh` copies that runtime data **live \u2192 lower** (staging / playground / dev) with **SSH + mysqldump + rsync of those host dirs**. Volume tars are only a fallback (no rsync, or a leftover named volume). There is no S3/MinIO path in this recipe.",
|
|
"",
|
|
"See **[sync-runtime.md](sync-runtime.md)** for flags, cron, and safety.",
|
|
"",
|
|
"| Command | What it does |",
|
|
"| --- | --- |",
|
|
"| `sync --from live` | Pull dump + bind-mount dirs from a higher env onto **this** host |",
|
|
"| `snapshot` | Write a local snapshot under `SYNC_SNAPSHOT_DIR` (rsync of host dirs) |",
|
|
"| `restore --snapshot <id>` | Restore a local snapshot onto this host |",
|
|
"",
|
|
"**Direction:** run `sync` on the consumer (cron on staging). Never auto-push into live. `SYNC_ENV=live` refuses `sync`.",
|
|
"",
|
|
"```bash",
|
|
"# on staging",
|
|
"cd /opt/shopware/<shop>",
|
|
"cp deploy/sync.env.example deploy/sync.env # set SYNC_SSH_* , SYNC_ENV=staging",
|
|
"chmod 600 deploy/sync.env",
|
|
"",
|
|
"bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"```",
|
|
"",
|
|
"`--data all` (default) is DB + bind-mount dirs; use `db` or `volumes` to limit. Both shops need this script in `deploy/` (Flex-update live as well as staging).",
|
|
"",
|
|
"Cron (staging):",
|
|
"",
|
|
"```cron",
|
|
"15 2 * * * cd /opt/shopware/staging && bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"```",
|
|
"",
|
|
"Local snapshot / restore (same host, still no S3):",
|
|
"",
|
|
"```bash",
|
|
"bash deploy/sync-runtime.sh snapshot --data all",
|
|
"bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
|
|
"```",
|
|
"",
|
|
"After a live DB lands on staging, `.env` is left alone. Optional `SYNC_REWRITE_FROM_URL` / `SYNC_REWRITE_TO_URL` rewrites `sales_channel_domain.url`. Then set staging `APP_URL` as usual.",
|
|
"",
|
|
"## Required CI secrets (Compose path)",
|
|
"",
|
|
"See comments at the top of `.github/workflows/cd.yaml` and `.gitlab-ci.yaml`.",
|
|
"",
|
|
"Typical: `SSH_PRIVATE_KEY`, `VPS_HOST`, `VPS_USER`, `VPS_PATH`, `SSH_KNOWN_HOSTS`.",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/compose.prod.yaml": {
|
|
"contents": [
|
|
"# Production / VPS overrides. Used as:",
|
|
"# docker compose --project-directory . \\",
|
|
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml ...",
|
|
"#",
|
|
"# Put secrets in `.env` (required) and optional extra keys in `.env.prod`.",
|
|
"# Create an empty `.env.prod` on the VPS if you do not need overrides.",
|
|
"# Never commit filled env files.",
|
|
"",
|
|
"services:",
|
|
" web:",
|
|
" restart: unless-stopped",
|
|
" env_file:",
|
|
" - .env",
|
|
" - .env.prod",
|
|
" ports:",
|
|
" # TODO: publish 8000 only on localhost and terminate TLS on a reverse proxy.",
|
|
" - \"${HTTP_PORT:-8000}:8000\"",
|
|
" deploy:",
|
|
" resources:",
|
|
" limits:",
|
|
" cpus: \"2\"",
|
|
" memory: 1G",
|
|
" reservations:",
|
|
" cpus: \"0.50\"",
|
|
" memory: 512M",
|
|
" logging:",
|
|
" driver: json-file",
|
|
" options:",
|
|
" max-size: \"10m\"",
|
|
" max-file: \"5\"",
|
|
"",
|
|
" mysql:",
|
|
" restart: unless-stopped",
|
|
" env_file:",
|
|
" - .env",
|
|
" - .env.prod",
|
|
" # Do not publish 3306 on a public interface.",
|
|
" ports: []",
|
|
" deploy:",
|
|
" resources:",
|
|
" limits:",
|
|
" cpus: \"1\"",
|
|
" memory: 1G",
|
|
" reservations:",
|
|
" memory: 256M",
|
|
"",
|
|
" redis:",
|
|
" restart: unless-stopped",
|
|
" deploy:",
|
|
" resources:",
|
|
" limits:",
|
|
" memory: 256M",
|
|
"",
|
|
" worker:",
|
|
" restart: unless-stopped",
|
|
" env_file:",
|
|
" - .env",
|
|
" - .env.prod",
|
|
" deploy:",
|
|
" replicas: 1",
|
|
" resources:",
|
|
" limits:",
|
|
" memory: 512M",
|
|
"",
|
|
" scheduler:",
|
|
" restart: unless-stopped",
|
|
" env_file:",
|
|
" - .env",
|
|
" - .env.prod",
|
|
"",
|
|
" setup:",
|
|
" env_file:",
|
|
" - .env",
|
|
" - .env.prod",
|
|
" restart: \"no\"",
|
|
"",
|
|
" init-perm:",
|
|
" restart: \"no\"",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/compose.vps.yaml": {
|
|
"contents": [
|
|
"# VPS-only Compose overlay. CI / deploy/vps-release.sh adds this file so the",
|
|
"# host never rebuilds the image (assets were compiled in CI).",
|
|
"#",
|
|
"# docker compose --project-directory . \\",
|
|
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml pull",
|
|
"# docker compose --project-directory . \\",
|
|
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml up -d --no-build",
|
|
"",
|
|
"services:",
|
|
" web:",
|
|
" image: ${IMAGE:?Set IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG to the git SHA}",
|
|
" pull_policy: always",
|
|
"",
|
|
" setup:",
|
|
" image: ${IMAGE}:${IMAGE_TAG}",
|
|
" pull_policy: always",
|
|
"",
|
|
" init-perm:",
|
|
" image: ${IMAGE}:${IMAGE_TAG}",
|
|
" pull_policy: always",
|
|
"",
|
|
" worker:",
|
|
" image: ${IMAGE}:${IMAGE_TAG}",
|
|
" pull_policy: always",
|
|
"",
|
|
" scheduler:",
|
|
" image: ${IMAGE}:${IMAGE_TAG}",
|
|
" pull_policy: always",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/compose.yaml": {
|
|
"contents": [
|
|
"# CD Compose stack for fyrst.dev Shopware shops (VPS).",
|
|
"# shopware-cli project create owns shop-root compose.yaml (local).",
|
|
"# Production overrides: deploy/compose.prod.yaml",
|
|
"# VPS pull/no-build: deploy/compose.vps.yaml",
|
|
"# Local-only tweaks: compose.override.yaml at the shop root (do not commit secrets)",
|
|
"#",
|
|
"# Copy .env.example \u2192 .env at the shop root and fill values. No real secrets belong here.",
|
|
"#",
|
|
"# From the shop root:",
|
|
"# docker compose --project-directory . \\",
|
|
"# -f deploy/compose.yaml -f deploy/compose.prod.yaml -f deploy/compose.vps.yaml ...",
|
|
"#",
|
|
"# Runtime files (media, theme, \u2026) are bind-mounted from",
|
|
"# ${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/{files,media,thumbnail,theme,sitemap}.",
|
|
"# mysql_data / redis_data stay named volumes. Set SHOPWARE_DATA_ROOT per shop if",
|
|
"# several stacks share a Docker host.",
|
|
"#",
|
|
"# Bundled MySQL: service name `mysql` (DATABASE_URL host = mysql).",
|
|
"# External DB: set DATABASE_URL to the DBaaS DSN, delete or comment out",
|
|
"# the `mysql` service, and drop it from depends_on.",
|
|
"#",
|
|
"# Optional profiles:",
|
|
"# redis \u2014 Redis 7",
|
|
"# setup \u2014 one-shot shopware-deployment-helper (CD / first install)",
|
|
"# worker \u2014 messenger:consume",
|
|
"# scheduler \u2014 scheduled-task:run",
|
|
"#",
|
|
"# Flex recipes cannot use YAML anchors; app services repeat image/build/mounts.",
|
|
"# TODO: set `name:` per shop if several stacks share a Docker host.",
|
|
"",
|
|
"name: shopware",
|
|
"",
|
|
"services:",
|
|
" web:",
|
|
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
|
|
" build:",
|
|
" context: .",
|
|
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
|
|
" args:",
|
|
" PHP_VERSION: ${PHP_VERSION:-8.3}",
|
|
" env_file:",
|
|
" - .env",
|
|
" volumes:",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/sitemap:/var/www/html/public/sitemap\"",
|
|
" ports:",
|
|
" - \"${HTTP_PORT:-8000}:8000\"",
|
|
" depends_on:",
|
|
" mysql:",
|
|
" condition: service_healthy",
|
|
" required: false",
|
|
" healthcheck:",
|
|
" test: [\"CMD-SHELL\", \"php -r 'exit(0);'\"]",
|
|
" interval: 10s",
|
|
" timeout: 5s",
|
|
" retries: 12",
|
|
" start_period: 30s",
|
|
"",
|
|
" # One-shot install/update. Never compiles themes/assets (those are in the image).",
|
|
" # docker compose --profile setup run --rm setup",
|
|
" setup:",
|
|
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
|
|
" build:",
|
|
" context: .",
|
|
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
|
|
" args:",
|
|
" PHP_VERSION: ${PHP_VERSION:-8.3}",
|
|
" env_file:",
|
|
" - .env",
|
|
" volumes:",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/sitemap:/var/www/html/public/sitemap\"",
|
|
" profiles: [\"setup\"]",
|
|
" restart: \"no\"",
|
|
" depends_on:",
|
|
" mysql:",
|
|
" condition: service_healthy",
|
|
" required: false",
|
|
" init-perm:",
|
|
" condition: service_completed_successfully",
|
|
" required: false",
|
|
" entrypoint:",
|
|
" [",
|
|
" \"php\",",
|
|
" \"vendor/bin/shopware-deployment-helper\",",
|
|
" \"run\",",
|
|
" \"--skip-theme-compile\",",
|
|
" \"--skip-assets-install\",",
|
|
" ]",
|
|
"",
|
|
" # Directory ownership for uid 82 (www-data in docker-base). Same profile as setup.",
|
|
" init-perm:",
|
|
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
|
|
" build:",
|
|
" context: .",
|
|
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
|
|
" args:",
|
|
" PHP_VERSION: ${PHP_VERSION:-8.3}",
|
|
" env_file:",
|
|
" - .env",
|
|
" volumes:",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/sitemap:/var/www/html/public/sitemap\"",
|
|
" profiles: [\"setup\"]",
|
|
" user: \"root\"",
|
|
" restart: \"no\"",
|
|
" entrypoint:",
|
|
" [",
|
|
" \"chown\",",
|
|
" \"82:82\",",
|
|
" \"/var/www/html/files\",",
|
|
" \"/var/www/html/public/theme\",",
|
|
" \"/var/www/html/public/media\",",
|
|
" \"/var/www/html/public/thumbnail\",",
|
|
" \"/var/www/html/public/sitemap\",",
|
|
" ]",
|
|
"",
|
|
" mysql:",
|
|
" image: mysql:8.4",
|
|
" # Shopware docs often use mariadb:11.4 \u2014 swap the image if you prefer MariaDB.",
|
|
" command:",
|
|
" - --character-set-server=utf8mb4",
|
|
" - --collation-server=utf8mb4_unicode_ci",
|
|
" env_file:",
|
|
" - .env",
|
|
" environment:",
|
|
" MYSQL_DATABASE: ${MYSQL_DATABASE:-shopware}",
|
|
" MYSQL_USER: ${MYSQL_USER:?Set MYSQL_USER in .env}",
|
|
" MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD in .env}",
|
|
" MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env}",
|
|
" volumes:",
|
|
" - mysql_data:/var/lib/mysql",
|
|
" healthcheck:",
|
|
" test: [\"CMD\", \"mysqladmin\", \"ping\", \"-h\", \"127.0.0.1\"]",
|
|
" interval: 5s",
|
|
" timeout: 5s",
|
|
" retries: 20",
|
|
" start_period: 20s",
|
|
"",
|
|
" redis:",
|
|
" image: redis:7-alpine",
|
|
" profiles: [\"redis\"]",
|
|
" command: [\"redis-server\", \"--appendonly\", \"yes\"]",
|
|
" volumes:",
|
|
" - redis_data:/data",
|
|
" healthcheck:",
|
|
" test: [\"CMD\", \"redis-cli\", \"ping\"]",
|
|
" interval: 5s",
|
|
" timeout: 3s",
|
|
" retries: 10",
|
|
"",
|
|
" worker:",
|
|
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
|
|
" build:",
|
|
" context: .",
|
|
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
|
|
" args:",
|
|
" PHP_VERSION: ${PHP_VERSION:-8.3}",
|
|
" env_file:",
|
|
" - .env",
|
|
" volumes:",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/sitemap:/var/www/html/public/sitemap\"",
|
|
" profiles: [\"worker\"]",
|
|
" depends_on:",
|
|
" mysql:",
|
|
" condition: service_healthy",
|
|
" required: false",
|
|
" entrypoint:",
|
|
" [",
|
|
" \"php\",",
|
|
" \"bin/console\",",
|
|
" \"messenger:consume\",",
|
|
" \"async\",",
|
|
" \"low_priority\",",
|
|
" \"--time-limit=300\",",
|
|
" \"--memory-limit=512M\",",
|
|
" ]",
|
|
"",
|
|
" scheduler:",
|
|
" image: ${IMAGE:?Set IMAGE in .env}:${IMAGE_TAG:-latest}",
|
|
" build:",
|
|
" context: .",
|
|
" dockerfile: ${DOCKERFILE:-docker/Dockerfile}",
|
|
" args:",
|
|
" PHP_VERSION: ${PHP_VERSION:-8.3}",
|
|
" env_file:",
|
|
" - .env",
|
|
" volumes:",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/files:/var/www/html/files\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/theme:/var/www/html/public/theme\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/media:/var/www/html/public/media\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/thumbnail:/var/www/html/public/thumbnail\"",
|
|
" - \"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/sitemap:/var/www/html/public/sitemap\"",
|
|
" profiles: [\"scheduler\"]",
|
|
" depends_on:",
|
|
" mysql:",
|
|
" condition: service_healthy",
|
|
" required: false",
|
|
" entrypoint: [\"php\", \"bin/console\", \"scheduled-task:run\"]",
|
|
"",
|
|
"volumes:",
|
|
" mysql_data:",
|
|
" redis_data:",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/managed/README.md": {
|
|
"contents": [
|
|
"# Optional deploy: managed container host",
|
|
"",
|
|
"Same Shopware **image** as the Compose/VPS path. Use `docker/Dockerfile` from `shopware/docker`. Do **not** add another Dockerfile.",
|
|
"",
|
|
"Locked process: [Shopware Create & Continuous Deploy](https://app.clickup.com/90151931897/docs/2kyqjkzt-915)",
|
|
"",
|
|
"## When to use",
|
|
"",
|
|
"The platform starts/restarts containers for you (managed Kubernetes-like runtimes, PaaS-style hosts, mittwald-style container hosting, etc.). CI still:",
|
|
"",
|
|
"1. Builds with `shopware-cli project ci` in the multi-stage `docker/Dockerfile`",
|
|
"2. Pushes `:sha` / `:latest` / `:semver`",
|
|
"3. Runs Deployment Helper as a one-shot/setup job against that image",
|
|
"",
|
|
"## What changes",
|
|
"",
|
|
"Only the **deploy job** (and maybe which registry you push to):",
|
|
"",
|
|
"- Push to the host\u2019s registry **or** let the host pull from yours",
|
|
"- Trigger their deploy API / CLI / UI instead of SSH + Compose",
|
|
"- Map runtime env (`APP_URL`, `DATABASE_URL`, `APP_SECRET`, `INSTALL_ADMIN_*`) in the host\u2019s secret store",
|
|
"",
|
|
"## CI switch",
|
|
"",
|
|
"Use **`DEPLOY_TARGET`** (repository variable, not a secret):",
|
|
"",
|
|
"| Value | Deploy job |",
|
|
"| --- | --- |",
|
|
"| unset / `compose` | Primary: SSH + Compose (`deploy/vps-release.sh`) |",
|
|
"| `managed` | Skip Compose SSH; run the managed job instead |",
|
|
"",
|
|
"(`DEPLY_TARGET` is a typo \u2014 do not use it.)",
|
|
"",
|
|
"GitHub: Actions variable `DEPLOY_TARGET`. GitLab: CI/CD variable `DEPLOY_TARGET`.",
|
|
"",
|
|
"## What to fill in per host (TODOs)",
|
|
"",
|
|
"- [ ] Registry URL the platform pulls from",
|
|
"- [ ] Deploy token / kubeconfig / host CLI credentials (CI secret)",
|
|
"- [ ] How to run the one-shot setup command with the **same** flags:",
|
|
"",
|
|
" ```bash",
|
|
" vendor/bin/shopware-deployment-helper run \\",
|
|
" --skip-theme-compile \\",
|
|
" --skip-assets-install",
|
|
" ```",
|
|
"",
|
|
"- [ ] Health/smoke URL after rollout",
|
|
"- [ ] Rollback: redeploy the previous `:sha` tag",
|
|
"",
|
|
"The managed jobs in `.github/workflows/cd.yaml` and `.gitlab-ci.yaml` are **stubs**: they fail with a clear message until you replace the script with the host\u2019s CLI. That is intentional \u2014 do not copy a fake happy-path.",
|
|
"",
|
|
"## Keep identical across hosts",
|
|
"",
|
|
"- `docker/Dockerfile` / `PHP_VERSION=8.3`",
|
|
"- `.shopware-project.yaml` (from `shopware-cli project create`, not this recipe)",
|
|
"- CD Compose under `deploy/` (`compose.yaml`, `compose.prod.yaml`, `compose.vps.yaml`)",
|
|
"- Image naming and tags",
|
|
"- Setup command (deployment helper + skip flags)",
|
|
"- Build-time secrets (`SHOPWARE_PACKAGES_TOKEN`, Composer auth)",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/sync-runtime.md": {
|
|
"contents": [
|
|
"# Runtime data sync (VPS, bind mounts, no object storage)",
|
|
"",
|
|
"Pull **database + Shopware runtime files** from another VPS onto this one. Typical direction: **live \u2192 staging / playground / dev**.",
|
|
"",
|
|
"This is **not** part of image CD. `deploy/vps-release.sh` is unchanged (pull image, setup helper, recreate `web`). Runtime files stay out of git and out of the Shopware app image (`/.dockerignore` already excludes `/deploy` and `/var`).",
|
|
"",
|
|
"Object storage (S3 and similar) is **out of scope** for this VPS path. Transfer is SSH + `mysqldump` + **rsync of host bind-mount directories**. Named-volume tars are only a fallback (no `rsync` on the consumer, or a leftover Docker volume from an older stack).",
|
|
"",
|
|
"## What is copied",
|
|
"",
|
|
"Default `--data all` (same as omitting `--data`):",
|
|
"",
|
|
"| Item | Mechanism |",
|
|
"| --- | --- |",
|
|
"| `db` | Logical SQL dump from the bundled compose `mysql` service (or `DATABASE_URL`) |",
|
|
"| `media` `files` `thumbnail` `theme` `sitemap` | Bind mounts under `${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/<name>` |",
|
|
"",
|
|
"Not copied: `mysql_data` / `redis_data` named volumes (use `db` for SQL; Redis is ephemeral for this recipe). Do not put dumps in git.",
|
|
"",
|
|
"Set `SHOPWARE_DATA_ROOT` in shop-root `.env` when several shops share a host. If the **source** uses a non-default root, set `SYNC_REMOTE_DATA_ROOT` or `SYNC_LIVE_DATA_ROOT` in `deploy/sync.env`.",
|
|
"",
|
|
"## Host packages",
|
|
"",
|
|
"On **every** VPS that snapshots or restores:",
|
|
"",
|
|
"- Docker Engine + Compose v2 plugin",
|
|
"- bash",
|
|
"- OpenSSH client",
|
|
"- gzip",
|
|
"- **rsync** (primary copy path for bind-mount dirs)",
|
|
"",
|
|
"The SSH user must be able to run `docker` (typically the `docker` group) so restore can `chown` uid 82 via a one-shot Alpine container.",
|
|
"",
|
|
"## One-time setup (consumer)",
|
|
"",
|
|
"On staging (or playground/dev), not on live:",
|
|
"",
|
|
"1. Copy `deploy/sync.env.example` \u2192 `deploy/sync.env` and `chmod 600 deploy/sync.env`.",
|
|
"2. Set `SYNC_ENV=staging` (or `playground` / `dev`). **Never** set `SYNC_ENV=live` on a host you restore onto.",
|
|
"3. Fill `SYNC_SSH_*` and `SYNC_SSH_PATH` for the source (live checkout, e.g. `/opt/shopware/live`).",
|
|
"4. Install an SSH key that can log in to live **without a passphrase** (cron). Pin `known_hosts`.",
|
|
"5. Confirm shop-root `.env` has `IMAGE` and `SHOPWARE_DATA_ROOT` (compose interpolation). Sync does not read secrets from the script itself.",
|
|
"",
|
|
"Do not commit `deploy/sync.env` (add it to the shop `.gitignore`; that file is owned by `shopware-cli project create`).",
|
|
"",
|
|
"## Commands",
|
|
"",
|
|
"Run from the **shop root** (the script `cd`s to the parent of `deploy/`):",
|
|
"",
|
|
"```bash",
|
|
"bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"bash deploy/sync-runtime.sh snapshot --data all",
|
|
"bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
|
|
"```",
|
|
"",
|
|
"| Flag | Meaning |",
|
|
"| --- | --- |",
|
|
"| `--from <env>` | Source for `sync` (e.g. `live`). Uses `SYNC_SSH_*` / `SYNC_LIVE_*` |",
|
|
"| `--data all\\|db\\|volumes` | Default `all`. `volumes` means the bind-mount dirs, not `mysql_data` |",
|
|
"| `--volume <name>` | Single dir for `export --data volumes` (`files`, `media`, \u2026) |",
|
|
"| `--snapshot <id>` | Snapshot directory name under `SYNC_SNAPSHOT_DIR` |",
|
|
"| `--yes` | Skip the overwrite prompt |",
|
|
"",
|
|
"### Cron (consumer)",
|
|
"",
|
|
"```cron",
|
|
"15 2 * * * cd /opt/shopware/staging && bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"```",
|
|
"",
|
|
"Overlapping runs are blocked with `flock`.",
|
|
"",
|
|
"## After restore",
|
|
"",
|
|
"- The script tries `bin/console cache:clear` via compose `web` and **does not fail** if that errors.",
|
|
"- Optional `SYNC_REWRITE_FROM_URL` / `SYNC_REWRITE_TO_URL` rewrites `sales_channel_domain.url`.",
|
|
"- Bind-mount dirs are `chown -R 82:82` after copy so `www-data` in the Shopware image can write.",
|
|
"",
|
|
"## Safety",
|
|
"",
|
|
"- `sync` **refuses** `SYNC_ENV=live` / `prod` / `production`. Convention is pull-only onto the lower env.",
|
|
"- `restore` onto live is refused unless `SYNC_ALLOW_LIVE_RESTORE=1` (disaster recovery).",
|
|
"- Dumps contain customer data: `umask` is not forced here; keep `SYNC_SNAPSHOT_DIR` mode `700` on the host.",
|
|
"",
|
|
"## Named-volume leftover",
|
|
"",
|
|
"If a host still has `shopware_media` (etc.) from an older recipe and the bind-mount directory is missing, snapshot/export will tar that named volume once. New stacks use bind mounts only; do not add `files`/`media`/\u2026 back as named volumes in `deploy/compose.yaml`.",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/sync-runtime.sh": {
|
|
"contents": [
|
|
"#!/usr/bin/env bash",
|
|
"# VPS runtime data: mysqldump + rsync of bind-mounted host dirs over SSH. No S3.",
|
|
"#",
|
|
"# Commands: sync | snapshot | restore",
|
|
"# Direction for sync: higher env \u2192 this host (live \u2192 staging / playground / dev).",
|
|
"# Never auto-pushes into live.",
|
|
"#",
|
|
"# Shopware files live on the host under",
|
|
"# ${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/{files,media,thumbnail,theme,sitemap}",
|
|
"# (compose bind mounts). mysql_data / redis_data stay named volumes and are not",
|
|
"# copied here (DB is mysqldump).",
|
|
"#",
|
|
"# Copy deploy/sync.env.example \u2192 deploy/sync.env on the consumer and fill SYNC_SSH_*.",
|
|
"#",
|
|
"# bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"# bash deploy/sync-runtime.sh snapshot --data all",
|
|
"# bash deploy/sync-runtime.sh restore --snapshot <id> --data all",
|
|
"#",
|
|
"# Plumbing (used over SSH on the source; stdout is the payload):",
|
|
"# bash deploy/sync-runtime.sh export --data db",
|
|
"# bash deploy/sync-runtime.sh export --data volumes --volume media",
|
|
"",
|
|
"set -euo pipefail",
|
|
"",
|
|
"usage() {",
|
|
" cat <<'EOF'",
|
|
"Usage: deploy/sync-runtime.sh <command> [options]",
|
|
"",
|
|
"Commands:",
|
|
" sync Pull DB + bind-mount dirs from a higher env onto this host",
|
|
" snapshot Write a local snapshot (mysqldump + rsync of host dirs)",
|
|
" restore Restore a local snapshot onto this host",
|
|
" export Plumbing: write a dump/tar to stdout (SSH fallback)",
|
|
"",
|
|
"Options:",
|
|
" --from <env> Source env for sync (e.g. live)",
|
|
" --data <what> all | db | volumes (default: all)",
|
|
" --volume <name> Single bind-mount dir (files|media|thumbnail|theme|sitemap)",
|
|
" --snapshot <id> Snapshot id for restore (directory name under SYNC_SNAPSHOT_DIR)",
|
|
" --yes Do not prompt",
|
|
" -h, --help",
|
|
"",
|
|
"Examples:",
|
|
" bash deploy/sync-runtime.sh sync --from live --data all",
|
|
" bash deploy/sync-runtime.sh snapshot --data all",
|
|
" bash deploy/sync-runtime.sh restore --snapshot 20260911T021500Z-live --data all",
|
|
"",
|
|
"No S3. Files stay on the VPS (rsync over SSH, or a local snapshot dir). Live",
|
|
"is never the destination of `sync`. Cron on the lower env (see",
|
|
"deploy/README.md and deploy/sync-runtime.md).",
|
|
"EOF",
|
|
"}",
|
|
"",
|
|
"log() { printf '==> %s\\n' \"$*\" >&2; }",
|
|
"die() { printf '%s\\n' \"$*\" >&2; exit 1; }",
|
|
"",
|
|
"COMMAND=\"\"",
|
|
"FROM_ENV=\"\"",
|
|
"DATA=\"all\"",
|
|
"VOLUME_KEY=\"\"",
|
|
"SNAPSHOT_ID=\"\"",
|
|
"YES=0",
|
|
"",
|
|
"while [[ $# -gt 0 ]]; do",
|
|
" case \"$1\" in",
|
|
" sync|snapshot|restore|export)",
|
|
" [[ -z \"$COMMAND\" ]] || die \"Multiple commands: $COMMAND and $1\"",
|
|
" COMMAND=\"$1\"",
|
|
" shift",
|
|
" ;;",
|
|
" --from)",
|
|
" FROM_ENV=\"${2:-}\"",
|
|
" [[ -n \"$FROM_ENV\" ]] || die \"--from requires an env name\"",
|
|
" shift 2",
|
|
" ;;",
|
|
" --data)",
|
|
" DATA=\"${2:-}\"",
|
|
" [[ -n \"$DATA\" ]] || die \"--data requires all|db|volumes\"",
|
|
" shift 2",
|
|
" ;;",
|
|
" --volume)",
|
|
" VOLUME_KEY=\"${2:-}\"",
|
|
" [[ -n \"$VOLUME_KEY\" ]] || die \"--volume requires a bind-mount dir name (files|media|thumbnail|theme|sitemap)\"",
|
|
" shift 2",
|
|
" ;;",
|
|
" --snapshot)",
|
|
" SNAPSHOT_ID=\"${2:-}\"",
|
|
" [[ -n \"$SNAPSHOT_ID\" ]] || die \"--snapshot requires an id\"",
|
|
" shift 2",
|
|
" ;;",
|
|
" --yes|-y)",
|
|
" YES=1",
|
|
" shift",
|
|
" ;;",
|
|
" -h|--help)",
|
|
" usage",
|
|
" exit 0",
|
|
" ;;",
|
|
" *)",
|
|
" die \"Unknown argument: $1\"",
|
|
" ;;",
|
|
" esac",
|
|
"done",
|
|
"",
|
|
"[[ -n \"$COMMAND\" ]] || { usage >&2; exit 1; }",
|
|
"",
|
|
"case \"$DATA\" in",
|
|
" all|db|volumes) ;;",
|
|
" *) die \"--data must be all, db, or volumes (got: $DATA)\" ;;",
|
|
"esac",
|
|
"",
|
|
"want_db() { [[ \"$DATA\" == all || \"$DATA\" == db ]]; }",
|
|
"want_volumes() { [[ \"$DATA\" == all || \"$DATA\" == volumes ]]; }",
|
|
"",
|
|
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"$(dirname \"$0\")/..\" && pwd)}\"",
|
|
"cd \"$COMPOSE_DIR\"",
|
|
"",
|
|
"CI_IMAGE=\"${IMAGE:-}\"",
|
|
"CI_IMAGE_TAG=\"${IMAGE_TAG:-}\"",
|
|
"CI_PROFILES=\"${COMPOSE_PROFILES:-}\"",
|
|
"",
|
|
"if [[ -f .env ]]; then",
|
|
" set -a",
|
|
" # shellcheck disable=SC1091",
|
|
" source .env",
|
|
" set +a",
|
|
"fi",
|
|
"if [[ -f .env.prod ]]; then",
|
|
" set -a",
|
|
" # shellcheck disable=SC1091",
|
|
" source .env.prod",
|
|
" set +a",
|
|
"fi",
|
|
"",
|
|
"IMAGE=\"${CI_IMAGE:-${IMAGE:-}}\"",
|
|
"IMAGE_TAG=\"${CI_IMAGE_TAG:-${IMAGE_TAG:-}}\"",
|
|
"COMPOSE_PROFILES=\"${CI_PROFILES:-${COMPOSE_PROFILES:-}}\"",
|
|
"export IMAGE IMAGE_TAG",
|
|
"",
|
|
"SYNC_ENV_FILE=\"${SYNC_ENV_FILE:-$COMPOSE_DIR/deploy/sync.env}\"",
|
|
"if [[ -f \"$SYNC_ENV_FILE\" ]]; then",
|
|
" set -a",
|
|
" # shellcheck disable=SC1091",
|
|
" source \"$SYNC_ENV_FILE\"",
|
|
" set +a",
|
|
"fi",
|
|
"",
|
|
"SYNC_COMPOSE_PROJECT=\"${SYNC_COMPOSE_PROJECT:-shopware}\"",
|
|
"SYNC_VOLUMES=\"${SYNC_VOLUMES:-files,media,thumbnail,theme,sitemap}\"",
|
|
"SHOPWARE_DATA_ROOT=\"${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}\"",
|
|
"SYNC_REMOTE_DATA_ROOT=\"${SYNC_REMOTE_DATA_ROOT:-/var/lib/shopware/data}\"",
|
|
"SYNC_SNAPSHOT_DIR=\"${SYNC_SNAPSHOT_DIR:-$COMPOSE_DIR/.runtime-snapshots}\"",
|
|
"SYNC_KEEP_SNAPSHOTS=\"${SYNC_KEEP_SNAPSHOTS:-5}\"",
|
|
"SYNC_SSH_PORT=\"${SYNC_SSH_PORT:-22}\"",
|
|
"",
|
|
"COMPOSE=(",
|
|
" docker compose",
|
|
" --project-directory \"$COMPOSE_DIR\"",
|
|
" -f deploy/compose.yaml",
|
|
" -f deploy/compose.prod.yaml",
|
|
" -f deploy/compose.vps.yaml",
|
|
")",
|
|
"",
|
|
"PROFILE_ARGS=()",
|
|
"IFS=',' read -ra RAW_PROFILES <<< \"${COMPOSE_PROFILES:-}\"",
|
|
"for p in \"${RAW_PROFILES[@]}\"; do",
|
|
" p=\"${p// /}\"",
|
|
" if [[ -z \"$p\" ]]; then",
|
|
" continue",
|
|
" fi",
|
|
" if [[ \"$p\" == \"setup\" ]]; then",
|
|
" continue",
|
|
" fi",
|
|
" PROFILE_ARGS+=(--profile \"$p\")",
|
|
"done",
|
|
"",
|
|
"has_service() {",
|
|
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" config --services 2>/dev/null | grep -qx \"$1\"",
|
|
"}",
|
|
"",
|
|
"project_name() {",
|
|
" local n",
|
|
" n=\"$(\"${COMPOSE[@]}\" config 2>/dev/null | sed -n 's/^name:[[:space:]]*//p' | head -n1 || true)\"",
|
|
" n=\"${n:-$SYNC_COMPOSE_PROJECT}\"",
|
|
" printf '%s\\n' \"${n:-shopware}\"",
|
|
"}",
|
|
"",
|
|
"volume_list() {",
|
|
" local raw=\"$SYNC_VOLUMES\" item",
|
|
" IFS=',' read -ra items <<< \"$raw\"",
|
|
" for item in \"${items[@]}\"; do",
|
|
" item=\"${item// /}\"",
|
|
" [[ -n \"$item\" ]] && printf '%s\\n' \"$item\"",
|
|
" done",
|
|
"}",
|
|
"",
|
|
"host_data_dir() {",
|
|
" printf '%s/%s\\n' \"$SHOPWARE_DATA_ROOT\" \"$1\"",
|
|
"}",
|
|
"",
|
|
"docker_volume_name() {",
|
|
" printf '%s_%s\\n' \"$(project_name)\" \"$1\"",
|
|
"}",
|
|
"",
|
|
"has_named_volume() {",
|
|
" docker volume inspect \"$(docker_volume_name \"$1\")\" >/dev/null 2>&1",
|
|
"}",
|
|
"",
|
|
"env_rank() {",
|
|
" case \"$1\" in",
|
|
" live|prod|production) echo 0 ;;",
|
|
" staging|stage) echo 1 ;;",
|
|
" playground|preview) echo 2 ;;",
|
|
" dev|local|development) echo 3 ;;",
|
|
" *) echo 10 ;;",
|
|
" esac",
|
|
"}",
|
|
"",
|
|
"is_live_env() {",
|
|
" case \"$1\" in",
|
|
" live|prod|production) return 0 ;;",
|
|
" *) return 1 ;;",
|
|
" esac",
|
|
"}",
|
|
"",
|
|
"confirm() {",
|
|
" local msg=\"$1\"",
|
|
" if [[ \"$YES\" -eq 1 || \"${SYNC_ASSUME_YES:-0}\" == 1 ]]; then",
|
|
" return 0",
|
|
" fi",
|
|
" if [[ ! -t 0 ]]; then",
|
|
" log \"$msg (non-interactive; continuing)\"",
|
|
" return 0",
|
|
" fi",
|
|
" local ans",
|
|
" read -r -p \"$msg [y/N] \" ans",
|
|
" [[ \"$ans\" == y || \"$ans\" == Y || \"$ans\" == yes ]]",
|
|
"}",
|
|
"",
|
|
"acquire_lock() {",
|
|
" local lock=\"/tmp/shopware-sync-runtime.lock\"",
|
|
" if command -v flock >/dev/null 2>&1; then",
|
|
" exec 9>\"$lock\"",
|
|
" if ! flock -n 9; then",
|
|
" die \"Another sync-runtime.sh is running ($lock)\"",
|
|
" fi",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"urldecode() {",
|
|
" local s=\"${1//+/ }\"",
|
|
" printf '%b' \"${s//%/\\\\x}\"",
|
|
"}",
|
|
"",
|
|
"# Sets DB_USER DB_PASS DB_HOST DB_PORT DB_NAME from DATABASE_URL (mysql://).",
|
|
"parse_database_url() {",
|
|
" local url=\"${DATABASE_URL:-}\"",
|
|
" [[ -n \"$url\" ]] || die \"DATABASE_URL is empty\"",
|
|
" url=\"${url#mysql://}\"",
|
|
" url=\"${url#mysqli://}\"",
|
|
" url=\"${url%%\\?*}\"",
|
|
" local cred hostpart",
|
|
" cred=\"${url%%@*}\"",
|
|
" hostpart=\"${url#*@}\"",
|
|
" if [[ \"$url\" == \"$cred\" ]]; then",
|
|
" die \"DATABASE_URL must look like mysql://USER:PASSWORD@HOST:3306/DATABASE\"",
|
|
" fi",
|
|
" DB_USER=\"${cred%%:*}\"",
|
|
" if [[ \"$cred\" == *:* ]]; then",
|
|
" DB_PASS=\"$(urldecode \"${cred#*:}\")\"",
|
|
" else",
|
|
" DB_PASS=\"\"",
|
|
" fi",
|
|
" DB_NAME=\"${hostpart#*/}\"",
|
|
" DB_NAME=\"${DB_NAME%%/*}\"",
|
|
" hostpart=\"${hostpart%%/*}\"",
|
|
" if [[ \"$hostpart\" == \\[* ]]; then",
|
|
" DB_HOST=\"${hostpart#\\[}\"",
|
|
" DB_HOST=\"${DB_HOST%%]*}\"",
|
|
" DB_PORT=\"${hostpart##*]:}\"",
|
|
" [[ \"$DB_PORT\" == \"$hostpart\" ]] && DB_PORT=3306",
|
|
" elif [[ \"$hostpart\" == *:* ]]; then",
|
|
" DB_HOST=\"${hostpart%%:*}\"",
|
|
" DB_PORT=\"${hostpart##*:}\"",
|
|
" else",
|
|
" DB_HOST=\"$hostpart\"",
|
|
" DB_PORT=3306",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"wait_mysql() {",
|
|
" local i",
|
|
" for i in $(seq 1 60); do",
|
|
" if \"${COMPOSE[@]}\" exec -T mysql mysqladmin ping -h 127.0.0.1 --silent >/dev/null 2>&1; then",
|
|
" return 0",
|
|
" fi",
|
|
" sleep 2",
|
|
" done",
|
|
" die \"mysql did not become ready\"",
|
|
"}",
|
|
"",
|
|
"ensure_mysql_up() {",
|
|
" if has_service mysql; then",
|
|
" log \"Starting mysql\"",
|
|
" \"${COMPOSE[@]}\" up -d --no-build mysql",
|
|
" wait_mysql",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"# Dump SQL to stdout. Logs go to stderr.",
|
|
"dump_sql() {",
|
|
" if has_service mysql; then",
|
|
" ensure_mysql_up",
|
|
" : \"${MYSQL_DATABASE:?Set MYSQL_DATABASE}\"",
|
|
" : \"${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}\"",
|
|
" \"${COMPOSE[@]}\" exec -T \\",
|
|
" -e MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" \\",
|
|
" mysql \\",
|
|
" mysqldump \\",
|
|
" -uroot \\",
|
|
" --single-transaction \\",
|
|
" --quick \\",
|
|
" --routines \\",
|
|
" --triggers \\",
|
|
" --no-tablespaces \\",
|
|
" --default-character-set=utf8mb4 \\",
|
|
" \"$MYSQL_DATABASE\"",
|
|
" return",
|
|
" fi",
|
|
" parse_database_url",
|
|
" docker run --rm --network host \\",
|
|
" -e MYSQL_PWD=\"$DB_PASS\" \\",
|
|
" mysql:8.4 \\",
|
|
" mysqldump \\",
|
|
" -h\"$DB_HOST\" \\",
|
|
" -P\"$DB_PORT\" \\",
|
|
" -u\"$DB_USER\" \\",
|
|
" --single-transaction \\",
|
|
" --quick \\",
|
|
" --routines \\",
|
|
" --triggers \\",
|
|
" --no-tablespaces \\",
|
|
" --default-character-set=utf8mb4 \\",
|
|
" \"$DB_NAME\"",
|
|
"}",
|
|
"",
|
|
"import_sql() {",
|
|
" if has_service mysql; then",
|
|
" ensure_mysql_up",
|
|
" : \"${MYSQL_DATABASE:?Set MYSQL_DATABASE}\"",
|
|
" : \"${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}\"",
|
|
" \"${COMPOSE[@]}\" exec -T \\",
|
|
" -e MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" \\",
|
|
" mysql \\",
|
|
" mysql \\",
|
|
" -uroot \\",
|
|
" --default-character-set=utf8mb4 \\",
|
|
" --max-allowed-packet=512M \\",
|
|
" \"$MYSQL_DATABASE\"",
|
|
" return",
|
|
" fi",
|
|
" parse_database_url",
|
|
" docker run --rm -i --network host \\",
|
|
" -e MYSQL_PWD=\"$DB_PASS\" \\",
|
|
" mysql:8.4 \\",
|
|
" mysql \\",
|
|
" -h\"$DB_HOST\" \\",
|
|
" -P\"$DB_PORT\" \\",
|
|
" -u\"$DB_USER\" \\",
|
|
" --default-character-set=utf8mb4 \\",
|
|
" --max-allowed-packet=512M \\",
|
|
" \"$DB_NAME\"",
|
|
"}",
|
|
"",
|
|
"rewrite_urls() {",
|
|
" local from=\"${SYNC_REWRITE_FROM_URL:-}\" to=\"${SYNC_REWRITE_TO_URL:-}\"",
|
|
" [[ -n \"$from\" && -n \"$to\" ]] || return 0",
|
|
" log \"Rewriting sales_channel_domain URLs: $from \u2192 $to\"",
|
|
" local sql",
|
|
" sql=$(printf \"UPDATE sales_channel_domain SET url = REPLACE(url, '%s', '%s');\\n\" \\",
|
|
" \"${from//\\'/\\'\\'}\" \"${to//\\'/\\'\\'}\")",
|
|
" printf '%s' \"$sql\" | import_sql",
|
|
"}",
|
|
"",
|
|
"# Primary: tar/rsync the bind-mounted host dir. Fallback: named Docker volume.",
|
|
"dump_volume_tar() {",
|
|
" local key=\"$1\" host vol",
|
|
" host=\"$(host_data_dir \"$key\")\"",
|
|
" if [[ -d \"$host\" ]]; then",
|
|
" tar -C \"$host\" -czf - .",
|
|
" return",
|
|
" fi",
|
|
" vol=\"$(docker_volume_name \"$key\")\"",
|
|
" if has_named_volume \"$key\"; then",
|
|
" log \"Bind-mount dir $host missing; archiving named volume $vol\"",
|
|
" docker run --rm \\",
|
|
" -v \"$vol\":/volume:ro \\",
|
|
" alpine:3.20 \\",
|
|
" tar -C /volume -czf - .",
|
|
" return",
|
|
" fi",
|
|
" die \"No bind-mount dir ($host) and no Docker volume $vol for $key\"",
|
|
"}",
|
|
"",
|
|
"rsync_local_dir() {",
|
|
" local src=\"$1\" dest=\"$2\"",
|
|
" mkdir -p \"$dest\"",
|
|
" if command -v rsync >/dev/null 2>&1; then",
|
|
" rsync -a --delete \"$src/\" \"$dest/\"",
|
|
" return",
|
|
" fi",
|
|
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
|
|
" tar -C \"$src\" -cf - . | tar -C \"$dest\" -xf -",
|
|
"}",
|
|
"",
|
|
"restore_host_tar_stdin() {",
|
|
" local dest",
|
|
" dest=\"$(host_data_dir \"$1\")\"",
|
|
" mkdir -p \"$dest\"",
|
|
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
|
|
" tar -C \"$dest\" -xzf -",
|
|
"}",
|
|
"",
|
|
"snapshot_runtime_dir() {",
|
|
" local key=\"$1\" dest=\"$2/$key\" host",
|
|
" host=\"$(host_data_dir \"$key\")\"",
|
|
" if [[ -d \"$host\" ]]; then",
|
|
" log \"Rsync $host \u2192 $dest\"",
|
|
" rsync_local_dir \"$host\" \"$dest\"",
|
|
" return",
|
|
" fi",
|
|
" if has_named_volume \"$key\"; then",
|
|
" log \"Archiving named volume $key \u2192 $2/${key}.tar.gz\"",
|
|
" dump_volume_tar \"$key\" >\"$2/${key}.tar.gz\"",
|
|
" return",
|
|
" fi",
|
|
" log \"Skip $key (no host dir $host)\"",
|
|
"}",
|
|
"",
|
|
"restore_runtime_dir() {",
|
|
" local key=\"$1\" dir=\"$2\" dest",
|
|
" dest=\"$(host_data_dir \"$key\")\"",
|
|
" mkdir -p \"$dest\"",
|
|
" if [[ -d \"$dir/$key\" ]]; then",
|
|
" log \"Rsync snapshot $key \u2192 $dest\"",
|
|
" rsync_local_dir \"$dir/$key\" \"$dest\"",
|
|
" return",
|
|
" fi",
|
|
" if [[ -f \"$dir/${key}.tar.gz\" ]]; then",
|
|
" log \"Extracting $key tar \u2192 $dest\"",
|
|
" find \"$dest\" -mindepth 1 -maxdepth 1 -exec rm -rf {} +",
|
|
" tar -C \"$dest\" -xzf \"$dir/${key}.tar.gz\"",
|
|
" return",
|
|
" fi",
|
|
" log \"Skip $key (not in snapshot)\"",
|
|
"}",
|
|
"",
|
|
"stop_app() {",
|
|
" local svcs=()",
|
|
" has_service web && svcs+=(web)",
|
|
" has_service worker && svcs+=(worker)",
|
|
" has_service scheduler && svcs+=(scheduler)",
|
|
" if [[ ${#svcs[@]} -gt 0 ]]; then",
|
|
" log \"Stopping ${svcs[*]}\"",
|
|
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" stop \"${svcs[@]}\" || true",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"start_app() {",
|
|
" log \"Starting web\"",
|
|
" \"${COMPOSE[@]}\" up -d --no-build --remove-orphans web",
|
|
" if [[ ${#PROFILE_ARGS[@]} -gt 0 ]]; then",
|
|
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" up -d --no-build",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"chown_volumes() {",
|
|
" local key dest",
|
|
" while IFS= read -r key; do",
|
|
" dest=\"$(host_data_dir \"$key\")\"",
|
|
" mkdir -p \"$dest\"",
|
|
" log \"chown 82:82 $dest\"",
|
|
" docker run --rm \\",
|
|
" -v \"$dest\":/data \\",
|
|
" alpine:3.20 \\",
|
|
" chown -R 82:82 /data",
|
|
" done < <(volume_list)",
|
|
"}",
|
|
"",
|
|
"cache_clear() {",
|
|
" if ! has_service web; then",
|
|
" return 0",
|
|
" fi",
|
|
" log \"cache:clear\"",
|
|
" \"${COMPOSE[@]}\" exec -T web php bin/console cache:clear || log \"cache:clear failed (continuing)\"",
|
|
"}",
|
|
"",
|
|
"env_upper() {",
|
|
" printf '%s' \"$1\" | tr '[:lower:]' '[:upper:]' | tr '-' '_'",
|
|
"}",
|
|
"",
|
|
"snapshot_ids() {",
|
|
" [[ -d \"$SYNC_SNAPSHOT_DIR\" ]] || return 0",
|
|
" find \"$SYNC_SNAPSHOT_DIR\" -mindepth 1 -maxdepth 1 -type d -printf '%f\\n' | sort",
|
|
"}",
|
|
"",
|
|
"prune_snapshots() {",
|
|
" local keep=\"$SYNC_KEEP_SNAPSHOTS\"",
|
|
" [[ \"$keep\" =~ ^[0-9]+$ ]] || return 0",
|
|
" local -a ids=()",
|
|
" mapfile -t ids < <(snapshot_ids)",
|
|
" local extra=$(( ${#ids[@]} - keep ))",
|
|
" (( extra > 0 )) || return 0",
|
|
" local i",
|
|
" for ((i = 0; i < extra; i++)); do",
|
|
" log \"Pruning snapshot ${ids[i]}\"",
|
|
" rm -rf \"${SYNC_SNAPSHOT_DIR:?}/${ids[i]}\"",
|
|
" done",
|
|
"}",
|
|
"",
|
|
"from_ssh_host() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_SSH_HOST\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_SSH_HOST:-}}\"",
|
|
"}",
|
|
"from_ssh_user() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_SSH_USER\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_SSH_USER:-}}\"",
|
|
"}",
|
|
"from_ssh_port() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_SSH_PORT\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_SSH_PORT:-22}}\"",
|
|
"}",
|
|
"from_ssh_identity() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_SSH_IDENTITY\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_SSH_IDENTITY:-}}\"",
|
|
"}",
|
|
"from_ssh_path() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_PATH\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_SSH_PATH:-}}\"",
|
|
"}",
|
|
"from_data_root() {",
|
|
" local var=\"SYNC_$(env_upper \"$1\")_DATA_ROOT\"",
|
|
" printf '%s\\n' \"${!var:-${SYNC_REMOTE_DATA_ROOT:-/var/lib/shopware/data}}\"",
|
|
"}",
|
|
"",
|
|
"ssh_rsh() {",
|
|
" local a out=\"\"",
|
|
" ssh_base \"$1\"",
|
|
" for a in \"${SSH_CMD[@]}\"; do",
|
|
" out+=\"$(printf '%q ' \"$a\")\"",
|
|
" done",
|
|
" printf '%s' \"${out% }\"",
|
|
"}",
|
|
"",
|
|
"rsync_from_remote() {",
|
|
" local from=\"$1\" key=\"$2\"",
|
|
" local host user remote dest",
|
|
" host=\"$(from_ssh_host \"$from\")\"",
|
|
" user=\"$(from_ssh_user \"$from\")\"",
|
|
" remote=\"$(from_data_root \"$from\")/$key\"",
|
|
" dest=\"$(host_data_dir \"$key\")\"",
|
|
" mkdir -p \"$dest\"",
|
|
" if command -v rsync >/dev/null 2>&1; then",
|
|
" log \"Rsync ${user}@${host}:${remote}/ \u2192 ${dest}/\"",
|
|
" rsync -a --delete -e \"$(ssh_rsh \"$from\")\" \\",
|
|
" \"${user}@${host}:${remote}/\" \"${dest}/\"",
|
|
" return",
|
|
" fi",
|
|
" log \"rsync not installed; streaming tar of $key from $from\"",
|
|
" remote_export \"$from\" \"--data volumes --volume $(printf '%q' \"$key\")\" \\",
|
|
" | restore_host_tar_stdin \"$key\"",
|
|
"}",
|
|
"",
|
|
"ssh_base() {",
|
|
" local from=\"$1\" port ident known",
|
|
" port=\"$(from_ssh_port \"$from\")\"",
|
|
" ident=\"$(from_ssh_identity \"$from\")\"",
|
|
" known=\"${SYNC_SSH_KNOWN_HOSTS:-}\"",
|
|
" SSH_CMD=(ssh -o BatchMode=yes -o IdentitiesOnly=yes)",
|
|
" SSH_CMD+=(-o ControlMaster=auto -o \"ControlPath=/tmp/shopware-sync-%C\" -o ControlPersist=30)",
|
|
" SSH_CMD+=(-p \"$port\")",
|
|
" if [[ -n \"$ident\" ]]; then",
|
|
" SSH_CMD+=(-i \"$ident\")",
|
|
" fi",
|
|
" if [[ -n \"$known\" ]]; then",
|
|
" SSH_CMD+=(-o \"UserKnownHostsFile=$known\" -o StrictHostKeyChecking=yes)",
|
|
" else",
|
|
" SSH_CMD+=(-o StrictHostKeyChecking=accept-new)",
|
|
" fi",
|
|
"}",
|
|
"",
|
|
"remote_export() {",
|
|
" local from=\"$1\" args=\"$2\" host user path",
|
|
" host=\"$(from_ssh_host \"$from\")\"",
|
|
" user=\"$(from_ssh_user \"$from\")\"",
|
|
" path=\"$(from_ssh_path \"$from\")\"",
|
|
" [[ -n \"$host\" ]] || die \"Set SYNC_SSH_HOST (or SYNC_$(env_upper \"$from\")_SSH_HOST) in deploy/sync.env\"",
|
|
" [[ -n \"$user\" ]] || die \"Set SYNC_SSH_USER in deploy/sync.env\"",
|
|
" [[ -n \"$path\" ]] || die \"Set SYNC_SSH_PATH in deploy/sync.env\"",
|
|
" ssh_base \"$from\"",
|
|
" \"${SSH_CMD[@]}\" \"${user}@${host}\" \\",
|
|
" \"set -euo pipefail; cd $(printf '%q' \"$path\"); bash ./deploy/sync-runtime.sh export $args\"",
|
|
"}",
|
|
"",
|
|
"cmd_export() {",
|
|
" case \"$DATA\" in",
|
|
" db)",
|
|
" dump_sql",
|
|
" ;;",
|
|
" volumes)",
|
|
" [[ -n \"$VOLUME_KEY\" ]] || die \"export --data volumes requires --volume <key>\"",
|
|
" dump_volume_tar \"$VOLUME_KEY\"",
|
|
" ;;",
|
|
" all)",
|
|
" die \"export --data all is not streamed as one payload; use db or volumes\"",
|
|
" ;;",
|
|
" esac",
|
|
"}",
|
|
"",
|
|
"cmd_snapshot() {",
|
|
" acquire_lock",
|
|
" mkdir -p \"$SYNC_SNAPSHOT_DIR\"",
|
|
" local id ts envn",
|
|
" ts=\"$(date -u +%Y%m%dT%H%M%SZ)\"",
|
|
" envn=\"${SYNC_ENV:-local}\"",
|
|
" id=\"${SNAPSHOT_ID:-$ts-$envn}\"",
|
|
" local dir=\"$SYNC_SNAPSHOT_DIR/$id\"",
|
|
" mkdir -p \"$dir\"",
|
|
" log \"Snapshot $id \u2192 $dir\"",
|
|
" {",
|
|
" printf 'id=%s\\n' \"$id\"",
|
|
" printf 'env=%s\\n' \"$envn\"",
|
|
" printf 'created_at=%s\\n' \"$ts\"",
|
|
" printf 'data=%s\\n' \"$DATA\"",
|
|
" printf 'volumes=%s\\n' \"$SYNC_VOLUMES\"",
|
|
" printf 'data_root=%s\\n' \"$SHOPWARE_DATA_ROOT\"",
|
|
" } >\"$dir/meta.txt\"",
|
|
" if want_db; then",
|
|
" log \"Dumping database\"",
|
|
" dump_sql | gzip -c >\"$dir/db.sql.gz\"",
|
|
" fi",
|
|
" if want_volumes; then",
|
|
" local key",
|
|
" while IFS= read -r key; do",
|
|
" snapshot_runtime_dir \"$key\" \"$dir\"",
|
|
" done < <(volume_list)",
|
|
" fi",
|
|
" prune_snapshots",
|
|
" log \"Snapshot finished $id\"",
|
|
" printf '%s\\n' \"$id\"",
|
|
"}",
|
|
"",
|
|
"cmd_restore() {",
|
|
" acquire_lock",
|
|
" if [[ -z \"$SNAPSHOT_ID\" ]]; then",
|
|
" log \"Available snapshots in $SYNC_SNAPSHOT_DIR:\"",
|
|
" snapshot_ids || true",
|
|
" die \"restore requires --snapshot <id>\"",
|
|
" fi",
|
|
" local dir=\"$SYNC_SNAPSHOT_DIR/$SNAPSHOT_ID\"",
|
|
" [[ -d \"$dir\" ]] || die \"Snapshot not found: $dir\"",
|
|
" local this_env=\"${SYNC_ENV:-}\"",
|
|
" if [[ -n \"$this_env\" ]] && is_live_env \"$this_env\"; then",
|
|
" if [[ \"${SYNC_ALLOW_LIVE_RESTORE:-0}\" != 1 ]]; then",
|
|
" die \"Refusing restore onto live/prod (set SYNC_ALLOW_LIVE_RESTORE=1 for disaster recovery)\"",
|
|
" fi",
|
|
" fi",
|
|
" confirm \"Overwrite runtime data on ${this_env:-this host} from snapshot $SNAPSHOT_ID?\" \\",
|
|
" || die \"Cancelled\"",
|
|
" stop_app",
|
|
" if want_db; then",
|
|
" [[ -f \"$dir/db.sql.gz\" ]] || die \"Snapshot has no db.sql.gz\"",
|
|
" log \"Importing database\"",
|
|
" gzip -dc \"$dir/db.sql.gz\" | import_sql",
|
|
" rewrite_urls",
|
|
" fi",
|
|
" if want_volumes; then",
|
|
" local key",
|
|
" while IFS= read -r key; do",
|
|
" restore_runtime_dir \"$key\" \"$dir\"",
|
|
" done < <(volume_list)",
|
|
" chown_volumes",
|
|
" fi",
|
|
" start_app",
|
|
" cache_clear",
|
|
" log \"Restore finished $SNAPSHOT_ID\"",
|
|
"}",
|
|
"",
|
|
"cmd_sync() {",
|
|
" acquire_lock",
|
|
" [[ -n \"$FROM_ENV\" ]] || die \"sync requires --from <env> (e.g. --from live)\"",
|
|
" [[ -n \"${SYNC_ENV:-}\" ]] || die \"Set SYNC_ENV in deploy/sync.env (this host, e.g. staging)\"",
|
|
" if is_live_env \"$SYNC_ENV\"; then",
|
|
" die \"Refusing sync onto live/prod (pull on the lower env, never push into live)\"",
|
|
" fi",
|
|
" if [[ \"$FROM_ENV\" == \"$SYNC_ENV\" ]]; then",
|
|
" die \"--from ($FROM_ENV) is this host (SYNC_ENV=$SYNC_ENV)\"",
|
|
" fi",
|
|
" local from_rank to_rank",
|
|
" from_rank=\"$(env_rank \"$FROM_ENV\")\"",
|
|
" to_rank=\"$(env_rank \"$SYNC_ENV\")\"",
|
|
" if [[ \"$to_rank\" -le \"$from_rank\" ]]; then",
|
|
" die \"Refusing ${FROM_ENV} \u2192 ${SYNC_ENV} (only higher \u2192 lower, e.g. live \u2192 staging)\"",
|
|
" fi",
|
|
" # Fail closed before stopping services.",
|
|
" [[ -n \"$(from_ssh_host \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_HOST (or SYNC_$(env_upper \"$FROM_ENV\")_SSH_HOST) in deploy/sync.env\"",
|
|
" [[ -n \"$(from_ssh_user \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_USER in deploy/sync.env\"",
|
|
" [[ -n \"$(from_ssh_path \"$FROM_ENV\")\" ]] || die \"Set SYNC_SSH_PATH in deploy/sync.env\"",
|
|
" confirm \"Overwrite ${SYNC_ENV} runtime data with ${FROM_ENV} (${DATA})?\" || die \"Cancelled\"",
|
|
" stop_app",
|
|
" if want_db; then",
|
|
" log \"Streaming mysqldump from $FROM_ENV\"",
|
|
" remote_export \"$FROM_ENV\" \"--data db\" | import_sql",
|
|
" rewrite_urls",
|
|
" fi",
|
|
" if want_volumes; then",
|
|
" local key",
|
|
" while IFS= read -r key; do",
|
|
" log \"Syncing bind-mount dir $key from $FROM_ENV\"",
|
|
" rsync_from_remote \"$FROM_ENV\" \"$key\"",
|
|
" done < <(volume_list)",
|
|
" chown_volumes",
|
|
" fi",
|
|
" start_app",
|
|
" cache_clear",
|
|
" log \"Sync finished ${FROM_ENV} \u2192 ${SYNC_ENV} ($DATA)\"",
|
|
"}",
|
|
"",
|
|
"case \"$COMMAND\" in",
|
|
" export) cmd_export ;;",
|
|
" snapshot) cmd_snapshot ;;",
|
|
" restore) cmd_restore ;;",
|
|
" sync) cmd_sync ;;",
|
|
" *) die \"Unknown command: $COMMAND\" ;;",
|
|
"esac",
|
|
""
|
|
],
|
|
"executable": true
|
|
},
|
|
"root/deploy/sync.env.example": {
|
|
"contents": [
|
|
"# Copy to deploy/sync.env on the *consumer* (staging / playground / dev). chmod 600.",
|
|
"# Never commit filled values. Live does not need this file for `snapshot`.",
|
|
"#",
|
|
"# No S3 \u2014 mysqldump + rsync of bind-mounted host dirs over SSH (or a local snapshot).",
|
|
"#",
|
|
"# Typical: this host is staging, source is live.",
|
|
"# cp deploy/sync.env.example deploy/sync.env",
|
|
"# bash deploy/sync-runtime.sh sync --from live --data all",
|
|
"",
|
|
"# This machine's environment. Must be a *lower* env for `sync` (not live / prod).",
|
|
"SYNC_ENV=staging",
|
|
"",
|
|
"# --- SSH to the source (--from live uses these unless SYNC_LIVE_* is set) ------",
|
|
"# TODO: live VPS hostname or IP",
|
|
"SYNC_SSH_HOST=",
|
|
"SYNC_SSH_USER=",
|
|
"SYNC_SSH_PORT=22",
|
|
"# Optional private key path (mode 0600). Empty = ssh defaults / agent.",
|
|
"SYNC_SSH_IDENTITY=",
|
|
"# Optional known_hosts file (ssh-keyscan output). Empty = accept-new on first connect.",
|
|
"# SYNC_SSH_KNOWN_HOSTS=/etc/ssh/ssh_known_hosts",
|
|
"# Checkout on the source (that env's VPS_PATH). Must contain this recipe's deploy/.",
|
|
"SYNC_SSH_PATH=/opt/shopware/live",
|
|
"",
|
|
"# Optional per-source overrides (used when --from NAME matches):",
|
|
"# SYNC_LIVE_SSH_HOST=",
|
|
"# SYNC_LIVE_SSH_USER=",
|
|
"# SYNC_LIVE_SSH_PORT=22",
|
|
"# SYNC_LIVE_SSH_IDENTITY=",
|
|
"# SYNC_LIVE_PATH=/opt/shopware/live",
|
|
"# SYNC_LIVE_DATA_ROOT=/var/lib/shopware/data",
|
|
"# SYNC_STAGING_SSH_HOST=",
|
|
"# SYNC_STAGING_PATH=/opt/shopware/staging",
|
|
"",
|
|
"# Bind-mount dirs to copy (not mysql_data / redis_data \u2014 DB is mysqldump).",
|
|
"# Host path is ${SHOPWARE_DATA_ROOT:-/var/lib/shopware/data}/<name>",
|
|
"SYNC_VOLUMES=files,media,thumbnail,theme,sitemap",
|
|
"",
|
|
"# Remote SHOPWARE_DATA_ROOT when the source uses a non-default path.",
|
|
"# SYNC_REMOTE_DATA_ROOT=/var/lib/shopware/data",
|
|
"",
|
|
"# Local snapshot directory (snapshot / restore). Not used as an object store.",
|
|
"SYNC_SNAPSHOT_DIR=/var/backups/shopware-runtime",
|
|
"SYNC_KEEP_SNAPSHOTS=5",
|
|
"",
|
|
"# Optional: rewrite live storefront URLs in sales_channel_domain after DB restore.",
|
|
"# SYNC_REWRITE_FROM_URL=https://www.example.com",
|
|
"# SYNC_REWRITE_TO_URL=https://staging.example.com",
|
|
"",
|
|
"# Disaster recovery only. `restore` onto live is refused unless this is 1.",
|
|
"# SYNC_ALLOW_LIVE_RESTORE=0",
|
|
""
|
|
],
|
|
"executable": false
|
|
},
|
|
"root/deploy/vps-release.sh": {
|
|
"contents": [
|
|
"#!/usr/bin/env bash",
|
|
"# Run on the VPS (or via SSH from CI) after the image has been pushed.",
|
|
"# Never builds the image and never compiles themes/assets.",
|
|
"#",
|
|
"# Required env:",
|
|
"# IMAGE registry/repo (e.g. ghcr.io/fyrst-dev/shop-name) \u2014 no real defaults",
|
|
"# IMAGE_TAG full git SHA (or a rollback tag)",
|
|
"# Optional:",
|
|
"# COMPOSE_DIR shop checkout (default: repository root next to deploy/)",
|
|
"# COMPOSE_PROFILES comma-separated: redis,worker,scheduler (never include \"setup\")",
|
|
"# SMOKE_URL HTTP URL to probe after up (e.g. http://127.0.0.1:8000)",
|
|
"#",
|
|
"# CI-exported IMAGE / IMAGE_TAG always win over .env (which often has IMAGE_TAG=latest).",
|
|
"#",
|
|
"# Compose files (shop root as --project-directory):",
|
|
"# deploy/compose.yaml, deploy/compose.prod.yaml, deploy/compose.vps.yaml",
|
|
"",
|
|
"set -euo pipefail",
|
|
"",
|
|
"COMPOSE_DIR=\"${COMPOSE_DIR:-$(cd \"$(dirname \"$0\")/..\" && pwd)}\"",
|
|
"cd \"$COMPOSE_DIR\"",
|
|
"",
|
|
"CI_IMAGE=\"${IMAGE:-}\"",
|
|
"CI_IMAGE_TAG=\"${IMAGE_TAG:-}\"",
|
|
"CI_SMOKE_URL=\"${SMOKE_URL:-}\"",
|
|
"CI_PROFILES=\"${COMPOSE_PROFILES:-}\"",
|
|
"",
|
|
"if [[ -f .env ]]; then",
|
|
" set -a",
|
|
" # shellcheck disable=SC1091",
|
|
" source .env",
|
|
" set +a",
|
|
"fi",
|
|
"if [[ -f .env.prod ]]; then",
|
|
" set -a",
|
|
" # shellcheck disable=SC1091",
|
|
" source .env.prod",
|
|
" set +a",
|
|
"fi",
|
|
"",
|
|
"IMAGE=\"${CI_IMAGE:-${IMAGE:-}}\"",
|
|
"IMAGE_TAG=\"${CI_IMAGE_TAG:-${IMAGE_TAG:-}}\"",
|
|
"SMOKE_URL=\"${CI_SMOKE_URL:-${SMOKE_URL:-}}\"",
|
|
"COMPOSE_PROFILES=\"${CI_PROFILES:-${COMPOSE_PROFILES:-}}\"",
|
|
"",
|
|
": \"${IMAGE:?Set IMAGE to the registry repository}\"",
|
|
": \"${IMAGE_TAG:?Set IMAGE_TAG to the git SHA (or previous tag for rollback)}\"",
|
|
"",
|
|
"export IMAGE IMAGE_TAG",
|
|
"",
|
|
"touch .env.prod",
|
|
"",
|
|
"COMPOSE=(",
|
|
" docker compose",
|
|
" --project-directory \"$COMPOSE_DIR\"",
|
|
" -f deploy/compose.yaml",
|
|
" -f deploy/compose.prod.yaml",
|
|
" -f deploy/compose.vps.yaml",
|
|
")",
|
|
"",
|
|
"PROFILE_ARGS=()",
|
|
"IFS=',' read -ra RAW_PROFILES <<< \"${COMPOSE_PROFILES:-}\"",
|
|
"for p in \"${RAW_PROFILES[@]}\"; do",
|
|
" p=\"${p// /}\"",
|
|
" if [[ -z \"$p\" ]]; then",
|
|
" continue",
|
|
" fi",
|
|
" if [[ \"$p\" == \"setup\" ]]; then",
|
|
" echo \"COMPOSE_PROFILES must not include setup (the script runs that profile itself)\" >&2",
|
|
" exit 1",
|
|
" fi",
|
|
" PROFILE_ARGS+=(--profile \"$p\")",
|
|
"done",
|
|
"",
|
|
"has_service() {",
|
|
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" config --services 2>/dev/null | grep -qx \"$1\"",
|
|
"}",
|
|
"",
|
|
"echo \"==> Deploying ${IMAGE}:${IMAGE_TAG} from ${COMPOSE_DIR}\"",
|
|
"",
|
|
"if [[ -f .deployed-tag ]]; then",
|
|
" cp .deployed-tag .previous-tag",
|
|
" echo \"==> Previous tag: $(cat .previous-tag)\"",
|
|
"fi",
|
|
"",
|
|
"echo \"==> Pulling images\"",
|
|
"\"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" pull",
|
|
"",
|
|
"if has_service mysql; then",
|
|
" echo \"==> Starting mysql\"",
|
|
" \"${COMPOSE[@]}\" up -d --no-build mysql",
|
|
"fi",
|
|
"",
|
|
"if has_service redis; then",
|
|
" echo \"==> Starting redis\"",
|
|
" \"${COMPOSE[@]}\" --profile redis up -d --no-build redis",
|
|
"fi",
|
|
"",
|
|
"echo \"==> One-shot setup (shopware-deployment-helper, skip theme/assets)\"",
|
|
"\"${COMPOSE[@]}\" --profile setup run --rm --no-build setup",
|
|
"",
|
|
"echo \"==> Recreating web (no build)\"",
|
|
"\"${COMPOSE[@]}\" up -d --no-build --remove-orphans web",
|
|
"",
|
|
"if [[ ${#PROFILE_ARGS[@]} -gt 0 ]]; then",
|
|
" echo \"==> Starting extra profiles: ${COMPOSE_PROFILES}\"",
|
|
" \"${COMPOSE[@]}\" \"${PROFILE_ARGS[@]}\" up -d --no-build",
|
|
"fi",
|
|
"",
|
|
"printf '%s\\n' \"$IMAGE_TAG\" > .deployed-tag",
|
|
"",
|
|
"if [[ -n \"${SMOKE_URL:-}\" ]]; then",
|
|
" echo \"==> Smoke ${SMOKE_URL}\"",
|
|
" ok=0",
|
|
" for _ in $(seq 1 30); do",
|
|
" if command -v curl >/dev/null 2>&1 && curl -fsS \"$SMOKE_URL\" >/dev/null; then",
|
|
" echo \"==> Smoke OK\"",
|
|
" ok=1",
|
|
" break",
|
|
" fi",
|
|
" sleep 2",
|
|
" done",
|
|
" if [[ \"$ok\" -ne 1 ]]; then",
|
|
" echo \"Smoke check failed for ${SMOKE_URL}\" >&2",
|
|
" exit 1",
|
|
" fi",
|
|
"fi",
|
|
"",
|
|
"echo \"==> Deploy finished ${IMAGE}:${IMAGE_TAG}\"",
|
|
""
|
|
],
|
|
"executable": true
|
|
}
|
|
},
|
|
"ref": "ef8f53eb0e3086076a1e9868f1f16712e479d247"
|
|
}
|
|
}
|
|
}
|