From 9a891fd5232bed7c126cdf9826bda37aa62bf388 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 14 Nov 2025 16:35:17 +0100 Subject: [PATCH] deps version bump + frontend --- .woodpecker/frontend.yml | 61 ++++++++++++ ci/prod/.env.runtime | 12 +++ ci/prod/compose/frontend.dockerfile | 53 +++++++++++ ci/prod/compose/frontend.yml | 40 ++++++++ ci/prod/scripts/deploy/frontend.sh | 95 +++++++++++++++++++ ci/scripts/common/bump_version.sh | 2 +- ci/scripts/frontend/build-image.sh | 85 +++++++++++++++++ ci/scripts/frontend/deploy.sh | 55 +++++++++++ frontend/pweb/caddy/Caddyfile | 14 +++ frontend/pweb/entrypoint.sh | 4 +- .../settings/profile/account/avatar.dart | 2 +- frontend/pweb/lib/utils/payment/dropdown.dart | 2 +- 12 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 .woodpecker/frontend.yml create mode 100644 ci/prod/compose/frontend.dockerfile create mode 100644 ci/prod/compose/frontend.yml create mode 100755 ci/prod/scripts/deploy/frontend.sh create mode 100755 ci/scripts/frontend/build-image.sh create mode 100755 ci/scripts/frontend/deploy.sh create mode 100644 frontend/pweb/caddy/Caddyfile diff --git a/.woodpecker/frontend.yml b/.woodpecker/frontend.yml new file mode 100644 index 0000000..6002256 --- /dev/null +++ b/.woodpecker/frontend.yml @@ -0,0 +1,61 @@ +matrix: + include: + - FRONTEND_IMAGE_PATH: frontend/service + FRONTEND_DOCKERFILE: ci/prod/compose/frontend.dockerfile + FRONTEND_ENV: prod + +when: + - event: push + branch: main + +steps: + - name: version + image: alpine:latest + commands: + - set -euo pipefail 2>/dev/null || set -eu + - apk add --no-cache git + - GIT_REV="$(git rev-parse --short HEAD)" + - BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)" + - APP_V="$(cat version)" + - BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + - BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}" + - printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \ + "$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version + + - name: secrets + image: alpine:latest + depends_on: [ version ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash coreutils openssh-keygen curl sed python3 + - mkdir -p secrets + - ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600 + - base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY + - chmod 600 secrets/SSH_KEY + - ssh-keygen -y -f secrets/SSH_KEY >/dev/null + - ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER + - ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD + + - name: build-image + image: gcr.io/kaniko-project/executor:debug + depends_on: [ version, secrets ] + commands: + - sh ci/scripts/frontend/build-image.sh + + - name: deploy + image: alpine:latest + depends_on: [ secrets, build-image ] + environment: + VAULT_ADDR: { from_secret: VAULT_ADDR } + VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE } + VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID } + commands: + - set -euo pipefail + - apk add --no-cache bash openssh-client rsync coreutils curl sed python3 + - mkdir -p /root/.ssh + - install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa + - sh ci/scripts/frontend/deploy.sh diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index 6b5d0c4..497b2ad 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -16,7 +16,11 @@ AMPLI_ENVIRONMENT=production API_PROTOCOL=https SERVICE_HOST=app.sendico.io API_ENDPOINT=https://app.sendico.io/api +WS_PROTOCOL=wss WS_ENDPOINT=wss://app.sendico.io/ws +AMPLITUDE_SECRET=c3d75b3e2520d708440acbb16b923e79 +DEFAULT_LOCALE=en +DEFAULT_CURRENCY=EUR PBM_S3_ENDPOINT=https://s3.sendico.io PBM_S3_REGION=eu-central-1 @@ -105,6 +109,14 @@ NOTIFICATION_COMPOSE_PROJECT=sendico-notification NOTIFICATION_SERVICE_NAME=sendico_notification NOTIFICATION_HTTP_PORT=8081 +# Frontend web +FRONTEND_DIR=frontend +FRONTEND_COMPOSE_PROJECT=sendico-frontend +FRONTEND_SERVICE_NAME=sendico_frontend +FRONTEND_HTTP_PORT=80 +FRONTEND_HTTPS_PORT=443 +CADDY_ACME_EMAIL=infra@sendico.io + # BFF service BFF_DIR=bff BFF_COMPOSE_PROJECT=sendico-bff diff --git a/ci/prod/compose/frontend.dockerfile b/ci/prod/compose/frontend.dockerfile new file mode 100644 index 0000000..2794358 --- /dev/null +++ b/ci/prod/compose/frontend.dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.7 + +FROM dart:latest AS web_builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + unzip \ + xz-utils \ + libglu1-mesa \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -ms /bin/bash flutteruser + +ENV PATH="/home/flutteruser/flutter/bin:${PATH}" + +USER flutteruser +WORKDIR /home/flutteruser + +RUN git clone --branch stable --depth 1 https://github.com/flutter/flutter.git \ + && flutter config --enable-web \ + && flutter precache --web \ + && flutter upgrade + +COPY --chown=flutteruser:flutteruser frontend /home/flutteruser/app + +# Build shared package code generation +WORKDIR /home/flutteruser/app/pshared +RUN flutter clean \ + && flutter pub get \ + && flutter pub run build_runner build --delete-conflicting-outputs + +# Build the web client +WORKDIR /home/flutteruser/app/pweb +RUN flutter clean \ + && flutter pub get \ + && flutter build web --release --no-tree-shake-icons + +FROM caddy:alpine AS runtime + +WORKDIR /usr/share/pweb + +COPY frontend/pweb/entrypoint.sh /entrypoint.sh +COPY frontend/pweb/caddy/Caddyfile /etc/caddy/Caddyfile +COPY --from=web_builder /home/flutteruser/app/pweb/build/web /usr/share/pweb + +RUN chmod +x /entrypoint.sh + +EXPOSE 80 443 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/ci/prod/compose/frontend.yml b/ci/prod/compose/frontend.yml new file mode 100644 index 0000000..9a1ef16 --- /dev/null +++ b/ci/prod/compose/frontend.yml @@ -0,0 +1,40 @@ +# Compose v2 - Frontend web client + +x-common-env: &common-env + env_file: + - ../env/.env.runtime + - ../env/.env.version + +networks: + sendico-net: + external: true + name: sendico-net + +services: + sendico_frontend: + <<: *common-env + container_name: sendico-frontend + restart: unless-stopped + image: ${REGISTRY_URL}/frontend/service:${APP_V} + pull_policy: always + environment: + WS_PROTOCOL: ${WS_PROTOCOL} + WS_ENDPOINT: ${WS_ENDPOINT} + API_PROTOCOL: ${API_PROTOCOL} + SERVICE_HOST: ${SERVICE_HOST} + API_ENDPOINT: ${API_ENDPOINT} + AMPLITUDE_SECRET: ${AMPLITUDE_SECRET} + DEFAULT_LOCALE: ${DEFAULT_LOCALE} + DEFAULT_CURRENCY: ${DEFAULT_CURRENCY} + CADDY_ACME_EMAIL: ${CADDY_ACME_EMAIL} + ports: + - "0.0.0.0:${FRONTEND_HTTP_PORT}:80" + - "0.0.0.0:${FRONTEND_HTTPS_PORT}:443" + healthcheck: + test: ["CMD-SHELL","curl -sf http://localhost:80/ >/dev/null"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - sendico-net diff --git a/ci/prod/scripts/deploy/frontend.sh b/ci/prod/scripts/deploy/frontend.sh new file mode 100755 index 0000000..32433df --- /dev/null +++ b/ci/prod/scripts/deploy/frontend.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && set -x +trap 'echo "[deploy-frontend] error at line $LINENO" >&2' ERR + +: "${REMOTE_BASE:?missing REMOTE_BASE}" +: "${SSH_USER:?missing SSH_USER}" +: "${SSH_HOST:?missing SSH_HOST}" +: "${FRONTEND_DIR:?missing FRONTEND_DIR}" +: "${FRONTEND_COMPOSE_PROJECT:?missing FRONTEND_COMPOSE_PROJECT}" +: "${FRONTEND_SERVICE_NAME:?missing FRONTEND_SERVICE_NAME}" + +REMOTE_DIR="${REMOTE_BASE%/}/${FRONTEND_DIR}" +REMOTE_TARGET="${SSH_USER}@${SSH_HOST}" +COMPOSE_FILE="frontend.yml" +SERVICE_NAMES="${FRONTEND_SERVICE_NAME}" + +if [[ ! -s .env.version ]]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +SSH_OPTS=( + -i /root/.ssh/id_rsa + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o LogLevel=ERROR + -q +) +if [[ "${DEBUG_DEPLOY:-0}" = "1" ]]; then + SSH_OPTS=("${SSH_OPTS[@]/-q/}" -vv) +fi + +RSYNC_FLAGS=(-az --delete) +[[ "${DEBUG_DEPLOY:-0}" = "1" ]] && RSYNC_FLAGS=(-avz --delete) + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p ${REMOTE_DIR}/{compose,env}" + +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/compose/ "$REMOTE_TARGET:${REMOTE_DIR}/compose/" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" ci/prod/.env.runtime "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.runtime" +rsync "${RSYNC_FLAGS[@]}" -e "ssh ${SSH_OPTS[*]}" .env.version "$REMOTE_TARGET:${REMOTE_DIR}/env/.env.version" + +SERVICES_LINE="${SERVICE_NAMES}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ + REMOTE_DIR="$REMOTE_DIR" \ + COMPOSE_FILE="$COMPOSE_FILE" \ + COMPOSE_PROJECT="$FRONTEND_COMPOSE_PROJECT" \ + SERVICES_LINE="$SERVICES_LINE" \ + bash -s <<'EOSSH' +set -euo pipefail +cd "${REMOTE_DIR}/compose" +set -a +. ../env/.env.runtime +load_kv_file() { + local file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + if printf '%s' "$line" | grep -Eq '^[[:alpha:]_][[:alnum:]_]*='; then + local key="${line%%=*}" + local value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ -n "$key" ]]; then + export "$key=$value" + fi + fi + done <"$file" +} +load_kv_file ../env/.env.version +set +a + +COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" +export COMPOSE_PROJECT_NAME +read -r -a SERVICES <<<"${SERVICES_LINE}" + +pull_cmd=(docker compose -f "$COMPOSE_FILE" pull) +up_cmd=(docker compose -f "$COMPOSE_FILE" up -d --remove-orphans) +ps_cmd=(docker compose -f "$COMPOSE_FILE" ps) +if [[ "${#SERVICES[@]}" -gt 0 ]]; then + pull_cmd+=("${SERVICES[@]}") + up_cmd+=("${SERVICES[@]}") + ps_cmd+=("${SERVICES[@]}") +fi + +"${pull_cmd[@]}" +"${up_cmd[@]}" +"${ps_cmd[@]}" + +date -Is > .last_deploy +logger -t "deploy-${COMPOSE_PROJECT_NAME}" "${COMPOSE_PROJECT_NAME} deployed at $(date -Is) in ${REMOTE_DIR}" +EOSSH diff --git a/ci/scripts/common/bump_version.sh b/ci/scripts/common/bump_version.sh index e92261f..41cdf30 100755 --- a/ci/scripts/common/bump_version.sh +++ b/ci/scripts/common/bump_version.sh @@ -8,7 +8,7 @@ VERSION_FILE="./version" if [ ! -f "${VERSION_FILE}" ]; then if git cat-file -e "HEAD:version" 2>/dev/null; then echo "[bump-version] version file missing in workspace, restoring from HEAD" >&2 - git checkout -- version + git show "HEAD:version" > "${VERSION_FILE}" else echo "[bump-version] version file not found: ${VERSION_FILE}" >&2 exit 1 diff --git a/ci/scripts/frontend/build-image.sh b/ci/scripts/frontend/build-image.sh new file mode 100755 index 0000000..2952236 --- /dev/null +++ b/ci/scripts/frontend/build-image.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +FRONTEND_ENV_NAME="${FRONTEND_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${FRONTEND_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[frontend-build] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +REGISTRY_URL="${REGISTRY_URL:?missing REGISTRY_URL}" +APP_V="${APP_V:?missing APP_V}" +FRONTEND_DOCKERFILE="${FRONTEND_DOCKERFILE:?missing FRONTEND_DOCKERFILE}" +FRONTEND_IMAGE_PATH="${FRONTEND_IMAGE_PATH:?missing FRONTEND_IMAGE_PATH}" + +REGISTRY_HOST="${REGISTRY_URL#http://}" +REGISTRY_HOST="${REGISTRY_HOST#https://}" +REGISTRY_USER="$(cat secrets/REGISTRY_USER)" +REGISTRY_PASSWORD="$(cat secrets/REGISTRY_PASSWORD)" +: "${REGISTRY_USER:?missing registry user}" +: "${REGISTRY_PASSWORD:?missing registry password}" + +mkdir -p /kaniko/.docker +AUTH_B64="$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" +cat </kaniko/.docker/config.json +{ + "auths": { + "https://${REGISTRY_HOST}": { "auth": "${AUTH_B64}" } + } +} +EOF + +BUILD_CONTEXT="${FRONTEND_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + +/kaniko/executor \ + --context "${BUILD_CONTEXT}" \ + --dockerfile "${FRONTEND_DOCKERFILE}" \ + --destination "${REGISTRY_URL}/${FRONTEND_IMAGE_PATH}:${APP_V}" \ + --build-arg APP_VERSION="${APP_V}" \ + --build-arg GIT_REV="${GIT_REV}" \ + --build-arg BUILD_BRANCH="${BUILD_BRANCH}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + --build-arg BUILD_USER="${BUILD_USER}" \ + --single-snapshot diff --git a/ci/scripts/frontend/deploy.sh b/ci/scripts/frontend/deploy.sh new file mode 100755 index 0000000..97d02a0 --- /dev/null +++ b/ci/scripts/frontend/deploy.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +if ! set -o pipefail 2>/dev/null; then + : +fi + +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "${REPO_ROOT}" + +sh ci/scripts/common/ensure_env_version.sh + +normalize_env_file() { + file="$1" + tmp="${file}.tmp.$$" + tr -d '\r' <"$file" >"$tmp" + mv "$tmp" "$file" +} + +load_env_file() { + file="$1" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) continue ;; + esac + key="${line%%=*}" + value="${line#*=}" + key="$(printf '%s' "$key" | tr -d '[:space:]')" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + export "$key=$value" + done <"$file" +} + +FRONTEND_ENV_NAME="${FRONTEND_ENV:-prod}" +RUNTIME_ENV_FILE="./ci/${FRONTEND_ENV_NAME}/.env.runtime" + +if [ ! -f "${RUNTIME_ENV_FILE}" ]; then + echo "[frontend-deploy] runtime env file not found: ${RUNTIME_ENV_FILE}" >&2 + exit 1 +fi + +normalize_env_file "${RUNTIME_ENV_FILE}" +normalize_env_file ./.env.version + +load_env_file "${RUNTIME_ENV_FILE}" +load_env_file ./.env.version + +if [ ! -s .env.version ]; then + echo ".env.version is missing; run version step first" >&2 + exit 66 +fi + +bash ci/prod/scripts/bootstrap/network.sh +bash ci/prod/scripts/deploy/frontend.sh diff --git a/frontend/pweb/caddy/Caddyfile b/frontend/pweb/caddy/Caddyfile new file mode 100644 index 0000000..0c51b84 --- /dev/null +++ b/frontend/pweb/caddy/Caddyfile @@ -0,0 +1,14 @@ +{ + email {$CADDY_ACME_EMAIL} + auto_https on +} + +{$SERVICE_HOST} { + root * /usr/share/pweb + encode zstd gzip + try_files {path} /index.html + file_server + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + } +} diff --git a/frontend/pweb/entrypoint.sh b/frontend/pweb/entrypoint.sh index 542be68..977f5d3 100755 --- a/frontend/pweb/entrypoint.sh +++ b/frontend/pweb/entrypoint.sh @@ -20,6 +20,6 @@ replace_env_var "DEFAULT_LOCALE" replace_env_var "DEFAULT_CURRENCY" echo "Passing by launch command" -# Execute the passed command (e.g., starting Nginx) +# Execute the passed command (e.g., starting Caddy) # exec "$@" -exec nginx -g 'daemon off;' \ No newline at end of file +exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/frontend/pweb/lib/pages/settings/profile/account/avatar.dart b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart index 0f80127..9d64c43 100644 --- a/frontend/pweb/lib/pages/settings/profile/account/avatar.dart +++ b/frontend/pweb/lib/pages/settings/profile/account/avatar.dart @@ -64,7 +64,7 @@ class _AvatarTileState extends State { width: _avatarSize, height: _avatarSize, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPlaceholder(), + errorBuilder: (_, _, _) => _buildPlaceholder(), ) : _buildPlaceholder(), ), diff --git a/frontend/pweb/lib/utils/payment/dropdown.dart b/frontend/pweb/lib/utils/payment/dropdown.dart index c56bd8e..0358db0 100644 --- a/frontend/pweb/lib/utils/payment/dropdown.dart +++ b/frontend/pweb/lib/utils/payment/dropdown.dart @@ -36,7 +36,7 @@ class _PaymentMethodDropdownState extends State { Widget build(BuildContext context) { return DropdownButtonFormField( dropdownColor: Theme.of(context).colorScheme.onSecondary, - value: _selectedMethod, + initialValue: _selectedMethod, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.whereGetMoney, border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),