From ba99748bf4b8f104502fd2e8db6687bef03eb8eb Mon Sep 17 00:00:00 2001 From: rgaunt Date: Mon, 22 Sep 2025 16:03:57 +1000 Subject: [PATCH 1/6] Added workflow check for circleci. --- .github/workflows/build-deploy.yml | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 2a280fd4e..bc9b78058 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -1,18 +1,14 @@ name: Build and Push civictheme-monorepo-drupal to Quant Cloud 'on': - push: - branches: - - main - - master - - develop - - quant-cloud-migration - - feature/* - - pr-* - - content/* - tags: - - '*' - pull_request: - branches: '*' + # Manual trigger for testing and emergency deployments + workflow_dispatch: + + # Trigger when CircleCI check suite completes successfully + check_suite: + types: [completed] + + # Alternative: Use status event for the specific CircleCI workflow check + status: concurrency: group: build-and-push-${{ github.ref }} @@ -20,6 +16,15 @@ concurrency: jobs: build-and-push: + # Only run when triggered by successful CircleCI checks or manual dispatch + if: | + (github.event_name == 'check_suite' && + github.event.check_suite.app.slug == 'circleci-checks' && + github.event.check_suite.conclusion == 'success') || + (github.event_name == 'status' && + github.event.context == 'ci/circleci: commit' && + github.event.state == 'success') || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: From 938465a19bb80181dba02c371536169471cb1511 Mon Sep 17 00:00:00 2001 From: richardgaunt <57734756+richardgaunt@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:33:42 +1000 Subject: [PATCH 2/6] Update .github/workflows/build-deploy.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/build-deploy.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index bc9b78058..5354e5c44 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -22,8 +22,9 @@ jobs: github.event.check_suite.app.slug == 'circleci-checks' && github.event.check_suite.conclusion == 'success') || (github.event_name == 'status' && - github.event.context == 'ci/circleci: commit' && - github.event.state == 'success') || + startsWith(github.event.context, 'ci/circleci:') && + github.event.state == 'success' && + (github.event.sender.login == 'circleci' || github.actor == 'circleci[bot]')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: From 917520271d81e3521484e3978688c05479c593ad Mon Sep 17 00:00:00 2001 From: rgaunt Date: Mon, 22 Sep 2025 17:18:17 +1000 Subject: [PATCH 3/6] Updated webhook from CircleCI to trigger GH workflow. --- .circleci/config.yml | 14 ++++++++++++++ .github/workflows/build-deploy.yml | 19 +++++-------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ac27e2dbf..67e2ebfa6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -457,6 +457,19 @@ jobs: DREVOPS_DEPLOY_PR_HEAD=$CIRCLE_SHA1 \ ./scripts/drevops/deploy.sh no_output_timeout: 30m + - run: + name: Trigger GitHub workflow for Quant Cloud deployment + command: | + if [ -n "${GITHUB_TOKEN}" ]; then + curl -X POST \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/dispatches" \ + -d "{\"event_type\":\"circleci_success\",\"client_payload\":{\"branch\":\"${CIRCLE_BRANCH}\",\"sha\":\"${CIRCLE_SHA1}\",\"build_url\":\"${CIRCLE_BUILD_URL}\"}}" + echo "Successfully triggered Quant GitHub deploy workflow for branch: ${CIRCLE_BRANCH}" + else + echo "GITHUB_TOKEN not set - skipping GitHub workflow trigger" + fi - store_artifacts: path: *artifacts @@ -540,6 +553,7 @@ jobs: done fi + ################################################################################ # WORKFLOWS ################################################################################ diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 5354e5c44..c1697a5ec 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -3,12 +3,9 @@ name: Build and Push civictheme-monorepo-drupal to Quant Cloud # Manual trigger for testing and emergency deployments workflow_dispatch: - # Trigger when CircleCI check suite completes successfully - check_suite: - types: [completed] - - # Alternative: Use status event for the specific CircleCI workflow check - status: + # Trigger when CircleCI notifies via repository dispatch + repository_dispatch: + types: [circleci_success] concurrency: group: build-and-push-${{ github.ref }} @@ -16,15 +13,9 @@ concurrency: jobs: build-and-push: - # Only run when triggered by successful CircleCI checks or manual dispatch + # Only run when triggered by CircleCI success or manual dispatch if: | - (github.event_name == 'check_suite' && - github.event.check_suite.app.slug == 'circleci-checks' && - github.event.check_suite.conclusion == 'success') || - (github.event_name == 'status' && - startsWith(github.event.context, 'ci/circleci:') && - github.event.state == 'success' && - (github.event.sender.login == 'circleci' || github.actor == 'circleci[bot]')) || + github.event_name == 'repository_dispatch' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: From 4c8a46ed9ed8d25ce36bd4131bb16433b3c49286 Mon Sep 17 00:00:00 2001 From: richardgaunt <57734756+richardgaunt@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:16:15 +1000 Subject: [PATCH 4/6] Update .circleci/config.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .circleci/config.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 67e2ebfa6..3b0de78ab 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -461,12 +461,22 @@ jobs: name: Trigger GitHub workflow for Quant Cloud deployment command: | if [ -n "${GITHUB_TOKEN}" ]; then - curl -X POST \ - -H "Accept: application/vnd.github.v3+json" \ + payload=$(jq -nc --arg branch "${CIRCLE_BRANCH}" --arg sha "${CIRCLE_SHA1}" --arg url "${CIRCLE_BUILD_URL}" \ + '{event_type:"circleci_success", client_payload:{branch:$branch, sha:$sha, build_url:$url}}') + resp=$(mktemp) + code=$(curl -sS -f -o "$resp" -w "%{http_code}" \ + -X POST \ + -H "Accept: application/vnd.github+json" \ -H "Authorization: token ${GITHUB_TOKEN}" \ "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/dispatches" \ - -d "{\"event_type\":\"circleci_success\",\"client_payload\":{\"branch\":\"${CIRCLE_BRANCH}\",\"sha\":\"${CIRCLE_SHA1}\",\"build_url\":\"${CIRCLE_BUILD_URL}\"}}" - echo "Successfully triggered Quant GitHub deploy workflow for branch: ${CIRCLE_BRANCH}" + -d "$payload" || true) + if [ "${code:-0}" -ge 200 ] && [ "${code:-0}" -lt 300 ]; then + echo "Triggered Quant GitHub deploy workflow for branch: ${CIRCLE_BRANCH}" + else + echo "Failed to trigger GitHub workflow (HTTP ${code:-0}). Response:" + cat "$resp" + exit 1 + fi else echo "GITHUB_TOKEN not set - skipping GitHub workflow trigger" fi From 1b79c9c370b567a2b6254c515935cb2a2eb084ee Mon Sep 17 00:00:00 2001 From: richardgaunt <57734756+richardgaunt@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:17:19 +1000 Subject: [PATCH 5/6] Update .github/workflows/build-deploy.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/build-deploy.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index c1697a5ec..61261f3f3 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -13,9 +13,12 @@ concurrency: jobs: build-and-push: + concurrency: + group: build-and-push-${{ github.event.client_payload.branch || github.ref || github.run_id }} + cancel-in-progress: true # Only run when triggered by CircleCI success or manual dispatch if: | - github.event_name == 'repository_dispatch' || + (github.event_name == 'repository_dispatch') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: From 750634ff8da1fa858a0d985ba30ba1fdeed2cd72 Mon Sep 17 00:00:00 2001 From: Stuart Rowlands Date: Thu, 25 Sep 2025 14:44:28 +1000 Subject: [PATCH 6/6] Add Quant Cloud migration configuration - Add quant.type labels to docker-compose services - Add GitHub Actions build-deploy workflow - Update Drupal settings for Quant Cloud environment variables - Make Elasticsearch compatible with ECS Fargate - Convert post-rollout tasks to entrypoint scripts - Add nginx configuration for CDN header handling - Add Quant Cloud scripts (provision and database backup) --- .../nginx/location_drupal_prepend_host.conf | 23 ++ .docker/entrypoints/cli/03-provision-site.sh | 10 + .docker/nginx-drupal.dockerfile | 5 +- .github/workflows/build-deploy.yml | 26 +- scripts/quant/download-db.sh | 227 ++++++++++++++++++ scripts/quant/provision-quant.sh | 224 +++++++++++++++++ 6 files changed, 501 insertions(+), 14 deletions(-) create mode 100644 .docker/config/nginx/location_drupal_prepend_host.conf create mode 100755 scripts/quant/download-db.sh create mode 100755 scripts/quant/provision-quant.sh diff --git a/.docker/config/nginx/location_drupal_prepend_host.conf b/.docker/config/nginx/location_drupal_prepend_host.conf new file mode 100644 index 000000000..24a0a9ddd --- /dev/null +++ b/.docker/config/nginx/location_drupal_prepend_host.conf @@ -0,0 +1,23 @@ +# Set HTTP_HOST for PHP using canonical host from CDN headers +# Priority: HTTP_QUANT_ORIG_HOST > HTTP_X_FORWARDED_HOST > original Host header (preserving port) + +# Default to the incoming Host header so non-standard ports stay intact (e.g. :8080) +set $final_host $http_host; + +# Use X-Forwarded-Host when provided (reverse proxies) +if ($http_x_forwarded_host != "") { + set $final_host $http_x_forwarded_host; +} + +# Override with Quant-Orig-Host when available +if ($http_quant_orig_host != "") { + set $final_host $http_quant_orig_host; +} + +# Extract first host from comma-separated X-Forwarded-Host values +if ($final_host ~ "^([^,\s]+)") { + set $final_host $1; +} + +# Always pass the calculated host; without override it matches the original Host header +fastcgi_param HTTP_HOST $final_host; diff --git a/.docker/entrypoints/cli/03-provision-site.sh b/.docker/entrypoints/cli/03-provision-site.sh index db339f756..b9cf8b32f 100755 --- a/.docker/entrypoints/cli/03-provision-site.sh +++ b/.docker/entrypoints/cli/03-provision-site.sh @@ -28,3 +28,13 @@ set -e # NOTE: This provision script has been disabled because: # - rsync commands try to connect to Lagoon SSH which is not available in Quant Cloud # - Use DREVOPS_PROVISION_SKIP=1 environment variable to skip provision steps + +# Delegate Drupal provisioning to the Quant-aware script. The standard +# DrevOps provision script is not compatible with Quant Cloud because it relies +# on Lagoon-specific tooling (e.g., rsync to Lagoon SSH). +if [ -x "./scripts/quant/provision-quant.sh" ]; then + ./scripts/quant/provision-quant.sh +else + echo "Quant provisioning script missing or not executable." >&2 + exit 1 +fi diff --git a/.docker/nginx-drupal.dockerfile b/.docker/nginx-drupal.dockerfile index b67c5ea19..da6a654ed 100644 --- a/.docker/nginx-drupal.dockerfile +++ b/.docker/nginx-drupal.dockerfile @@ -17,4 +17,7 @@ ENV WEBROOT=${WEBROOT} RUN apk add --no-cache tzdata -COPY --from=cli /app /app +# Copy custom nginx configuration for CDN header handling +COPY .docker/config/nginx/location_drupal_prepend_host.conf /etc/nginx/conf.d/drupal/ +RUN chmod 0644 /etc/nginx/conf.d/drupal/location_drupal_prepend_host.conf +COPY --from=cli /app /app \ No newline at end of file diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 61261f3f3..5aa91733a 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -1,11 +1,18 @@ name: Build and Push civictheme-monorepo-drupal to Quant Cloud 'on': - # Manual trigger for testing and emergency deployments - workflow_dispatch: - - # Trigger when CircleCI notifies via repository dispatch - repository_dispatch: - types: [circleci_success] + push: + branches: + - main + - master + - develop + - quant-cloud-migration + - feature/* + - content/* + - pr-* + tags: + - '*' + pull_request: + branches: '*' concurrency: group: build-and-push-${{ github.ref }} @@ -13,13 +20,6 @@ concurrency: jobs: build-and-push: - concurrency: - group: build-and-push-${{ github.event.client_payload.branch || github.ref || github.run_id }} - cancel-in-progress: true - # Only run when triggered by CircleCI success or manual dispatch - if: | - (github.event_name == 'repository_dispatch') || - github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: diff --git a/scripts/quant/download-db.sh b/scripts/quant/download-db.sh new file mode 100755 index 000000000..8b04c340e --- /dev/null +++ b/scripts/quant/download-db.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# Download the latest Quant Cloud database backup, decompress it, and report paths. +set -euo pipefail + +if ! command -v qc >/dev/null 2>&1; then + echo "Quant CLI (qc) is required but was not found. Install it first." >&2 + exit 1 +fi + +QUANT_ORG_NAME="${QUANT_ORG_NAME:-salsa-digital}" + +if ! qc org select "${QUANT_ORG_NAME}" >/dev/null 2>&1; then + echo "Failed to select Quant organisation '${QUANT_ORG_NAME}'. Run \`qc org list\` to verify access." >&2 + exit 1 +fi + +echo "Downloading database from Quant Cloud. If you need Lagoon, run \`ahoy download-db-lagoon\`." + +QUANT_APP_NAME="${QUANT_APP_NAME:-${LAGOON_PROJECT:-qld-bsc}}" +# QUANT_APP_NAME is the canonical Quant application identifier; falls back to LAGOON_PROJECT for legacy workflows. +QUANT_ENVIRONMENT="${QUANT_ENVIRONMENT:-production}" +# Backward compatibility: expose LAGOON_PROJECT for legacy tooling until everything is Quant-aware. +if [ -z "${LAGOON_PROJECT:-}" ]; then + export LAGOON_PROJECT="${QUANT_APP_NAME}" +fi + +DREVOPS_DB_DIR="${DREVOPS_DB_DIR:-./.data}" +DREVOPS_DB_FILE="${DREVOPS_DB_FILE:-db.sql}" +QUANT_DOWNLOAD_DIR="${QUANT_DOWNLOAD_DIR:-./downloads}" +QUANT_BACKUP_POLL_INTERVAL="${QUANT_BACKUP_POLL_INTERVAL:-15}" +QUANT_BACKUP_TIMEOUT="${QUANT_BACKUP_TIMEOUT:-900}" +QUANT_BACKUP_DESCRIPTION="${QUANT_BACKUP_DESCRIPTION:-Database backup triggered by ahoy download-db on $(date -u +'%Y-%m-%dT%H:%M:%SZ')}" + +mkdir -p "${DREVOPS_DB_DIR}" "${QUANT_DOWNLOAD_DIR}" + +QC_BIN_REALPATH="$(python3 - <<'PY' +import os +import shutil +path = shutil.which('qc') +if not path: + raise SystemExit('qc binary not found') +print(os.path.realpath(path)) +PY +)" +QUANT_CLI_MODULE_DIR="$(dirname "${QC_BIN_REALPATH}")" +export QUANT_CLI_MODULE_DIR +export QUANT_APP_NAME QUANT_ENVIRONMENT QUANT_DOWNLOAD_DIR DREVOPS_DB_DIR DREVOPS_DB_FILE +export QUANT_BACKUP_POLL_INTERVAL QUANT_BACKUP_TIMEOUT QUANT_BACKUP_DESCRIPTION + +NODE_OUTPUT="$(node --input-type=module <<'NODE' +import fs from 'fs'; +import path from 'path'; +import { pipeline } from 'stream/promises'; +import { createGunzip } from 'zlib'; +import { pathToFileURL } from 'url'; + +const hasGzipMagicNumber = (filePath) => { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.alloc(2); + const bytesRead = fs.readSync(fd, buffer, 0, 2, 0); + return bytesRead === 2 && buffer[0] === 0x1f && buffer[1] === 0x8b; + } catch (error) { + return false; + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +}; + +const moduleDir = process.env.QUANT_CLI_MODULE_DIR; +if (!moduleDir) { + console.error('[quant] QUANT_CLI_MODULE_DIR not set. Cannot proceed.'); + process.exit(1); +} + +const toNumber = (value, fallback) => { + const num = Number(value); + return Number.isFinite(num) && num > 0 ? num : fallback; +}; + +const pollIntervalSec = toNumber(process.env.QUANT_BACKUP_POLL_INTERVAL, 15); +const timeoutSec = toNumber(process.env.QUANT_BACKUP_TIMEOUT, 900); +const downloadDir = process.env.QUANT_DOWNLOAD_DIR || './downloads'; +const decompressDir = process.env.DREVOPS_DB_DIR || './.data'; +const decompressFile = process.env.DREVOPS_DB_FILE || 'db.sql'; +const appName = process.env.QUANT_APP_NAME || process.env.QUANT_APPLICATION || process.env.LAGOON_PROJECT || ''; +const envName = process.env.QUANT_ENVIRONMENT || ''; +const description = process.env.QUANT_BACKUP_DESCRIPTION || `Database backup triggered by ahoy download-db on ${new Date().toISOString()}`; + +const { ApiClient } = await import(pathToFileURL(path.join(moduleDir, 'utils/api.js')).href); +const client = await ApiClient.create(); + +const orgId = client.defaultOrganizationId; +const appId = appName || client.defaultApplicationId; +const envId = envName || client.defaultEnvironmentId; + +if (!orgId || !appId || !envId) { + console.error('[quant] Missing organization, application, or environment context. Use `qc login` and ensure defaults are set.'); + process.exit(1); +} + +console.error(`[quant] Creating database backup for app "${appId}" environment "${envId}"...`); + +const createResp = await client.backupManagementApi.createBackup(orgId, appId, envId, 'database', { description }); +const backup = createResp.body || {}; +const backupId = backup.id || backup.backupId; + +if (!backupId) { + console.error('[quant] API did not return a backup ID.'); + process.exit(1); +} + +const normalizeStatus = (value) => (value || '').toLowerCase(); +let lastStatus = normalizeStatus(backup.status) || 'requested'; +console.error(`[quant] Backup requested (ID: ${backupId}). Initial status: ${lastStatus}.`); + +const pollIntervalMs = Math.max(5, pollIntervalSec) * 1000; +const timeoutMs = Math.max(pollIntervalMs, timeoutSec * 1000); +const start = Date.now(); + +while (true) { + const listResp = await client.backupManagementApi.listBackups(orgId, appId, envId, 'database'); + const backups = listResp.body?.backups ?? []; + const current = backups.find((b) => (b.backupId || b.id) === backupId); + + if (current) { + const status = normalizeStatus(current.status); + if (status !== lastStatus) { + console.error(`[quant] Backup status: ${status}`); + lastStatus = status; + } + if (status === 'completed') { + console.error('[quant] Backup completed.'); + break; + } + if (status === 'failed') { + console.error('[quant] Backup failed.'); + process.exit(1); + } + } else { + console.error('[quant] Backup not yet visible in list; waiting...'); + } + + if (Date.now() - start > timeoutMs) { + console.error(`[quant] Backup did not complete within ${Math.round(timeoutMs / 1000)} seconds.`); + process.exit(1); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); +} + +console.error('[quant] Requesting download URL...'); +const downloadResp = await client.backupManagementApi.downloadBackup(orgId, appId, envId, 'database', backupId); +const downloadData = downloadResp.body || {}; +const downloadUrl = downloadData.downloadUrl; +const filename = downloadData.filename || `${backupId}.sql.gz`; + +if (!downloadUrl) { + console.error('[quant] API did not return a download URL.'); + process.exit(1); +} + +const outputDir = path.resolve(downloadDir); +fs.mkdirSync(outputDir, { recursive: true }); +const downloadPath = path.join(outputDir, filename); + +console.error(`[quant] Downloading backup to ${downloadPath}...`); +const response = await fetch(downloadUrl); +if (!response.ok || !response.body) { + console.error(`[quant] Failed to download backup: HTTP ${response.status}`); + process.exit(1); +} +const fileStream = fs.createWriteStream(downloadPath); +await pipeline(response.body, fileStream); +console.error('[quant] Download complete.'); + +const stats = fs.statSync(downloadPath); + +const decompressedPath = path.resolve(decompressDir, decompressFile); +fs.mkdirSync(path.dirname(decompressedPath), { recursive: true }); +fs.rmSync(decompressedPath, { force: true }); + +const lowerCaseFilename = filename.toLowerCase(); +const backupIsLikelyGzip = lowerCaseFilename.endsWith('.gz') || lowerCaseFilename.endsWith('.gzip') || hasGzipMagicNumber(downloadPath); + +if (backupIsLikelyGzip) { + console.error(`[quant] Decompressing backup to ${decompressedPath}...`); + await pipeline( + fs.createReadStream(downloadPath), + createGunzip(), + fs.createWriteStream(decompressedPath) + ); + console.error('[quant] Decompression complete.'); +} else { + console.error(`[quant] Backup is already uncompressed; copying to ${decompressedPath}...`); + await pipeline(fs.createReadStream(downloadPath), fs.createWriteStream(decompressedPath)); + console.error('[quant] Copy complete.'); +} + +const toShellValue = (value) => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'number') { + return String(value); + } + return JSON.stringify(value); +}; + +console.log(`BACKUP_ID=${toShellValue(backupId)}`); +console.log(`DOWNLOAD_PATH=${toShellValue(downloadPath)}`); +console.log(`DECOMPRESSED_PATH=${toShellValue(decompressedPath)}`); +console.log(`DOWNLOAD_SIZE_BYTES=${toShellValue(stats.size)}`); +NODE +)" +if [ -z "${NODE_OUTPUT}" ]; then + echo "Failed to download backup from Quant Cloud." >&2 + exit 1 +fi + +eval "${NODE_OUTPUT}" + +echo "Quant backup ${BACKUP_ID} downloaded to ${DOWNLOAD_PATH}" +echo "Database decompressed to ${DECOMPRESSED_PATH}" diff --git a/scripts/quant/provision-quant.sh b/scripts/quant/provision-quant.sh new file mode 100755 index 000000000..3ddb56b7b --- /dev/null +++ b/scripts/quant/provision-quant.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +## +# Run Drupal provisioning steps tailored for Quant Cloud deployments. +# +# This script runs essential deployment tasks (database updates, configuration +# import, and cache rebuild) while ensuring Drush commands execute against the +# correct Quant site URL. +# +# shellcheck disable=SC1091 + +# Load project environment variables while preserving current exports. +t=$(mktemp) && export -p >"${t}" && set -a && . ./.env && if [ -f ./.env.local ]; then . ./.env.local; fi && set +a && . "${t}" && rm "${t}" && unset t + +set -eu +[ "${DREVOPS_DEBUG-}" = "1" ] && set -x + +# Helper output functions. +note() { printf " %s\n" "${1}"; } +info() { [ "${TERM:-}" != "dumb" ] && tput colors >/dev/null 2>&1 && printf "\033[34m[INFO] %s\033[0m\n" "${1}" || printf "[INFO] %s\n" "${1}"; } +pass() { [ "${TERM:-}" != "dumb" ] && tput colors >/dev/null 2>&1 && printf "\033[32m[ OK ] %s\033[0m\n" "${1}" || printf "[ OK ] %s\n" "${1}"; } +fail() { [ "${TERM:-}" != "dumb" ] && tput colors >/dev/null 2>&1 && printf "\033[31m[FAIL] %s\033[0m\n" "${1}" || printf "[FAIL] %s\n" "${1}"; } + +DREVOPS_PROVISION_SKIP="${DREVOPS_PROVISION_SKIP:-0}" + +if [ "${DREVOPS_PROVISION_SKIP}" = "1" ]; then + pass "Skipped Quant provisioning because DREVOPS_PROVISION_SKIP=1." + exit 0 +fi + +# Detect Quant Cloud environment (variables may be set but empty). +has_quant_environment=0 +if [ -v QUANT_ENV_TYPE ] || [ -v QUANT_ENV_NAME ] || [ -v QUANT_APP_NAME ]; then + has_quant_environment=1 +fi + +if [ "${has_quant_environment}" -ne 1 ]; then + info "Quant Cloud environment not detected; nothing to do." + exit 0 +fi + +info "Detected Quant Cloud environment." + +# Prepare Drush URI using QUANT_ROUTE when available. +drush_uri="" +if [ -v QUANT_ROUTE ] && [ -n "${QUANT_ROUTE}" ]; then + case "${QUANT_ROUTE}" in + http://*|https://*) + drush_uri="${QUANT_ROUTE%/}" + info "Using QUANT_ROUTE for Drush URI: ${drush_uri}" + ;; + *) + note "QUANT_ROUTE is set but is not a full URL. Drush will fall back to the default site context." + ;; + esac +elif [ -v QUANT_ROUTE ]; then + note "QUANT_ROUTE is defined but empty. Drush will fall back to the default site context." +else + note "QUANT_ROUTE not defined. Drush will fall back to the default site context." +fi + +drush_quant() { + if [ -n "${drush_uri}" ]; then + ./vendor/bin/drush -y --uri="${drush_uri}" "$@" + else + ./vendor/bin/drush -y "$@" + fi +} + +# Check whether a comma or space separated list contains the provided token. +contains_token() { + local needle="$1" + local haystack="$2" + local token trimmed + + if [ -z "${needle}" ] || [ -z "${haystack}" ]; then + return 1 + fi + + for token in ${haystack//,/ }; do + # Remove any whitespace characters from the token to allow comma-separated + # or space-separated lists. + trimmed="${token//[[:space:]]/}" + if [ -n "${trimmed}" ] && [ "${needle}" = "${trimmed}" ]; then + return 0 + fi + done + + return 1 +} + +# Resolve the configuration source directory for the current Quant environment. +resolve_config_source() { + local env_type="$1" + local env_name="$2" + local source="${default_config_dir}" + + # Normalise case and trim accidental whitespace. + env_type="${env_type,,}" + env_name="${env_name,,}" + + # Exclude these names from mapping to development by default. + # By convention, Quant won't use these, but keep guard-rails in place. + local exclude_from_dev="${QUANT_CONFIG_ENV_NAMES_EXCLUDE_FROM_DEVELOPMENT:-ci,local}" + + # The rule that determined the mapping; used for logging. + CONFIG_SOURCE_RULE="default" + + # 1) Explicit production mapping takes precedence. + if contains_token "${env_type}" "${production_types}" || contains_token "${env_name}" "${production_names}"; then + source="${production_config_dir}" + CONFIG_SOURCE_RULE="production" + + # 2) Explicit test mapping by name next. + elif contains_token "${env_name}" "${test_names}"; then + source="${test_config_dir}" + CONFIG_SOURCE_RULE="test" + + # 3) Explicit development mapping by type/name. + elif contains_token "${env_type}" "${development_types}" || contains_token "${env_name}" "${development_names}"; then + source="${development_config_dir}" + CONFIG_SOURCE_RULE="development-explicit" + + # 4) Fallback rule: anything not 'ci' or 'local' is development. + elif ! contains_token "${env_name}" "${exclude_from_dev}"; then + source="${development_config_dir}" + CONFIG_SOURCE_RULE="development-fallback" + else + CONFIG_SOURCE_RULE="excluded-from-development" + fi + + # Print both values so caller can capture without relying on subshell state. + printf '%s\n%s' "${source}" "${CONFIG_SOURCE_RULE}" +} + +# Determine if the provided configuration directory exists and contains YAML +# files. Partial or full imports should be skipped when the directory is empty +# to avoid Drush failures. +config_dir_has_files() { + local dir="$1" + + if [ ! -d "${dir}" ]; then + return 1 + fi + + if find "${dir}" -type f -name '*.yml' -print -quit | grep -q .; then + return 0 + fi + + return 1 +} + +info "Running Drupal database updates." +drush_quant updatedb || { fail "Database updates failed."; exit 1; } +pass "Database updates complete." + +default_config_dir="${QUANT_CONFIG_DIR_DEFAULT:-config/default}" +base_config_available=0 + +if config_dir_has_files "${default_config_dir}"; then + info "Importing Drupal configuration from ${default_config_dir}." + drush_quant config:import || { fail "Configuration import failed."; exit 1; } + pass "Configuration import complete." + base_config_available=1 +else + note "Configuration directory ${default_config_dir} is missing or empty; skipping all configuration imports." +fi + +# Apply environment-specific configuration overlays only when base configuration +# exists and has been imported. +quant_env_type="${QUANT_ENV_TYPE:-}" +quant_env_name="${QUANT_ENV_NAME:-}" + +if [ "${base_config_available}" -eq 1 ] && { [ -n "${quant_env_type}" ] || [ -n "${quant_env_name}" ]; }; then + production_config_dir="${QUANT_CONFIG_DIR_PRODUCTION:-${default_config_dir}}" + development_config_dir="${QUANT_CONFIG_DIR_DEVELOPMENT:-config/dev}" + test_config_dir="${QUANT_CONFIG_DIR_TEST:-config/test}" + + production_types="${QUANT_CONFIG_ENV_TYPES_PRODUCTION:-production}" + development_types="${QUANT_CONFIG_ENV_TYPES_DEVELOPMENT:-development}" + + production_names="${QUANT_CONFIG_ENV_NAMES_PRODUCTION:-production}" + development_names="${QUANT_CONFIG_ENV_NAMES_DEVELOPMENT:-develop}" + # Default to recognise both master and uat as test environments. + test_names="${QUANT_CONFIG_ENV_NAMES_TEST:-master,uat}" + + # Capture both the resolved source and the rule without leaking subshell state. + read -r config_source CONFIG_SOURCE_RULE <<<"$(resolve_config_source "${quant_env_type}" "${quant_env_name}")" + + # Always pass an absolute path to Drush to avoid CWD/docroot ambiguity. + if [ -n "${config_source}" ] && [ "${config_source#/}" = "${config_source}" ]; then + # Convert relative path to absolute from repository root. + config_source="$(pwd -P)/${config_source#./}" + fi + + info "Resolved config overlay mapping: type='${quant_env_type:-unset}', name='${quant_env_name:-unset}' -> '${config_source}' (rule: ${CONFIG_SOURCE_RULE:-default})." + + if [ -z "${config_source}" ]; then + note "Environment-specific configuration mapping did not resolve to a directory; skipping partial import." + elif [ "${config_source}" = "${default_config_dir}" ]; then + note "Environment-specific configuration maps to ${config_source} (rule: ${CONFIG_SOURCE_RULE:-default}); base configuration already imported." + elif config_dir_has_files "${config_source}"; then + message_suffix="" + if [ -n "${quant_env_name}" ]; then + message_suffix=", name: ${quant_env_name}" + fi + info "Importing environment-specific configuration from ${config_source} (type: ${quant_env_type:-unset}${message_suffix})." + drush_quant config:import --partial --source="${config_source}" || { + fail "Environment-specific configuration import failed."; exit 1; + } + pass "Environment-specific configuration import complete." + else + note "Environment-specific configuration directory ${config_source} is missing or empty; skipping partial import." + fi +elif [ "${base_config_available}" -eq 0 ]; then + note "Skipping environment-specific configuration import because base configuration is unavailable." +else + note "QUANT environment variables not provided; skipping environment-specific configuration import." +fi + +info "Rebuilding Drupal caches." +drush_quant cache:rebuild || { fail "Cache rebuild failed."; exit 1; } +pass "Cache rebuild complete." + +pass "Quant Cloud provisioning finished successfully."