Updated release scripts. Part 2.
Thanks to xet7 !
This commit is contained in:
1 parent
f660817627
commit
07eb062474
15 files changed
+2523
-240
No files matched your search
@@ -59,6 +59,12 @@ jobs:
|
||||
echo "GHEOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
|
||||
# Meteor 3.4 installer supports only: Linux x86_64, Linux aarch64, macOS, Windows.
|
||||
# It explicitly rejects armhf, s390x, and ppc64le at install time.
|
||||
# Therefore the full Meteor build always runs here on x86_64.
|
||||
# All other Linux architectures then rebuild only the native npm modules
|
||||
# from this bundle (much faster than a full Meteor build).
|
||||
# Node.js version must match what Meteor 3.4 bundles: 22.22.0 (ABI 127).
|
||||
build-amd64:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -86,11 +92,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
|
||||
|
||||
- name: Install Node.js 22.22.0
|
||||
run: |
|
||||
sudo npm install -g n
|
||||
sudo n 22.22.0
|
||||
hash -r
|
||||
# Pin to Node 22.22.0 — the exact version bundled by Meteor 3.4.
|
||||
# Using the same version for build and npm rebuild ensures consistent native module ABI.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.22.0'
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
@@ -118,6 +124,10 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# ─── 3. Build arm64 bundle (rebuild native modules natively) ─────────────────
|
||||
# Meteor 3.4 does support Linux aarch64, but running a full Meteor build a
|
||||
# second time would double the build time for the same result. npm rebuild on
|
||||
# the native arm64 GitHub Actions runner is much faster and produces the same
|
||||
# platform-specific native modules.
|
||||
build-arm64:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -163,6 +173,10 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4. Build s390x bundle (rebuild native modules via QEMU container) ───────
|
||||
# Meteor 3.4 does NOT support s390x (installer exits with error on that arch).
|
||||
# GitHub Actions has no native s390x runners.
|
||||
# Solution: QEMU emulation + node:22.22.0-slim (s390x variant) for npm rebuild.
|
||||
# node:22.22.0-slim is pinned to match Meteor 3.4's bundled Node.js version exactly.
|
||||
build-s390x:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -186,11 +200,11 @@ jobs:
|
||||
- name: Rebuild native modules for s390x in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
# Run npm rebuild inside a native s390x Node.js 22 container
|
||||
# node:22.22.0-slim s390x — same Node.js version as Meteor 3.4 bundled
|
||||
docker run --rm --platform linux/s390x \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:22-slim \
|
||||
node:22.22.0-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm rebuild"
|
||||
|
||||
- name: Clean up and create s390x zip
|
||||
@@ -209,6 +223,107 @@ jobs:
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-s390x.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4b. Build ppc64le bundle (rebuild native modules via QEMU container) ────
|
||||
# Meteor 3.4 does NOT support ppc64le (installer exits with error on that arch).
|
||||
# GitHub Actions has no native ppc64le runners.
|
||||
# Solution: QEMU emulation + node:22.22.0-slim (ppc64le variant) for npm rebuild.
|
||||
build-ppc64le:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Set up QEMU for ppc64le
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: ppc64le
|
||||
|
||||
- name: Install unzip/zip
|
||||
run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Rebuild native modules for ppc64le in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
# node:22.22.0-slim ppc64le — same Node.js version as Meteor 3.4 bundled
|
||||
docker run --rm --platform linux/ppc64le \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:22.22.0-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm rebuild"
|
||||
|
||||
- name: Clean up and create ppc64le zip
|
||||
run: |
|
||||
cd bundle
|
||||
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '.*.swp' -delete 2>/dev/null || true
|
||||
find . -name '*.swp' -delete 2>/dev/null || true
|
||||
cd ..
|
||||
zip -r wekan-${VERSION}-ppc64le.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundle-ppc64le
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 4c. Build armhf bundle (rebuild native modules via QEMU container) ──────
|
||||
# Meteor 3.4 does NOT support arm/v7 (armhf). Installer explicitly rejects it.
|
||||
# GitHub Actions has no native arm/v7 runners.
|
||||
# Solution: QEMU emulation + node:22.22.0-slim (arm/v7 variant) for npm rebuild.
|
||||
# arm/v7 under QEMU is slowest — timeout set to 120 min.
|
||||
build-armhf:
|
||||
needs: [prepare, build-amd64]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Set up QEMU for arm/v7
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm
|
||||
|
||||
- name: Install unzip/zip
|
||||
run: sudo apt-get update && sudo apt-get install -y unzip zip
|
||||
|
||||
- name: Download amd64 bundle
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bundle-amd64
|
||||
|
||||
- name: Rebuild native modules for armhf in container
|
||||
run: |
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
# node:22.22.0-slim arm/v7 — same Node.js version as Meteor 3.4 bundled
|
||||
docker run --rm --platform linux/arm/v7 \
|
||||
-v "$(pwd)/bundle:/bundle" \
|
||||
-w /bundle/programs/server \
|
||||
node:22.22.0-slim \
|
||||
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm rebuild"
|
||||
|
||||
- name: Clean up and create armhf zip
|
||||
run: |
|
||||
cd bundle
|
||||
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '.*.swp' -delete 2>/dev/null || true
|
||||
find . -name '*.swp' -delete 2>/dev/null || true
|
||||
cd ..
|
||||
zip -r wekan-${VERSION}-armhf.zip bundle
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundle-armhf
|
||||
path: wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
retention-days: 1
|
||||
|
||||
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
|
||||
build-win64:
|
||||
needs: [prepare, build-amd64]
|
||||
@@ -254,7 +369,7 @@ jobs:
|
||||
|
||||
# ─── 6. Create GitHub Release and upload all bundles ────────────────────────
|
||||
release:
|
||||
needs: [prepare, build-amd64, build-arm64, build-s390x, build-win64]
|
||||
needs: [prepare, build-amd64, build-arm64, build-s390x, build-ppc64le, build-armhf, build-win64]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -274,6 +389,8 @@ jobs:
|
||||
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-s390x.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-ppc64le.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-armhf.zip
|
||||
wekan-${{ needs.prepare.outputs.version }}-win64.zip
|
||||
|
||||
# ─── 7. Build and push multi-platform Docker image ──────────────────────────
|
||||
@@ -315,7 +432,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/s390x
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x
|
||||
push: true
|
||||
build-args: VERSION=${{ needs.prepare.outputs.version }}
|
||||
tags: |
|
||||
@@ -328,7 +445,11 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ─── 8. Build and publish snap package ──────────────────────────────────────
|
||||
# ─── 8. Build and publish snap packages (all architectures via Launchpad) ────
|
||||
# Uses snapcraft remote-build on Launchpad's native builders (more reliable
|
||||
# than QEMU for snap builds). Each architecture bundles its own database:
|
||||
# amd64, arm64 — MongoDB Community 8.0 (OpLog via --replSet rs0)
|
||||
# armhf, s390x, ppc64el — FerretDB 2 + PostgreSQL 16 (OpLog via replica set)
|
||||
snap:
|
||||
needs: [prepare, release]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -337,36 +458,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Update snapcraft.yaml version and download URLs
|
||||
run: |
|
||||
# Update the version field
|
||||
sed -i "s/^version: '[0-9.]*'/version: '${VERSION}'/" snapcraft.yaml
|
||||
# Update all download URLs that reference the old version
|
||||
OLD_VER=$(grep -oP "download/v\K[0-9.]+" snapcraft.yaml | head -1)
|
||||
if [ -n "$OLD_VER" ] && [ "$OLD_VER" != "$VERSION" ]; then
|
||||
sed -i "s|download/v${OLD_VER}/wekan-${OLD_VER}|download/v${VERSION}/wekan-${VERSION}|g" snapcraft.yaml
|
||||
fi
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Build snap
|
||||
uses: snapcore/action-build@v1
|
||||
id: snap-build
|
||||
|
||||
- name: Upload snap artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: snap-amd64
|
||||
path: ${{ steps.snap-build.outputs.snap }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish snap to Snap Store (stable channel)
|
||||
uses: snapcore/action-publish@v1
|
||||
- name: Remote-build snap for all architectures via Launchpad
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }}
|
||||
with:
|
||||
snap: ${{ steps.snap-build.outputs.snap }}
|
||||
release: stable
|
||||
run: |
|
||||
snapcraft remote-build \
|
||||
--build-for=amd64,arm64,armhf,s390x,ppc64el \
|
||||
--launchpad-accept-public-upload
|
||||
|
||||
- name: Upload snap to GitHub Release
|
||||
- name: Upload all snap files as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: snaps-all-arches
|
||||
path: "*.snap"
|
||||
retention-days: 30
|
||||
|
||||
# Publish to candidate, beta, and edge — NOT stable.
|
||||
# Stable channel holds the old WeKan version until database migrations are ready.
|
||||
# Promote to stable manually via the Snap Store dashboard when migrations exist.
|
||||
- name: Publish all snaps to Snap Store (candidate, beta, edge)
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }}
|
||||
run: |
|
||||
for snap_file in *.snap; do
|
||||
echo "Publishing $snap_file ..."
|
||||
snapcraft upload --release=candidate,beta,edge "$snap_file"
|
||||
done
|
||||
|
||||
- name: Upload all snaps to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ steps.snap-build.outputs.snap }}
|
||||
files: "*.snap"
|
||||
+10
-6
@@ -4,8 +4,9 @@ LABEL org.opencontainers.image.ref.name="ubuntu"
|
||||
LABEL org.opencontainers.image.version="24.04"
|
||||
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
|
||||
# TARGETARCH is automatically provided by Docker Buildx
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=8.42
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -13,7 +14,7 @@ ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essen
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v22.22.0 \
|
||||
NODE_VERSION=v22.22.2 \
|
||||
METEOR_RELEASE=METEOR@3.4 \
|
||||
USE_EDGE=false \
|
||||
METEOR_EDGE=1.5-beta.17 \
|
||||
@@ -169,11 +170,14 @@ apt-get update --assume-yes
|
||||
apt-get upgrade --assume-yes
|
||||
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
|
||||
|
||||
# Multi-arch mapping logic
|
||||
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name
|
||||
# arm/v7 uses TARGETARCH=arm; armhf has no MongoDB Community -> uses FerretDB
|
||||
case "${TARGETARCH}" in
|
||||
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
|
||||
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
|
||||
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
|
||||
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
|
||||
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
|
||||
"arm") NODE_ARCH="armv7l" WEKAN_ARCH="armhf" ;;
|
||||
"ppc64le") NODE_ARCH="ppc64le" WEKAN_ARCH="ppc64le" ;;
|
||||
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# WeKan with FerretDB 2 + PostgreSQL (DocumentDB extension)
|
||||
#
|
||||
# Use this compose file instead of docker-compose.yml on platforms where
|
||||
# MongoDB Community Edition is not available, or when you prefer PostgreSQL
|
||||
# as the backing store.
|
||||
#
|
||||
# Supported on all platforms where Node.js 22 runs:
|
||||
# linux/amd64, linux/arm64, linux/arm/v7, linux/ppc64le, linux/s390x
|
||||
#
|
||||
# Based on: docs/Databases/FerretDB2-PostgreSQL.md
|
||||
#
|
||||
# Setup:
|
||||
# docker compose -f docker-compose-ferretdb.yml up -d
|
||||
#
|
||||
# To stop:
|
||||
# docker compose -f docker-compose-ferretdb.yml stop
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
|
||||
# PostgreSQL 17 with DocumentDB extension (FerretDB 2 backing store)
|
||||
# ghcr.io/ferretdb/postgres-documentdb is the official PostgreSQL image
|
||||
# pre-installed with the pg_documentdb extension required by FerretDB 2.
|
||||
postgres-documentdb:
|
||||
image: ghcr.io/ferretdb/postgres-documentdb:17
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ferretdb
|
||||
POSTGRES_PASSWORD: ${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}
|
||||
POSTGRES_DB: ferretdb
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- wekan-ferretdb
|
||||
|
||||
# FerretDB 2 — MongoDB-compatible protocol layer over PostgreSQL
|
||||
ferretdb:
|
||||
image: ghcr.io/ferretdb/ferretdb:2
|
||||
restart: always
|
||||
environment:
|
||||
FERRETDB_POSTGRESQL_URL: postgres://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@postgres-documentdb/ferretdb
|
||||
ports:
|
||||
# Expose the MongoDB-compatible port only on localhost for security.
|
||||
# Remove "127.0.0.1:" prefix if WeKan runs on a different host.
|
||||
- 127.0.0.1:27017:27017
|
||||
depends_on:
|
||||
- postgres-documentdb
|
||||
networks:
|
||||
- wekan-ferretdb
|
||||
|
||||
# WeKan application
|
||||
wekan-app:
|
||||
image: ghcr.io/wekan/wekan:latest
|
||||
restart: always
|
||||
networks:
|
||||
- wekan-ferretdb
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
# FerretDB speaks the MongoDB wire protocol on port 27017.
|
||||
# The database name "wekan" is created automatically on first run.
|
||||
MONGO_URL: mongodb://ferretdb:${FERRETDB_POSTGRES_PASSWORD:-ferretdb_secret}@ferretdb:27017/wekan?authMechanism=PLAIN
|
||||
ROOT_URL: ${ROOT_URL:-http://localhost:8080}
|
||||
PORT: 8080
|
||||
WITH_API: true
|
||||
WRITABLE_PATH: /data
|
||||
# Mail settings (optional)
|
||||
MAIL_URL: ${MAIL_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-}
|
||||
volumes:
|
||||
- wekan-data:/data
|
||||
depends_on:
|
||||
- ferretdb
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
wekan-data:
|
||||
|
||||
networks:
|
||||
wekan-ferretdb:
|
||||
driver: bridge
|
||||
+155
-26
@@ -86,26 +86,155 @@
|
||||
|
||||
services:
|
||||
|
||||
wekandb:
|
||||
#-------------------------------------------------------------------------------------
|
||||
# ==== MONGODB FROM DOCKER HUB ====
|
||||
image: mongo:7
|
||||
#-------------------------------------------------------------------------------------
|
||||
container_name: wekan-db
|
||||
#-------------------------------------------------------------------------------------
|
||||
# ==== DATABASE: FerretDB 2 + PostgreSQL 16 ====
|
||||
# FerretDB is a MongoDB-compatible proxy that stores data in PostgreSQL.
|
||||
# It supports the full MongoDB wire protocol used by Meteor/WeKan,
|
||||
# including OpLog via replication-set emulation (--replication-set-name=rs0).
|
||||
#
|
||||
# Upgrade path from MongoDB:
|
||||
# The wekan-migrate service below runs ONCE before wekan starts.
|
||||
# It copies all data from an existing MongoDB instance (if reachable at
|
||||
# SOURCE_MONGO_URL) to FerretDB, upgrades the schema to v8.43, and
|
||||
# moves GridFS / CollectionFS binary files to the filesystem.
|
||||
# Progress is visible in a browser while migration runs.
|
||||
#
|
||||
# If you are upgrading from a MongoDB-based install:
|
||||
# 1. Keep your old MongoDB container running (or start it separately).
|
||||
# 2. Set SOURCE_MONGO_URL in the wekan-migrate environment below.
|
||||
# 3. Run: docker compose up -d
|
||||
# 4. Watch migration at http://localhost:8080/ until "completed".
|
||||
# 5. Shut down old MongoDB once satisfied.
|
||||
#
|
||||
# If this is a fresh install, wekan-migrate exits instantly (nothing to migrate).
|
||||
#-------------------------------------------------------------------------------------
|
||||
|
||||
wekan-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: wekan-postgres
|
||||
restart: always
|
||||
networks:
|
||||
- wekan-tier
|
||||
expose:
|
||||
- 5432
|
||||
environment:
|
||||
- POSTGRES_DB=wekan
|
||||
- POSTGRES_USER=wekan
|
||||
- POSTGRES_PASSWORD=wekan
|
||||
# Tune for a kanban workload (low connection count, moderate write rate)
|
||||
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- wekan-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wekan -d wekan"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
wekan-ferretdb:
|
||||
image: ghcr.io/ferretdb/ferretdb:2
|
||||
container_name: wekan-ferretdb
|
||||
restart: always
|
||||
# command: mongod --oplogSize 128
|
||||
# Syslog: mongod --syslog --oplogSize 128 --quiet
|
||||
# Disable MongoDB logs:
|
||||
command: mongod --logpath /dev/null --oplogSize 128 --quiet
|
||||
networks:
|
||||
- wekan-tier
|
||||
expose:
|
||||
- 27017
|
||||
environment:
|
||||
# FerretDB stores everything in PostgreSQL
|
||||
- FERRETDB_POSTGRESQL_URL=postgresql://wekan:wekan@wekan-postgres:5432/wekan
|
||||
# Emulate MongoDB replica-set for Meteor OpLog (real-time updates)
|
||||
- FERRETDB_REPLICATION_SET_NAME=rs0
|
||||
# Suppress verbose debug output
|
||||
- FERRETDB_LOG_LEVEL=warn
|
||||
depends_on:
|
||||
wekan-postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "ferretdb", "--version"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
#-------------------------------------------------------------------------------------
|
||||
# ==== MIGRATION SERVICE ====
|
||||
# Runs once, before WeKan starts. Exits 0 on success, 1 on fatal error.
|
||||
# WeKan only starts after this service exits successfully
|
||||
# (depends_on condition: service_completed_successfully).
|
||||
#
|
||||
# On a FRESH install (no SOURCE_MONGO_URL or source unreachable) it exits
|
||||
# immediately — no penalty for new deployments.
|
||||
#
|
||||
# SOURCE_MONGO_URL: point to your old MongoDB container/host.
|
||||
# e.g. mongodb://wekandb:27017/wekan
|
||||
# mongodb://192.168.1.10:27017/wekan
|
||||
# Leave it unset or empty to skip migration.
|
||||
#-------------------------------------------------------------------------------------
|
||||
wekan-migrate:
|
||||
image: node:22.22.2-slim
|
||||
container_name: wekan-migrate
|
||||
restart: "no"
|
||||
networks:
|
||||
- wekan-tier
|
||||
working_dir: /migrate
|
||||
# Install the mongodb driver then run the migration script.
|
||||
# npm ci is used if package-lock.json exists; npm install otherwise.
|
||||
command: >
|
||||
sh -c "
|
||||
cd /migrate &&
|
||||
npm install --omit=dev --prefer-offline 2>/dev/null || npm install --omit=dev &&
|
||||
node migrate-mongodb-to-ferretdb.mjs
|
||||
"
|
||||
environment:
|
||||
# ---- Source (old MongoDB) ----
|
||||
# Set to your old MongoDB URL to trigger data migration.
|
||||
# Leave empty for a fresh install (migration is skipped automatically).
|
||||
- SOURCE_MONGO_URL=
|
||||
#- SOURCE_MONGO_URL=mongodb://wekandb:27017/wekan
|
||||
|
||||
# ---- Target (FerretDB) ----
|
||||
- TARGET_MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
|
||||
# ---- File storage ----
|
||||
# Must match the WRITABLE_PATH used by the wekan service.
|
||||
- WRITABLE_PATH=/data
|
||||
|
||||
# ---- Progress dashboard ----
|
||||
# Browse to http://localhost:8080/ while migration runs.
|
||||
- MIGRATION_PORT=8080
|
||||
|
||||
# ---- Tuning (optional) ----
|
||||
#- BATCH_SIZE=200
|
||||
#- DRY_RUN=true # Log only, no writes — useful for testing
|
||||
#- FORCE_MIGRATE=true # Re-run even if already marked complete
|
||||
ports:
|
||||
- 8080:8080
|
||||
depends_on:
|
||||
wekan-ferretdb:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- wekan-db:/data/db
|
||||
- wekan-db-dump:/dump
|
||||
#- /etc/timezone:/etc/timezone:ro # Do not use https://github.com/wekan/wekan/issues/5123
|
||||
- wekan-files:/data:rw
|
||||
- ./releases:/migrate:ro
|
||||
|
||||
#-------------------------------------------------------------------------------------
|
||||
# ==== LEGACY MONGODB (commented out) ====
|
||||
# Uncomment to keep old MongoDB running alongside FerretDB during migration.
|
||||
# After migration is verified, remove this block entirely.
|
||||
#-------------------------------------------------------------------------------------
|
||||
#wekandb:
|
||||
# image: mongo:7
|
||||
# container_name: wekan-db
|
||||
# restart: always
|
||||
# command: mongod --logpath /dev/null --oplogSize 128 --quiet
|
||||
# networks:
|
||||
# - wekan-tier
|
||||
# expose:
|
||||
# - 27017
|
||||
# volumes:
|
||||
# - /etc/localtime:/etc/localtime:ro
|
||||
# - wekan-db:/data/db
|
||||
# - wekan-db-dump:/dump
|
||||
|
||||
wekan:
|
||||
#-------------------------------------------------------------------------------------
|
||||
@@ -165,9 +294,8 @@ services:
|
||||
#- S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx"}}'
|
||||
#- S3_SECRET_FILE=/run/secrets/s3_secret
|
||||
#-----------------------------------------------------------------
|
||||
# ==== MONGO_URL ====
|
||||
- MONGO_URL=mongodb://wekandb:27017/wekan
|
||||
#- MONGO_URL=mongodb://username:password@wekandb:27017/wekan
|
||||
# ==== MONGO_URL — points to FerretDB (MongoDB-compatible) ====
|
||||
- MONGO_URL=mongodb://wekan-ferretdb:27017/wekan
|
||||
#- MONGO_PASSWORD_FILE=/run/secrets/mongo_password
|
||||
#---------------------------------------------------------------
|
||||
# ==== ROOT_URL SETTING ====
|
||||
@@ -215,15 +343,9 @@ services:
|
||||
# - Latency from 50ms to 2000ms
|
||||
# Must configure MongoDB replica set first
|
||||
# See: https://blog.meteor.com/tuning-meteor-mongo-livedata-for-scalability-13fe9deb8908
|
||||
# For local MongoDB with replicaSet 'rs0':
|
||||
# - MONGO_OPLOG_URL=mongodb://127.0.0.1:27017/local?replicaSet=rs0
|
||||
# For production with authentication:
|
||||
# - MONGO_OPLOG_URL=mongodb://<username>:<password>@<mongoDbURL>/local?authSource=admin&replicaSet=rsWekan
|
||||
# Enables:
|
||||
# - Real-time data updates via DDP (sub-100ms latency)
|
||||
# - Lower CPU usage and network overhead
|
||||
# - Better scalability with multiple Wekan instances
|
||||
# - MONGO_OPLOG_URL=mongodb://127.0.0.1:27017/local?replicaSet=rs0
|
||||
# FerretDB emulates OpLog via --replication-set-name=rs0.
|
||||
# HIGHLY RECOMMENDED — enables real-time DDP updates (sub-100ms latency).
|
||||
- MONGO_OPLOG_URL=mongodb://wekan-ferretdb:27017/local?replicaSet=rs0
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
@@ -732,7 +854,10 @@ services:
|
||||
# - WAIT_SPINNER=Bounce
|
||||
#---------------------------------------------------------------------
|
||||
depends_on:
|
||||
- wekandb
|
||||
wekan-migrate:
|
||||
condition: service_completed_successfully
|
||||
wekan-ferretdb:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- wekan-files:/data:rw
|
||||
@@ -796,6 +921,10 @@ services:
|
||||
volumes:
|
||||
wekan-files:
|
||||
driver: local
|
||||
wekan-postgres:
|
||||
driver: local
|
||||
# Legacy MongoDB volumes — kept so existing data survives until migration is done.
|
||||
# Remove once migration is verified and old MongoDB is decommissioned.
|
||||
wekan-db:
|
||||
driver: local
|
||||
wekan-db-dump:
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build WeKan armhf (arm/v7) bundle - Meteor 3 style (no fibers needed).
|
||||
#
|
||||
# This script rebuilds only the native Node.js modules for arm/v7 (armhf).
|
||||
# Note: MongoDB Community Edition is not available for armhf.
|
||||
# Use docker-compose-ferretdb.yml or the snap's built-in FerretDB as the database.
|
||||
#
|
||||
# Run on an armhf machine, or use a QEMU arm/v7 environment.
|
||||
#
|
||||
# Usage:
|
||||
# ./releases/build-bundle-armhf.sh 8.43
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Syntax with Wekan version number:"
|
||||
echo " ./releases/build-bundle-armhf.sh 8.43"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
# Install build dependencies
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
|
||||
fi
|
||||
|
||||
# Remove old files
|
||||
rm -rf bundle
|
||||
rm -f wekan-${VERSION}-armhf.zip wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Download the amd64 bundle as the base
|
||||
wget --no-check-certificate \
|
||||
https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Extract bundle
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Rebuild native Node.js modules for armhf (no fibers needed with Meteor 3)
|
||||
cd bundle/programs/server
|
||||
npm rebuild
|
||||
cd ../../..
|
||||
|
||||
# Clean up temporary files
|
||||
cd bundle
|
||||
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '.*.swp' -delete 2>/dev/null || true
|
||||
find . -name '*.swp' -delete 2>/dev/null || true
|
||||
cd ..
|
||||
|
||||
# Create armhf bundle
|
||||
zip -r wekan-${VERSION}-armhf.zip bundle
|
||||
|
||||
echo "Done: wekan-${VERSION}-armhf.zip"
|
||||
echo "Note: MongoDB Community is not available for armhf."
|
||||
echo " Use docker-compose-ferretdb.yml or the WeKan snap (built-in FerretDB) as the database."
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build WeKan ppc64le bundle - Meteor 3 style (no fibers needed).
|
||||
#
|
||||
# This script rebuilds only the native Node.js modules for ppc64le.
|
||||
# Run on a ppc64le machine (e.g. IBM Power), or use a QEMU ppc64le environment.
|
||||
#
|
||||
# Usage:
|
||||
# ./releases/build-bundle-ppc64le.sh 8.43
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Syntax with Wekan version number:"
|
||||
echo " ./releases/build-bundle-ppc64le.sh 8.43"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
# Install build dependencies (Debian/Ubuntu or RHEL/Fedora)
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf install -y gcc gcc-c++ make python3 curl wget zip unzip
|
||||
sudo dnf groupinstall -y "Development Tools"
|
||||
fi
|
||||
|
||||
# Remove old files
|
||||
rm -rf bundle
|
||||
rm -f wekan-${VERSION}-ppc64le.zip wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Download the amd64 bundle as the base
|
||||
wget --no-check-certificate \
|
||||
https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Extract bundle
|
||||
unzip wekan-${VERSION}-amd64.zip
|
||||
|
||||
# Rebuild native Node.js modules for ppc64le (no fibers needed with Meteor 3)
|
||||
cd bundle/programs/server
|
||||
npm rebuild
|
||||
cd ../../..
|
||||
|
||||
# Clean up temporary files
|
||||
cd bundle
|
||||
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '.*.swp' -delete 2>/dev/null || true
|
||||
find . -name '*.swp' -delete 2>/dev/null || true
|
||||
cd ..
|
||||
|
||||
# Create ppc64le bundle
|
||||
zip -r wekan-${VERSION}-ppc64le.zip bundle
|
||||
|
||||
echo "Done: wekan-${VERSION}-ppc64le.zip"
|
||||
@@ -0,0 +1,797 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WeKan MongoDB → FerretDB + PostgreSQL Migration Script
|
||||
*
|
||||
* Runs BEFORE WeKan starts. Connects to an existing MongoDB instance (any
|
||||
* version ≥ 3.0), migrates all data to a FerretDB instance (which speaks
|
||||
* the MongoDB wire protocol), upgrades the schema to v8.43, and extracts
|
||||
* GridFS/CollectionFS binary files to the local filesystem.
|
||||
*
|
||||
* Served at ROOT_URL (or MIGRATION_PORT) while running so operators can
|
||||
* watch progress in a browser.
|
||||
*
|
||||
* Usage:
|
||||
* node releases/migrate-mongodb-to-ferretdb.mjs
|
||||
* # – or –
|
||||
* node releases/migrate-mongodb-to-ferretdb.mjs --help
|
||||
*
|
||||
* Key environment variables (all optional, sensible defaults shown):
|
||||
* SOURCE_MONGO_URL mongodb://localhost:27017/wekan Old MongoDB
|
||||
* TARGET_MONGO_URL mongodb://localhost:27018/wekan FerretDB
|
||||
* WRITABLE_PATH /data File-storage root
|
||||
* MIGRATION_PORT 8080 HTTP progress port
|
||||
* BATCH_SIZE 200 Docs per batch
|
||||
* DRY_RUN false Log only, no writes
|
||||
*/
|
||||
|
||||
import { MongoClient, GridFSBucket, ObjectId } from 'mongodb';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { statfsSync } from 'node:fs';
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────
|
||||
const SOURCE_URL = process.env.SOURCE_MONGO_URL || process.env.MONGO_URL || 'mongodb://localhost:27017/wekan';
|
||||
const TARGET_URL = process.env.TARGET_MONGO_URL || 'mongodb://localhost:27018/wekan';
|
||||
const WRITABLE = process.env.WRITABLE_PATH || '/data';
|
||||
const PORT = parseInt(process.env.MIGRATION_PORT || process.env.PORT || '8080', 10);
|
||||
const BATCH_SIZE = parseInt(process.env.BATCH_SIZE || '200', 10);
|
||||
const DRY_RUN = process.env.DRY_RUN === 'true';
|
||||
const ATTACH_DIR = path.join(WRITABLE, 'files', 'attachments');
|
||||
const AVATAR_DIR = path.join(WRITABLE, 'files', 'avatars');
|
||||
const MARKER_COLL = '_wekan_migration'; // written to target when done
|
||||
const SCHEMA_VER = 843; // v8.43
|
||||
|
||||
// ── Progress state (shared between HTTP server and migration logic) ─────────
|
||||
const state = {
|
||||
phase: 'starting', // string label
|
||||
phase_detail: '', // extra context
|
||||
collections: { // per-collection progress
|
||||
// collName: { total, done, skipped, errors }
|
||||
},
|
||||
files: {
|
||||
attachments: { total: 0, done: 0, skipped: 0, errors: 0, bytes: 0 },
|
||||
avatars: { total: 0, done: 0, skipped: 0, errors: 0, bytes: 0 },
|
||||
},
|
||||
errors: [], // up to 200 recent errors
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
success: null,
|
||||
dryRun: DRY_RUN,
|
||||
};
|
||||
|
||||
function pushError(msg) {
|
||||
console.error('[ERROR]', msg);
|
||||
state.errors.push({ ts: new Date().toISOString(), msg: String(msg).slice(0, 400) });
|
||||
if (state.errors.length > 200) state.errors.shift();
|
||||
}
|
||||
|
||||
// ── HTTP progress server ───────────────────────────────────────────────────
|
||||
const HTML_TEMPLATE = `<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="utf-8"><meta http-equiv="refresh" content="3">
|
||||
<title>WeKan Migration Progress</title>
|
||||
<style>
|
||||
body{font-family:monospace;background:#111;color:#ddd;padding:1em 2em}
|
||||
h1{color:#7bf} .ok{color:#7f7} .fail{color:#f77} .warn{color:#fb7}
|
||||
.bar-wrap{background:#333;border-radius:4px;height:18px;width:100%;max-width:600px}
|
||||
.bar{background:#4a9;height:18px;border-radius:4px;transition:width .4s}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
td,th{padding:4px 10px;border-bottom:1px solid #333;text-align:left}
|
||||
th{color:#aaa;font-size:.85em}
|
||||
.phase{font-size:1.3em;margin:1em 0}
|
||||
</style></head><body>
|
||||
<h1>WeKan Migration: MongoDB → FerretDB + PostgreSQL</h1>
|
||||
<div class="phase">Phase: <strong id="phase">…</strong> <span id="detail" style="color:#aaa"></span></div>
|
||||
<p>Started: __STARTED__ Elapsed: <span id="elapsed"></span></p>
|
||||
<div id="files_section">
|
||||
<h2>Files</h2>
|
||||
<table>
|
||||
<tr><th>Type</th><th>Done</th><th>Skipped</th><th>Errors</th><th>Bytes moved</th></tr>
|
||||
__FILES__
|
||||
</table>
|
||||
</div>
|
||||
<h2>Collections</h2>
|
||||
<table><tr><th>Collection</th><th>Progress</th><th>Done</th><th>Total</th><th>Errors</th></tr>
|
||||
__COLLS__
|
||||
</table>
|
||||
<h2>Recent Errors</h2><div id="errs">__ERRORS__</div>
|
||||
<script>
|
||||
const started = new Date("__STARTED__");
|
||||
setInterval(()=>{
|
||||
const s = Math.floor((Date.now()-started)/1000);
|
||||
document.getElementById('elapsed').textContent = s+'s';
|
||||
},1000);
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
function buildHtml() {
|
||||
const colRows = Object.entries(state.collections).map(([name, c]) => {
|
||||
const pct = c.total > 0 ? Math.round(100 * c.done / c.total) : 0;
|
||||
return `<tr><td>${name}</td><td><div class="bar-wrap"><div class="bar" style="width:${pct}%"></div></div>${pct}%</td>`
|
||||
+ `<td>${c.done}</td><td>${c.total}</td><td class="${c.errors>0?'fail':''}">${c.errors}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
const fRows = Object.entries(state.files).map(([type, f]) =>
|
||||
`<tr><td>${type}</td><td>${f.done}</td><td>${f.skipped}</td><td class="${f.errors>0?'fail':''}">${f.errors}</td><td>${(f.bytes/1024/1024).toFixed(2)} MB</td></tr>`
|
||||
).join('');
|
||||
|
||||
const errHtml = state.errors.length === 0 ? '<p style="color:#aaa">None</p>'
|
||||
: state.errors.slice(-20).reverse().map(e => `<div class="fail">${e.ts} — ${escHtml(e.msg)}</div>`).join('');
|
||||
|
||||
let phaseColor = '';
|
||||
if (state.success === true) phaseColor = ' class="ok"';
|
||||
if (state.success === false) phaseColor = ' class="fail"';
|
||||
|
||||
return HTML_TEMPLATE
|
||||
.replace(/__STARTED__/g, state.startedAt)
|
||||
.replace('__FILES__', fRows)
|
||||
.replace('__COLLS__', colRows)
|
||||
.replace('__ERRORS__', errHtml)
|
||||
.replace('id="phase">…', `id="phase"${phaseColor}>${escHtml(state.phase)}`)
|
||||
.replace('id="detail" style="color:#aaa">', `id="detail" style="color:#aaa">${escHtml(state.phase_detail)}`);
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/migration-status' || req.url === '/migration-status.json') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(state, null, 2));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(buildHtml());
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[migrate] Progress dashboard: http://localhost:${PORT}/`);
|
||||
console.log(`[migrate] JSON status: http://localhost:${PORT}/migration-status`);
|
||||
});
|
||||
|
||||
// ── Filesystem helpers ─────────────────────────────────────────────────────
|
||||
function ensureDir(p) {
|
||||
fs.mkdirSync(p, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the directory has at least `neededBytes` + 10 MB free.
|
||||
* Falls back to true (proceed) if statfsSync is unavailable or throws.
|
||||
*/
|
||||
function hasEnoughSpace(dir, neededBytes) {
|
||||
try {
|
||||
const st = statfsSync(dir);
|
||||
const available = Number(st.bavail) * Number(st.bsize);
|
||||
return available > neededBytes + 10 * 1024 * 1024;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a safe, cross-platform filename from a (potentially URL-encoded,
|
||||
* special-char-rich) original name. The ObjectId prefix guarantees uniqueness.
|
||||
* The original name is preserved in the database for download headers.
|
||||
*
|
||||
* Rules applied (in order):
|
||||
* 1. URI-decode percent-encoded sequences
|
||||
* 2. Strip null bytes and ASCII control chars
|
||||
* 3. Replace directory separators (/ \) with _
|
||||
* 4. Replace Windows-forbidden chars (: * ? " < > |) with _
|
||||
* 5. Collapse leading dots (hidden-file trap on Unix)
|
||||
* 6. Collapse runs of underscores/spaces
|
||||
* 7. Trim and truncate to 180 chars (leaves room for the id prefix)
|
||||
* 8. Fallback to "file" if result is empty
|
||||
*/
|
||||
function sanitizeFilename(original) {
|
||||
let name = original || 'file';
|
||||
try { name = decodeURIComponent(name); } catch { /* keep raw */ }
|
||||
name = name
|
||||
.replace(/[\x00-\x1f\x7f]/g, '') // control chars
|
||||
.replace(/[/\\]/g, '_') // path separators
|
||||
.replace(/[:<>"|?*]/g, '_') // Windows-forbidden
|
||||
.replace(/^\.+/, '') // leading dots
|
||||
.replace(/\s+/g, ' ') // collapse whitespace
|
||||
.replace(/_+/g, '_') // collapse underscores
|
||||
.trim();
|
||||
if (name.length > 180) name = name.slice(0, 180);
|
||||
if (!name) name = 'file';
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a collision-free path under dir.
|
||||
* Returns the full path and the relative basename used.
|
||||
*/
|
||||
function uniqueFilePath(dir, idHex, originalName) {
|
||||
const safe = sanitizeFilename(originalName);
|
||||
let base = `${idHex}_${safe}`;
|
||||
let candidate = path.join(dir, base);
|
||||
let counter = 0;
|
||||
while (fs.existsSync(candidate)) {
|
||||
counter++;
|
||||
base = `${idHex}_${counter}_${safe}`;
|
||||
candidate = path.join(dir, base);
|
||||
}
|
||||
return { fullPath: candidate, basename: base };
|
||||
}
|
||||
|
||||
// ── Schema upgrade helpers ─────────────────────────────────────────────────
|
||||
/**
|
||||
* During the copy phase we call this on every document of a given collection
|
||||
* and return the (possibly transformed) document to insert into the target.
|
||||
*
|
||||
* Each transformer is pure: takes a doc, returns a doc (or null to skip).
|
||||
*/
|
||||
function upgradeBoard(doc) {
|
||||
return {
|
||||
...doc,
|
||||
migrationVersion: SCHEMA_VER,
|
||||
comprehensiveMigrationCompleted: true,
|
||||
fixMissingListsCompleted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeList(doc, defaultSwimlaneIdFor) {
|
||||
const swimlaneId = doc.swimlaneId || defaultSwimlaneIdFor[doc.boardId] || '';
|
||||
return { ...doc, swimlaneId };
|
||||
}
|
||||
|
||||
function upgradeCard(doc, listSwimlaneMap, defaultSwimlaneIdFor) {
|
||||
let swimlaneId = doc.swimlaneId;
|
||||
if (!swimlaneId && doc.listId && listSwimlaneMap[doc.listId]) {
|
||||
swimlaneId = listSwimlaneMap[doc.listId];
|
||||
}
|
||||
if (!swimlaneId) {
|
||||
swimlaneId = defaultSwimlaneIdFor[doc.boardId] || '';
|
||||
}
|
||||
return { ...doc, swimlaneId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CollectionFS filerecord to a Meteor-Files (ostrio:files) document.
|
||||
* Keeps the same _id so existing card.coverId / attachment URL refs still work.
|
||||
*/
|
||||
function cfsRecordToMeteorFile(doc, type /* 'attachments' | 'avatars' */, storedBasename) {
|
||||
const original = doc.original || {};
|
||||
const meta = doc.meta || {};
|
||||
const now = new Date();
|
||||
return {
|
||||
_id: doc._id,
|
||||
name: original.name || doc.filename || 'file',
|
||||
size: original.size || 0,
|
||||
type: original.type || 'application/octet-stream',
|
||||
extension: (original.name || '').split('.').pop() || '',
|
||||
extensionWithDot: original.name && original.name.includes('.') ? '.' + original.name.split('.').pop() : '',
|
||||
meta: {
|
||||
boardId: meta.boardId || doc.boardId || '',
|
||||
cardId: meta.cardId || doc.cardId || '',
|
||||
listId: meta.listId || doc.listId || '',
|
||||
swimlaneId: meta.swimlaneId || doc.swimlaneId || '',
|
||||
userId: meta.userId || doc.userId || '',
|
||||
source: 'cfs-migration',
|
||||
cfsOriginalId: String(doc._id),
|
||||
},
|
||||
path: path.join(WRITABLE, 'files', type, storedBasename || String(doc._id)),
|
||||
versions: {
|
||||
original: {
|
||||
path: path.join(WRITABLE, 'files', type, storedBasename || String(doc._id)),
|
||||
size: original.size || 0,
|
||||
type: original.type || 'application/octet-stream',
|
||||
storage: 'fs',
|
||||
},
|
||||
},
|
||||
userId: meta.userId || doc.userId || '',
|
||||
uploadedAt: doc.uploadedAt || doc.createdAt || now,
|
||||
uploadedAtOstrio: doc.uploadedAt || doc.createdAt || now,
|
||||
updatedAt: now,
|
||||
public: false,
|
||||
collectionName: type,
|
||||
};
|
||||
}
|
||||
|
||||
// ── GridFS extraction ──────────────────────────────────────────────────────
|
||||
/**
|
||||
* Read one GridFS file by its ObjectId and stream it to destPath.
|
||||
* Returns the number of bytes written, or throws.
|
||||
*/
|
||||
async function extractGridFsFile(bucket, fileId, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const oid = typeof fileId === 'string' ? new ObjectId(fileId) : fileId;
|
||||
const stream = bucket.openDownloadStream(oid);
|
||||
const out = fs.createWriteStream(destPath);
|
||||
let bytes = 0;
|
||||
stream.on('data', chunk => { bytes += chunk.length; });
|
||||
stream.on('error', reject);
|
||||
out.on('error', reject);
|
||||
stream.pipe(out);
|
||||
out.on('finish', () => resolve(bytes));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generic collection copy ────────────────────────────────────────────────
|
||||
async function copyCollection(srcDb, tgtDb, collName, transformer) {
|
||||
const src = srcDb.collection(collName);
|
||||
const tgt = tgtDb.collection(collName);
|
||||
|
||||
const total = await src.countDocuments().catch(() => 0);
|
||||
state.collections[collName] = { total, done: 0, skipped: 0, errors: 0 };
|
||||
|
||||
if (total === 0) return;
|
||||
|
||||
const cursor = src.find({});
|
||||
let batch = [];
|
||||
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return;
|
||||
if (!DRY_RUN) {
|
||||
for (const doc of batch) {
|
||||
try {
|
||||
await tgt.replaceOne({ _id: doc._id }, doc, { upsert: true });
|
||||
state.collections[collName].done++;
|
||||
} catch (err) {
|
||||
state.collections[collName].errors++;
|
||||
pushError(`${collName}/${doc._id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.collections[collName].done += batch.length;
|
||||
}
|
||||
batch = [];
|
||||
};
|
||||
|
||||
for await (const doc of cursor) {
|
||||
let out = doc;
|
||||
if (transformer) {
|
||||
try { out = transformer(doc); }
|
||||
catch (err) {
|
||||
pushError(`transform ${collName}/${doc._id}: ${err.message}`);
|
||||
state.collections[collName].errors++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (out === null) { state.collections[collName].skipped++; continue; }
|
||||
batch.push(out);
|
||||
if (batch.length >= BATCH_SIZE) await flush();
|
||||
}
|
||||
await flush();
|
||||
await cursor.close();
|
||||
}
|
||||
|
||||
// ── Main migration logic ───────────────────────────────────────────────────
|
||||
async function run() {
|
||||
// ── 0. Check if already migrated ────────────────────────────────────────
|
||||
state.phase = 'connecting';
|
||||
state.phase_detail = `source=${SOURCE_URL} target=${TARGET_URL}`;
|
||||
console.log('[migrate] Connecting to source:', SOURCE_URL);
|
||||
console.log('[migrate] Connecting to target:', TARGET_URL);
|
||||
if (DRY_RUN) console.log('[migrate] DRY_RUN mode — no writes to target or filesystem');
|
||||
|
||||
let srcClient, tgtClient;
|
||||
try {
|
||||
srcClient = await MongoClient.connect(SOURCE_URL, {
|
||||
serverSelectionTimeoutMS: 15_000,
|
||||
directConnection: true,
|
||||
});
|
||||
} catch (err) {
|
||||
pushError(`Cannot connect to source MongoDB: ${err.message}`);
|
||||
console.log('[migrate] Source MongoDB not reachable — assuming fresh install, nothing to migrate.');
|
||||
state.phase = 'skipped';
|
||||
state.phase_detail = 'Source not reachable — no migration needed';
|
||||
state.success = true;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
setTimeout(() => process.exit(0), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
tgtClient = await MongoClient.connect(TARGET_URL, {
|
||||
serverSelectionTimeoutMS: 15_000,
|
||||
directConnection: true,
|
||||
});
|
||||
} catch (err) {
|
||||
await srcClient.close();
|
||||
pushError(`Cannot connect to target FerretDB: ${err.message}`);
|
||||
state.phase = 'error';
|
||||
state.success = false;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
setTimeout(() => process.exit(1), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const srcDbName = new URL(SOURCE_URL.replace('mongodb://', 'http://')).pathname.slice(1) || 'wekan';
|
||||
const tgtDbName = new URL(TARGET_URL.replace('mongodb://', 'http://')).pathname.slice(1) || 'wekan';
|
||||
const srcDb = srcClient.db(srcDbName);
|
||||
const tgtDb = tgtClient.db(tgtDbName);
|
||||
|
||||
// Check idempotency marker
|
||||
const markerColl = tgtDb.collection(MARKER_COLL);
|
||||
const marker = await markerColl.findOne({ _id: 'completed' });
|
||||
if (marker && marker.schemaVersion >= SCHEMA_VER && !process.env.FORCE_MIGRATE) {
|
||||
console.log('[migrate] Already migrated (schema v' + marker.schemaVersion + '). Exiting.');
|
||||
state.phase = 'already-migrated';
|
||||
state.phase_detail = 'Schema v' + marker.schemaVersion + ' already present in target';
|
||||
state.success = true;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
await srcClient.close();
|
||||
await tgtClient.close();
|
||||
setTimeout(() => process.exit(0), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Build lookup maps needed for schema upgrades ────────────────────
|
||||
state.phase = 'analyzing';
|
||||
state.phase_detail = 'Building swimlane/list lookup maps…';
|
||||
console.log('[migrate] Analyzing source schema…');
|
||||
|
||||
// boardId → first swimlaneId (for orphaned lists/cards)
|
||||
const defaultSwimlaneIdFor = {};
|
||||
const swimlaneCursor = srcDb.collection('swimlanes').find({ archived: false }, {
|
||||
projection: { _id: 1, boardId: 1, sort: 1 }, sort: { sort: 1 },
|
||||
});
|
||||
for await (const sw of swimlaneCursor) {
|
||||
if (!defaultSwimlaneIdFor[sw.boardId]) {
|
||||
defaultSwimlaneIdFor[sw.boardId] = String(sw._id);
|
||||
}
|
||||
}
|
||||
await swimlaneCursor.close();
|
||||
|
||||
// listId → swimlaneId (so orphaned cards can inherit from their list)
|
||||
const listSwimlaneMap = {};
|
||||
const listCursor = srcDb.collection('lists').find({}, { projection: { _id: 1, swimlaneId: 1 } });
|
||||
for await (const ls of listCursor) {
|
||||
listSwimlaneMap[String(ls._id)] = ls.swimlaneId || '';
|
||||
}
|
||||
await listCursor.close();
|
||||
|
||||
// ── 2. Ensure target filesystem directories exist ───────────────────────
|
||||
if (!DRY_RUN) {
|
||||
ensureDir(ATTACH_DIR);
|
||||
ensureDir(AVATAR_DIR);
|
||||
}
|
||||
|
||||
// ── 3. Discover all collection names in source ─────────────────────────
|
||||
state.phase = 'listing-collections';
|
||||
const allColls = (await srcDb.listCollections().toArray()).map(c => c.name);
|
||||
console.log('[migrate] Source collections:', allColls.join(', '));
|
||||
|
||||
// Collections that get special schema treatment
|
||||
const specialCols = new Set([
|
||||
'boards', 'lists', 'cards', 'swimlanes',
|
||||
'cfs.attachments.filerecord', 'cfs.avatars.filerecord',
|
||||
]);
|
||||
// GridFS meta-collections — handled separately
|
||||
const gridFsCols = new Set([
|
||||
'cfs_gridfs.attachments.files', 'cfs_gridfs.attachments.chunks',
|
||||
'cfs_gridfs.avatars.files', 'cfs_gridfs.avatars.chunks',
|
||||
'cfs_gridfs.attachments.files', // duplicated intentionally for clarity
|
||||
]);
|
||||
|
||||
// ── 4. Copy all plain collections ─────────────────────────────────────
|
||||
state.phase = 'migrating-collections';
|
||||
for (const name of allColls) {
|
||||
if (gridFsCols.has(name)) continue; // handled in file phase
|
||||
|
||||
let transformer = null;
|
||||
if (name === 'boards') {
|
||||
transformer = doc => upgradeBoard(doc);
|
||||
} else if (name === 'lists') {
|
||||
transformer = doc => upgradeList(doc, defaultSwimlaneIdFor);
|
||||
} else if (name === 'cards') {
|
||||
transformer = doc => upgradeCard(doc, listSwimlaneMap, defaultSwimlaneIdFor);
|
||||
} else if (name === 'cfs.attachments.filerecord') {
|
||||
// Convert CFS attachment records to Meteor-Files format.
|
||||
// Actual binary data is handled in the file-extraction phase.
|
||||
transformer = doc => cfsRecordToMeteorFile(doc, 'attachments', null);
|
||||
} else if (name === 'cfs.avatars.filerecord') {
|
||||
transformer = doc => cfsRecordToMeteorFile(doc, 'avatars', null);
|
||||
}
|
||||
|
||||
state.phase_detail = name;
|
||||
console.log(`[migrate] Copying collection: ${name}`);
|
||||
await copyCollection(srcDb, tgtDb, name, transformer);
|
||||
}
|
||||
|
||||
// ── 5. Also ensure boards without a swimlane have one in the target ────
|
||||
state.phase = 'ensuring-swimlanes';
|
||||
state.phase_detail = 'Creating default swimlanes for boards that have none…';
|
||||
if (!DRY_RUN) {
|
||||
const tgtBoards = await tgtDb.collection('boards').find({}).toArray();
|
||||
for (const board of tgtBoards) {
|
||||
const boardId = String(board._id);
|
||||
const hasSwim = await tgtDb.collection('swimlanes')
|
||||
.countDocuments({ boardId });
|
||||
if (hasSwim === 0) {
|
||||
const newId = new ObjectId();
|
||||
await tgtDb.collection('swimlanes').insertOne({
|
||||
_id: newId,
|
||||
title: 'Default',
|
||||
boardId,
|
||||
archived: false,
|
||||
sort: 0,
|
||||
type: 'swimlane',
|
||||
createdAt: new Date(),
|
||||
modifiedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
// Patch lists and cards that had no swimlane
|
||||
await tgtDb.collection('lists').updateMany(
|
||||
{ boardId, swimlaneId: { $in: ['', null] } },
|
||||
{ $set: { swimlaneId: String(newId) } },
|
||||
);
|
||||
await tgtDb.collection('cards').updateMany(
|
||||
{ boardId, swimlaneId: { $in: ['', null] } },
|
||||
{ $set: { swimlaneId: String(newId) } },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Fix orphaned cards: card.listId references a nonexistent list ───
|
||||
state.phase = 'fixing-orphans';
|
||||
state.phase_detail = 'Linking orphaned cards to visible swimlane/list…';
|
||||
if (!DRY_RUN) {
|
||||
// Collect all valid listIds per board
|
||||
const allLists = await tgtDb.collection('lists').find({}, {
|
||||
projection: { _id: 1, boardId: 1, swimlaneId: 1 },
|
||||
}).toArray();
|
||||
const listSet = new Set(allLists.map(l => String(l._id)));
|
||||
const listByBoard = {};
|
||||
for (const l of allLists) {
|
||||
if (!listByBoard[l.boardId]) listByBoard[l.boardId] = l;
|
||||
}
|
||||
|
||||
const orphanCursor = tgtDb.collection('cards').find({});
|
||||
for await (const card of orphanCursor) {
|
||||
const cid = String(card._id);
|
||||
const bid = card.boardId;
|
||||
const lid = card.listId;
|
||||
let update = null;
|
||||
|
||||
if (lid && !listSet.has(lid)) {
|
||||
// listId points nowhere — reassign to first list of this board
|
||||
const fallback = listByBoard[bid];
|
||||
if (fallback) {
|
||||
update = {
|
||||
listId: String(fallback._id),
|
||||
swimlaneId: fallback.swimlaneId || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!card.swimlaneId) {
|
||||
update = { ...(update || {}), swimlaneId: defaultSwimlaneIdFor[bid] || '' };
|
||||
}
|
||||
if (update) {
|
||||
await tgtDb.collection('cards').updateOne({ _id: card._id }, { $set: update });
|
||||
}
|
||||
}
|
||||
await orphanCursor.close();
|
||||
}
|
||||
|
||||
// ── 7. Extract GridFS / CollectionFS binary files to filesystem ─────────
|
||||
state.phase = 'migrating-files';
|
||||
|
||||
async function migrateGridFs(bucketName, destDir, fileStateKey) {
|
||||
const filesCollName = `cfs_gridfs.${bucketName}.files`;
|
||||
const chunksCollName = `cfs_gridfs.${bucketName}.chunks`;
|
||||
if (!allColls.includes(filesCollName)) return;
|
||||
|
||||
state.phase_detail = `Extracting ${bucketName} files from GridFS…`;
|
||||
console.log(`[migrate] Extracting GridFS bucket: ${bucketName} → ${destDir}`);
|
||||
|
||||
const bucket = new GridFSBucket(srcDb, { bucketName: `cfs_gridfs.${bucketName}` });
|
||||
const filesCol = srcDb.collection(filesCollName);
|
||||
const metaCursor = srcDb.collection(`cfs.${bucketName}.filerecord`).find({});
|
||||
|
||||
state.files[fileStateKey].total = await filesCol.countDocuments();
|
||||
|
||||
for await (const record of metaCursor) {
|
||||
const original = record.original || {};
|
||||
const gridFsId = original.gridFsFileId || record.gridFsFileId || (record.copies && record.copies.gridfs && record.copies.gridfs.key);
|
||||
if (!gridFsId) {
|
||||
state.files[fileStateKey].skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalName = original.name || record.filename || String(record._id);
|
||||
const { fullPath, basename } = uniqueFilePath(destDir, String(record._id), originalName);
|
||||
|
||||
if (!hasEnoughSpace(destDir, original.size || 0)) {
|
||||
pushError(`Not enough disk space for ${originalName} (${original.size} bytes) — skipping`);
|
||||
state.files[fileStateKey].errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
state.files[fileStateKey].done++;
|
||||
state.files[fileStateKey].bytes += original.size || 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = await extractGridFsFile(bucket, gridFsId, fullPath);
|
||||
state.files[fileStateKey].done++;
|
||||
state.files[fileStateKey].bytes += bytes;
|
||||
|
||||
// Update the Meteor-Files record in target to point to the new path
|
||||
await tgtDb.collection(bucketName).updateOne(
|
||||
{ _id: record._id },
|
||||
{
|
||||
$set: {
|
||||
path: fullPath,
|
||||
'meta.originalFilename': originalName,
|
||||
'meta.storedBasename': basename,
|
||||
'meta.source': 'cfs-migration',
|
||||
'versions.original.path': fullPath,
|
||||
'versions.original.storage': 'fs',
|
||||
},
|
||||
},
|
||||
{ upsert: false },
|
||||
);
|
||||
} catch (err) {
|
||||
pushError(`GridFS extract ${bucketName}/${record._id}: ${err.message}`);
|
||||
state.files[fileStateKey].errors++;
|
||||
// Clean up partial file
|
||||
try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
await metaCursor.close();
|
||||
}
|
||||
|
||||
await migrateGridFs('attachments', ATTACH_DIR, 'attachments');
|
||||
await migrateGridFs('avatars', AVATAR_DIR, 'avatars');
|
||||
|
||||
// Also migrate any attachments that are already in Meteor-Files format but stored
|
||||
// in GridFS (storage === 'gridfs') — move binary to filesystem and update record.
|
||||
state.phase_detail = 'Migrating Meteor-Files GridFS records to filesystem…';
|
||||
for (const [collName, destDir, fileStateKey] of [
|
||||
['attachments', ATTACH_DIR, 'attachments'],
|
||||
['avatars', AVATAR_DIR, 'avatars'],
|
||||
]) {
|
||||
if (!allColls.includes(collName)) continue;
|
||||
const col = srcDb.collection(collName);
|
||||
const cursor = col.find({ 'versions.original.storage': 'gridfs' });
|
||||
const bucket = new GridFSBucket(srcDb, { bucketName: collName });
|
||||
|
||||
for await (const doc of cursor) {
|
||||
const gridFsId = doc.versions?.original?.meta?.gridFsFileId;
|
||||
if (!gridFsId) continue;
|
||||
const originalName = doc.name || String(doc._id);
|
||||
const { fullPath, basename } = uniqueFilePath(destDir, String(doc._id), originalName);
|
||||
|
||||
if (!hasEnoughSpace(destDir, doc.size || 0)) {
|
||||
pushError(`Disk full — cannot move ${originalName}`);
|
||||
state.files[fileStateKey].errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY_RUN) { state.files[fileStateKey].done++; continue; }
|
||||
|
||||
try {
|
||||
const bytes = await extractGridFsFile(bucket, gridFsId, fullPath);
|
||||
state.files[fileStateKey].done++;
|
||||
state.files[fileStateKey].bytes += bytes;
|
||||
await tgtDb.collection(collName).updateOne(
|
||||
{ _id: doc._id },
|
||||
{
|
||||
$set: {
|
||||
path: fullPath,
|
||||
'meta.storedBasename': basename,
|
||||
'meta.originalFilename': originalName,
|
||||
'versions.original.path': fullPath,
|
||||
'versions.original.storage': 'fs',
|
||||
'versions.original.meta': {},
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
pushError(`Move GridFS→fs ${collName}/${doc._id}: ${err.message}`);
|
||||
state.files[fileStateKey].errors++;
|
||||
try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
await cursor.close();
|
||||
}
|
||||
|
||||
// ── 8. Write completion marker ─────────────────────────────────────────
|
||||
if (!DRY_RUN) {
|
||||
await markerColl.replaceOne(
|
||||
{ _id: 'completed' },
|
||||
{
|
||||
_id: 'completed',
|
||||
schemaVersion: SCHEMA_VER,
|
||||
completedAt: new Date(),
|
||||
sourceUrl: SOURCE_URL,
|
||||
targetUrl: TARGET_URL,
|
||||
dryRun: DRY_RUN,
|
||||
collections: Object.fromEntries(
|
||||
Object.entries(state.collections).map(([k, v]) => [k, { done: v.done, errors: v.errors }])
|
||||
),
|
||||
files: state.files,
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. Ensure indexes on commonly-queried fields ───────────────────────
|
||||
if (!DRY_RUN) {
|
||||
state.phase = 'creating-indexes';
|
||||
state.phase_detail = 'Creating indexes on target…';
|
||||
try {
|
||||
const idx = (col, spec, opts = {}) =>
|
||||
tgtDb.collection(col).createIndex(spec, { background: true, ...opts }).catch(() => {});
|
||||
await Promise.all([
|
||||
idx('boards', { members: 1 }),
|
||||
idx('boards', { slug: 1 }),
|
||||
idx('swimlanes', { boardId: 1, sort: 1 }),
|
||||
idx('lists', { boardId: 1, swimlaneId: 1, sort: 1 }),
|
||||
idx('cards', { boardId: 1, swimlaneId: 1, listId: 1, sort: 1 }),
|
||||
idx('cards', { listId: 1, swimlaneId: 1 }),
|
||||
idx('cards', { userId: 1 }),
|
||||
idx('activities',{ boardId: 1, createdAt: -1 }),
|
||||
idx('attachments',{ 'meta.cardId': 1 }),
|
||||
idx('attachments',{ 'meta.boardId': 1 }),
|
||||
idx('avatars', { userId: 1 }),
|
||||
]);
|
||||
} catch { /* index creation is best-effort — FerretDB may have limitations */ }
|
||||
}
|
||||
|
||||
// ── Done ──────────────────────────────────────────────────────────────
|
||||
await srcClient.close();
|
||||
await tgtClient.close();
|
||||
|
||||
const totalErrors = state.errors.length;
|
||||
if (totalErrors === 0) {
|
||||
state.phase = 'completed';
|
||||
state.success = true;
|
||||
console.log('[migrate] ✓ Migration completed successfully.');
|
||||
} else {
|
||||
state.phase = 'completed-with-errors';
|
||||
state.success = totalErrors < 10; // treat <10 isolated errors as success
|
||||
console.log(`[migrate] ⚠ Migration done with ${totalErrors} error(s). See dashboard.`);
|
||||
}
|
||||
state.finishedAt = new Date().toISOString();
|
||||
|
||||
// Keep HTTP server alive for 60 s so operators can read the final status
|
||||
setTimeout(() => process.exit(state.success ? 0 : 1), 60_000);
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
console.log(`
|
||||
WeKan MongoDB → FerretDB Migration
|
||||
===================================
|
||||
Usage: node releases/migrate-mongodb-to-ferretdb.mjs [--dry-run]
|
||||
|
||||
Environment variables:
|
||||
SOURCE_MONGO_URL Source MongoDB URL (default: $MONGO_URL or mongodb://localhost:27017/wekan)
|
||||
TARGET_MONGO_URL Target FerretDB URL (default: mongodb://localhost:27018/wekan)
|
||||
WRITABLE_PATH File storage root (default: /data)
|
||||
MIGRATION_PORT HTTP progress server port (default: 8080)
|
||||
BATCH_SIZE Docs per write batch (default: 200)
|
||||
DRY_RUN true = no writes (default: false)
|
||||
FORCE_MIGRATE true = ignore done marker (default: false)
|
||||
|
||||
Files are written to:
|
||||
\$WRITABLE_PATH/files/attachments/
|
||||
\$WRITABLE_PATH/files/avatars/
|
||||
|
||||
Idempotent: re-running after success is safe (marker in _wekan_migration).
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--dry-run')) {
|
||||
process.env.DRY_RUN = 'true';
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
pushError('Fatal: ' + err.stack);
|
||||
state.phase = 'error';
|
||||
state.success = false;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
console.error('[migrate] Fatal error:', err);
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "wekan-migration-tools",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone migration tools for WeKan (MongoDB → FerretDB + PostgreSQL)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"migrate": "node migrate-mongodb-to-ferretdb.mjs",
|
||||
"migrate-dry": "node migrate-mongodb-to-ferretdb.mjs --dry-run",
|
||||
"test-urls": "bash test-download-urls.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^6.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
+28
-18
@@ -12,11 +12,12 @@
|
||||
# - Make sure all code changes are ready to ship
|
||||
#
|
||||
# What this script does:
|
||||
# 0. Rebuilds OpenAPI docs (wekan.yml + wekan.html) from current source
|
||||
# 1. Updates all version numbers in release files
|
||||
# 2. Commits and pushes the version bump
|
||||
# 3. Creates and pushes the git tag <-- this triggers GitHub Actions
|
||||
# 4. Updates the website repo if ~/repos/w/wekan.fi exists
|
||||
# 0. Updates Node.js to the latest 22.x release across all files
|
||||
# 1. Rebuilds OpenAPI docs (wekan.yml + wekan.html) from current source
|
||||
# 2. Updates all WeKan version numbers in release files
|
||||
# 3. Commits and pushes the version bump
|
||||
# 4. Creates and pushes the git tag <-- this triggers GitHub Actions
|
||||
# 5. Updates the website repo if ~/repos/w/wekan.fi exists
|
||||
#
|
||||
# GitHub Actions (release-all.yml) then automatically:
|
||||
# - Builds amd64 bundle (full Meteor 3 build)
|
||||
@@ -57,51 +58,60 @@ cd "$REPO_DIR"
|
||||
echo "=== WeKan release: v$OLD -> v$NEW ==="
|
||||
echo ""
|
||||
|
||||
# ── Step 0: Rebuild API docs from current source ─────────────────────────────
|
||||
# ── Step 0: Update Node.js to the latest 22.x release ───────────────────────
|
||||
# Fetches https://nodejs.org/dist/latest-v22.x/ and updates Dockerfile,
|
||||
# snapcraft.yaml, GitHub Actions workflow, rebuild-wekan.sh, and
|
||||
# releases/test-download-urls.sh so all platforms use the same newest version.
|
||||
echo "--- Step 0: Updating Node.js to latest 22.x ---"
|
||||
"$RELEASES_DIR/update-node-version.sh"
|
||||
echo "Done."
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Rebuild API docs from current source ─────────────────────────────
|
||||
# Runs before the version bump so the freshly generated docs are included in
|
||||
# the commit and therefore in both the wekan repo and the wekan.fi website.
|
||||
echo "--- Step 0: Rebuilding API docs ---"
|
||||
echo "--- Step 1: Rebuilding API docs ---"
|
||||
"$RELEASES_DIR/rebuild-docs.sh" "$NEW"
|
||||
echo "Done."
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Update version numbers in all release files ─────────────────────
|
||||
echo "--- Step 1: Updating version numbers ---"
|
||||
# ── Step 2: Update WeKan version numbers in all release files ────────────────
|
||||
echo "--- Step 2: Updating WeKan version numbers ---"
|
||||
"$RELEASES_DIR/sed-release-versions.sh" "$OLD" "$NEW"
|
||||
echo "Done."
|
||||
echo ""
|
||||
|
||||
# ── Step 2: Commit and push the version bump ────────────────────────────────
|
||||
echo "--- Step 2: Committing and pushing version bump ---"
|
||||
# ── Step 3: Commit and push ──────────────────────────────────────────────────
|
||||
echo "--- Step 3: Committing and pushing version bump ---"
|
||||
git add --all
|
||||
git commit -m "v$NEW"
|
||||
git push
|
||||
echo "Done."
|
||||
echo ""
|
||||
|
||||
# ── Step 3: Tag and push (this triggers GitHub Actions release-all.yml) ──────
|
||||
echo "--- Step 3: Creating and pushing tag v$NEW ---"
|
||||
# ── Step 4: Tag and push (this triggers GitHub Actions release-all.yml) ──────
|
||||
echo "--- Step 4: Creating and pushing tag v$NEW ---"
|
||||
git tag -a "v$NEW" -m "v$NEW"
|
||||
git push origin "v$NEW"
|
||||
echo "Done."
|
||||
echo ""
|
||||
|
||||
echo "=== GitHub Actions is now building and releasing v$NEW ==="
|
||||
echo " Building: amd64 (full Meteor build), arm64, s390x, win64 bundles"
|
||||
echo " Building: amd64 (full Meteor build), arm64, s390x, ppc64le, armhf, win64 bundles"
|
||||
echo " Releasing: GitHub Release with CHANGELOG notes + all .zip bundles"
|
||||
echo " Docker: wekanteam/wekan, quay.io/wekan/wekan, ghcr.io/wekan/wekan"
|
||||
echo " Snap: published to Snap Store stable channel + uploaded to release"
|
||||
echo " Snap: published to candidate/beta/edge channels + uploaded to release"
|
||||
echo " Follow at: https://github.com/wekan/wekan/actions"
|
||||
echo ""
|
||||
|
||||
# ── Step 4: Update website repo (skipped if not present) ─────────────────────
|
||||
# ── Step 5: Update website repo (skipped if not present) ─────────────────────
|
||||
WEBSITE_DIR="$HOME/repos/w/wekan.fi"
|
||||
if [ -d "$WEBSITE_DIR" ]; then
|
||||
echo "--- Step 4: Updating website ---"
|
||||
echo "--- Step 5: Updating website ---"
|
||||
"$RELEASES_DIR/release-website.sh" "$OLD" "$NEW"
|
||||
echo "Done."
|
||||
else
|
||||
echo "--- Step 4: Website repo not found at $WEBSITE_DIR, skipping ---"
|
||||
echo "--- Step 5: Website repo not found at $WEBSITE_DIR, skipping ---"
|
||||
echo " Run manually when ready: ./releases/release-website.sh $OLD $NEW"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Test that all download URLs used in snapcraft.yaml actually exist.
|
||||
# Uses HTTP HEAD requests — nothing is downloaded.
|
||||
#
|
||||
# Summary of what the snap bundles per architecture:
|
||||
# amd64, arm64 — MongoDB Community 8.0 (newest) + mongosh + db-tools
|
||||
# armhf, s390x, ppc64el — FerretDB 2.7.0 built from Go 1.24.13 source + PostgreSQL 16
|
||||
# (MongoDB Community Edition has no Community builds for these arches)
|
||||
#
|
||||
# Snap base: core24 (Ubuntu 24.04 LTS — core26 is edge-only, not released yet)
|
||||
# Docker base: ubuntu:24.04 (current LTS, support until 2029;
|
||||
# ubuntu:26.04 is pre-release until April 17 2026)
|
||||
#
|
||||
# Usage:
|
||||
# ./releases/test-download-urls.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local label="$1"
|
||||
local url="$2"
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -L --head --max-time 20 "$url")
|
||||
if [ "$HTTP" = "200" ] || [ "$HTTP" = "302" ] || [ "$HTTP" = "301" ]; then
|
||||
printf " OK %s\n %s\n" "$label" "$url"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " FAIL [HTTP %s] %s\n %s\n" "$HTTP" "$label" "$url"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== MongoDB Community 8.0.20 (amd64 and arm64 only) ==="
|
||||
check "MongoDB 8.0.20 amd64 (x86_64, ubuntu2404)" \
|
||||
"https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2404-8.0.20.tgz"
|
||||
check "MongoDB 8.0.20 arm64 (aarch64, ubuntu2404)" \
|
||||
"https://fastdl.mongodb.org/linux/mongodb-linux-aarch64-ubuntu2404-8.0.20.tgz"
|
||||
echo " NOTE MongoDB Community has never published builds for s390x, ppc64le, or arm/v7"
|
||||
echo " (those are Enterprise-only). FerretDB 2 + PostgreSQL is used instead."
|
||||
echo ""
|
||||
|
||||
echo "=== mongosh 2.8.2 (amd64 and arm64) ==="
|
||||
check "mongosh 2.8.2 amd64 (x64)" \
|
||||
"https://downloads.mongodb.com/compass/mongosh-2.8.2-linux-x64.tgz"
|
||||
check "mongosh 2.8.2 arm64" \
|
||||
"https://downloads.mongodb.com/compass/mongosh-2.8.2-linux-arm64.tgz"
|
||||
echo " NOTE mongosh is not published for s390x, ppc64le, or armhf"
|
||||
echo ""
|
||||
|
||||
echo "=== mongodb-database-tools 100.13.0 (amd64 and arm64) ==="
|
||||
check "db-tools 100.13.0 amd64 (x86_64, ubuntu2404)" \
|
||||
"https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2404-x86_64-100.13.0.tgz"
|
||||
check "db-tools 100.13.0 arm64 (ubuntu2404)" \
|
||||
"https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2404-arm64-100.13.0.tgz"
|
||||
echo " NOTE db-tools is not published for s390x, ppc64le, or armhf"
|
||||
echo ""
|
||||
|
||||
echo "=== Go 1.24.13 toolchain (used to build FerretDB 2 on armhf/s390x/ppc64le) ==="
|
||||
check "Go 1.24.13 linux-armv6l (armhf snap builder)" \
|
||||
"https://dl.google.com/go/go1.24.13.linux-armv6l.tar.gz"
|
||||
check "Go 1.24.13 linux-s390x (s390x snap builder)" \
|
||||
"https://dl.google.com/go/go1.24.13.linux-s390x.tar.gz"
|
||||
check "Go 1.24.13 linux-ppc64le (ppc64el snap builder)" \
|
||||
"https://dl.google.com/go/go1.24.13.linux-ppc64le.tar.gz"
|
||||
echo ""
|
||||
|
||||
echo "=== FerretDB 2.7.0 Go module (built from source via Go proxy) ==="
|
||||
FERRETDB_MOD="https://proxy.golang.org/github.com/!ferret!d!b/!ferret!d!b/v2/@v/v2.7.0.info"
|
||||
HTTP=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 20 "$FERRETDB_MOD")
|
||||
if [ "$HTTP" = "200" ]; then
|
||||
printf " OK FerretDB v2/cmd/ferretdb@v2.7.0 available on Go proxy\n"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " FAIL [HTTP %s] FerretDB v2.7.0 not found on Go proxy\n %s\n" "$HTTP" "$FERRETDB_MOD"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Node.js 22.22.2 (all snap architectures) ==="
|
||||
check "Node.js 22.22.2 linux-x64 (amd64)" \
|
||||
"https://nodejs.org/dist/v22.22.2/node-v22.22.2-linux-x64.tar.gz"
|
||||
check "Node.js 22.22.2 linux-arm64 (arm64)" \
|
||||
"https://nodejs.org/dist/v22.22.2/node-v22.22.2-linux-arm64.tar.gz"
|
||||
check "Node.js 22.22.2 linux-armv7l (armhf)" \
|
||||
"https://nodejs.org/dist/v22.22.2/node-v22.22.2-linux-armv7l.tar.gz"
|
||||
check "Node.js 22.22.2 linux-s390x (s390x)" \
|
||||
"https://nodejs.org/dist/v22.22.2/node-v22.22.2-linux-s390x.tar.gz"
|
||||
check "Node.js 22.22.2 linux-ppc64le (ppc64el)" \
|
||||
"https://nodejs.org/dist/v22.22.2/node-v22.22.2-linux-ppc64le.tar.gz"
|
||||
echo ""
|
||||
|
||||
echo "=== Results ==="
|
||||
echo " Passed: $PASS"
|
||||
echo " Failed: $FAIL"
|
||||
echo ""
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "Some URLs are broken — update snapcraft.yaml before releasing."
|
||||
exit 1
|
||||
else
|
||||
echo "All URLs are reachable."
|
||||
fi
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Fetch the latest Node.js 22.x release and update all files that hardcode
|
||||
# the version number: Dockerfile, snapcraft.yaml, GitHub Actions workflow,
|
||||
# rebuild-wekan.sh, and releases/test-download-urls.sh.
|
||||
#
|
||||
# Usage (standalone):
|
||||
# ./releases/update-node-version.sh
|
||||
#
|
||||
# Called automatically by releases/release-all.sh before every release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
# BSD sed (macOS) requires an empty string after -i; GNU sed (Linux) does not.
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
sedi() { sed -i '' "$@"; }
|
||||
else
|
||||
sedi() { sed -i "$@"; }
|
||||
fi
|
||||
|
||||
# ── Fetch latest Node.js 22.x version ────────────────────────────────────────
|
||||
echo " Fetching latest Node.js 22.x version from nodejs.org..."
|
||||
|
||||
# The directory listing always contains a line like:
|
||||
# node-v22.22.2-linux-x64.tar.gz
|
||||
# Extract the version from the first x64 tarball listed.
|
||||
NEW_NODE=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ \
|
||||
| grep -o 'node-v22\.[0-9][0-9]*\.[0-9][0-9]*-linux-x64\.tar\.gz' \
|
||||
| head -1 \
|
||||
| sed 's/node-v\(.*\)-linux-x64\.tar\.gz/\1/')
|
||||
|
||||
if [ -z "$NEW_NODE" ]; then
|
||||
echo "Error: could not determine latest Node.js 22.x version." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Latest Node.js 22.x: $NEW_NODE"
|
||||
|
||||
# ── Dockerfile ────────────────────────────────────────────────────────────────
|
||||
# Matches: NODE_VERSION=v22.22.2
|
||||
sedi "s|NODE_VERSION=v22\.[0-9][0-9]*\.[0-9][0-9]*|NODE_VERSION=v${NEW_NODE}|g" \
|
||||
Dockerfile
|
||||
|
||||
# ── snapcraft.yaml ───────────────────────────────────────────────────────────
|
||||
# Matches: npm-node-version: 22.22.2
|
||||
sedi "s|npm-node-version: 22\.[0-9][0-9]*\.[0-9][0-9]*|npm-node-version: ${NEW_NODE}|g" \
|
||||
snapcraft.yaml
|
||||
# Matches: NODE_VERSION="22.22.2" (in wekan override-build)
|
||||
sedi "s|NODE_VERSION=\"22\.[0-9][0-9]*\.[0-9][0-9]*\"|NODE_VERSION=\"${NEW_NODE}\"|g" \
|
||||
snapcraft.yaml
|
||||
# Matches comment: matching Node.js 22.22.2 binary
|
||||
sedi "s|Node\.js 22\.[0-9][0-9]*\.[0-9][0-9]* binary|Node.js ${NEW_NODE} binary|g" \
|
||||
snapcraft.yaml
|
||||
|
||||
# ── .github/workflows/release-all.yml ────────────────────────────────────────
|
||||
WORKFLOW=".github/workflows/release-all.yml"
|
||||
# Matches: node-version: '22.22.0' (actions/setup-node, windows job)
|
||||
sedi "s|node-version: '22\.[0-9][0-9]*\.[0-9][0-9]*'|node-version: '${NEW_NODE}'|g" \
|
||||
"$WORKFLOW"
|
||||
# Matches: node:22.22.0-slim (docker run commands and comments)
|
||||
sedi "s|node:22\.[0-9][0-9]*\.[0-9][0-9]*-slim|node:${NEW_NODE}-slim|g" \
|
||||
"$WORKFLOW"
|
||||
# Matches version number in comments: bundles: 22.22.0 (ABI
|
||||
sedi "s|bundles: 22\.[0-9][0-9]*\.[0-9][0-9]* (ABI|bundles: ${NEW_NODE} (ABI|g" \
|
||||
"$WORKFLOW"
|
||||
# Matches: Node 22.22.0 — the exact version bundled
|
||||
sedi "s|Node 22\.[0-9][0-9]*\.[0-9][0-9]* —|Node ${NEW_NODE} —|g" \
|
||||
"$WORKFLOW"
|
||||
|
||||
# ── rebuild-wekan.sh ──────────────────────────────────────────────────────────
|
||||
# Matches: sudo n 22.22.0
|
||||
sedi "s|sudo n 22\.[0-9][0-9]*\.[0-9][0-9]*|sudo n ${NEW_NODE}|g" \
|
||||
rebuild-wekan.sh
|
||||
|
||||
# ── releases/test-download-urls.sh ───────────────────────────────────────────
|
||||
# Matches URL path: /v22.22.2/
|
||||
sedi "s|/v22\.[0-9][0-9]*\.[0-9][0-9]*/|/v${NEW_NODE}/|g" \
|
||||
releases/test-download-urls.sh
|
||||
# Matches tarball name: node-v22.22.2-
|
||||
sedi "s|node-v22\.[0-9][0-9]*\.[0-9][0-9]*-|node-v${NEW_NODE}-|g" \
|
||||
releases/test-download-urls.sh
|
||||
# Matches label text: Node.js 22.22.2
|
||||
sedi "s|Node\.js 22\.[0-9][0-9]*\.[0-9][0-9]*|Node.js ${NEW_NODE}|g" \
|
||||
releases/test-download-urls.sh
|
||||
|
||||
echo " Updated all files to Node.js $NEW_NODE."
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WeKan snap: GridFS / CollectionFS binary files → filesystem
|
||||
*
|
||||
* Reads GridFS chunks from the running FerretDB (MongoDB wire protocol),
|
||||
* writes files to $SNAP_COMMON/files/attachments/ and
|
||||
* $SNAP_COMMON/files/avatars/
|
||||
* then updates the path fields in the attachments/avatars collections.
|
||||
*
|
||||
* Called by mongodb-control after mongorestore is complete.
|
||||
* Safe to re-run (skips files that already exist at destination).
|
||||
*
|
||||
* Environment:
|
||||
* SOURCE_MONGO_URL mongodb://127.0.0.1:27017/wekan (FerretDB, already populated)
|
||||
* WRITABLE_PATH $SNAP_COMMON
|
||||
*/
|
||||
|
||||
import { MongoClient, GridFSBucket, ObjectId } from 'mongodb';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { statfsSync } from 'node:fs';
|
||||
|
||||
const MONGO_URL = process.env.SOURCE_MONGO_URL || 'mongodb://127.0.0.1:27017/wekan';
|
||||
const WRITABLE = process.env.WRITABLE_PATH || process.env.SNAP_COMMON || '/data';
|
||||
const ATTACH_DIR = path.join(WRITABLE, 'files', 'attachments');
|
||||
const AVATAR_DIR = path.join(WRITABLE, 'files', 'avatars');
|
||||
|
||||
fs.mkdirSync(ATTACH_DIR, { recursive: true });
|
||||
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function hasEnoughSpace(dir, bytes) {
|
||||
try {
|
||||
const st = statfsSync(dir);
|
||||
return Number(st.bavail) * Number(st.bsize) > bytes + 10 * 1024 * 1024;
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
try { name = decodeURIComponent(name); } catch {}
|
||||
name = name
|
||||
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||
.replace(/[/\\]/g, '_')
|
||||
.replace(/[:<>"|?*]/g, '_')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/_+/g, '_')
|
||||
.trim();
|
||||
if (name.length > 180) name = name.slice(0, 180);
|
||||
return name || 'file';
|
||||
}
|
||||
|
||||
function uniquePath(dir, idHex, originalName) {
|
||||
const safe = sanitizeFilename(originalName);
|
||||
let base = `${idHex}_${safe}`;
|
||||
let fullPath = path.join(dir, base);
|
||||
let counter = 0;
|
||||
while (fs.existsSync(fullPath)) {
|
||||
counter++;
|
||||
base = `${idHex}_${counter}_${safe}`;
|
||||
fullPath = path.join(dir, base);
|
||||
}
|
||||
return { fullPath, basename: base };
|
||||
}
|
||||
|
||||
async function extractFile(bucket, gridFsId, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const oid = typeof gridFsId === 'string' ? new ObjectId(gridFsId) : gridFsId;
|
||||
const src = bucket.openDownloadStream(oid);
|
||||
const dst = fs.createWriteStream(destPath);
|
||||
let bytes = 0;
|
||||
src.on('data', chunk => { bytes += chunk.length; });
|
||||
src.on('error', reject);
|
||||
dst.on('error', reject);
|
||||
src.pipe(dst);
|
||||
dst.on('finish', () => resolve(bytes));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
const client = await MongoClient.connect(MONGO_URL, {
|
||||
serverSelectionTimeoutMS: 30_000,
|
||||
directConnection: true,
|
||||
});
|
||||
const dbName = new URL(MONGO_URL.replace('mongodb://', 'http://')).pathname.slice(1) || 'wekan';
|
||||
const db = client.db(dbName);
|
||||
|
||||
let done = 0, skipped = 0, errors = 0;
|
||||
|
||||
async function migrateCollection(collName, destDir, bucketPrefix) {
|
||||
const filesCollName = `${bucketPrefix}.files`;
|
||||
const allColls = (await db.listCollections().toArray()).map(c => c.name);
|
||||
if (!allColls.includes(filesCollName)) return;
|
||||
|
||||
console.log(`[gridfs-migrate] Processing ${bucketPrefix} → ${destDir}`);
|
||||
const bucket = new GridFSBucket(db, { bucketName: bucketPrefix });
|
||||
|
||||
// Try CFS filerecord collection first, then fall back to Meteor-Files docs
|
||||
const sources = [];
|
||||
if (allColls.includes(`cfs.${collName}.filerecord`)) {
|
||||
const cursor = db.collection(`cfs.${collName}.filerecord`).find({});
|
||||
for await (const rec of cursor) {
|
||||
sources.push({ id: rec._id, gridFsId: rec.original?.gridFsFileId, name: rec.original?.name });
|
||||
}
|
||||
await cursor.close();
|
||||
}
|
||||
// Also handle Meteor-Files docs already in target that are marked storage:'gridfs'
|
||||
const mfCursor = db.collection(collName).find({ 'versions.original.storage': 'gridfs' });
|
||||
for await (const doc of mfCursor) {
|
||||
sources.push({ id: doc._id, gridFsId: doc.versions?.original?.meta?.gridFsFileId, name: doc.name });
|
||||
}
|
||||
await mfCursor.close();
|
||||
|
||||
for (const src of sources) {
|
||||
if (!src.gridFsId) { skipped++; continue; }
|
||||
|
||||
const originalName = src.name || String(src.id);
|
||||
const { fullPath, basename } = uniquePath(destDir, String(src.id), originalName);
|
||||
|
||||
// Already extracted
|
||||
if (fs.existsSync(fullPath)) { skipped++; continue; }
|
||||
|
||||
// Disk space check
|
||||
let fileSize = 0;
|
||||
try {
|
||||
const meta = await db.collection(filesCollName).findOne({ _id: new ObjectId(src.gridFsId) });
|
||||
fileSize = meta?.length || 0;
|
||||
} catch {}
|
||||
if (!hasEnoughSpace(destDir, fileSize)) {
|
||||
console.error(`[gridfs-migrate] Not enough disk space for ${originalName} — skipping`);
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const bytes = await extractFile(bucket, src.gridFsId, fullPath);
|
||||
done++;
|
||||
console.log(`[gridfs-migrate] ${basename} (${(bytes/1024).toFixed(1)} KB)`);
|
||||
|
||||
// Update DB record with new filesystem path and original filename for download
|
||||
await db.collection(collName).updateOne(
|
||||
{ _id: src.id },
|
||||
{
|
||||
$set: {
|
||||
path: fullPath,
|
||||
'meta.storedBasename': basename,
|
||||
'meta.originalFilename': originalName,
|
||||
'versions.original.path': fullPath,
|
||||
'versions.original.storage': 'fs',
|
||||
'versions.original.meta': {},
|
||||
},
|
||||
},
|
||||
{ upsert: false },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[gridfs-migrate] Error extracting ${src.id}: ${err.message}`);
|
||||
errors++;
|
||||
try { fs.unlinkSync(fullPath); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await migrateCollection('attachments', ATTACH_DIR, 'cfs_gridfs.attachments');
|
||||
await migrateCollection('avatars', AVATAR_DIR, 'cfs_gridfs.avatars');
|
||||
|
||||
await client.close();
|
||||
|
||||
console.log(`[gridfs-migrate] Done. extracted=${done} skipped=${skipped} errors=${errors}`);
|
||||
process.exit(errors > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* WeKan schema migration v8.43 — mongosh script
|
||||
*
|
||||
* Run with:
|
||||
* mongosh --port 27017 --file migrate-schema-v843.js
|
||||
*
|
||||
* Idempotent: safe to run multiple times.
|
||||
* Applies to: FerretDB (via MongoDB wire protocol) or plain MongoDB.
|
||||
*
|
||||
* What it does:
|
||||
* 1. Ensures every list has a non-empty swimlaneId.
|
||||
* 2. Ensures every card has a non-empty swimlaneId and a valid listId.
|
||||
* 3. Creates a "Rescued Data" swimlane for each board that has orphaned
|
||||
* entities (lists or cards without a valid swimlaneId/listId).
|
||||
* 4. Marks each board with migrationVersion: 843 once upgraded.
|
||||
* 5. Converts cfs.attachments.filerecord documents to the Meteor-Files
|
||||
* (ostrio:files) schema inside the attachments collection.
|
||||
* 6. Converts cfs.avatars.filerecord documents similarly.
|
||||
* 7. Writes a completion marker to _wekan_migration.
|
||||
*/
|
||||
|
||||
// ── Connect to the wekan database ──────────────────────────────────────────
|
||||
const db = db.getSiblingDB('wekan');
|
||||
const SCHEMA_VER = 843;
|
||||
|
||||
// ── Idempotency check ──────────────────────────────────────────────────────
|
||||
const marker = db.getCollection('_wekan_migration').findOne({ _id: 'schema_v843' });
|
||||
if (marker && marker.completedAt) {
|
||||
print('Schema v8.43 already applied at ' + marker.completedAt + ' — skipping.');
|
||||
quit(0);
|
||||
}
|
||||
|
||||
print('=== Applying WeKan schema v8.43 ===');
|
||||
|
||||
// ── 1. Build per-board default-swimlane map ────────────────────────────────
|
||||
print('Step 1: Building default swimlane map...');
|
||||
const defaultSwimlaneId = {}; // boardId → first swimlaneId
|
||||
db.swimlanes.find({ archived: false }, { _id: 1, boardId: 1, sort: 1 })
|
||||
.sort({ sort: 1 })
|
||||
.forEach(sw => {
|
||||
const bid = String(sw.boardId);
|
||||
if (!defaultSwimlaneId[bid]) defaultSwimlaneId[bid] = String(sw._id);
|
||||
});
|
||||
|
||||
// ── 2. Ensure each board that has entities has at least one swimlane ───────
|
||||
print('Step 2: Creating default swimlanes for boards that have none...');
|
||||
db.boards.find({}).forEach(board => {
|
||||
const boardId = String(board._id);
|
||||
if (db.swimlanes.countDocuments({ boardId }) === 0) {
|
||||
const newId = new ObjectId();
|
||||
db.swimlanes.insertOne({
|
||||
_id: newId,
|
||||
title: 'Default',
|
||||
boardId,
|
||||
archived: false,
|
||||
sort: 0,
|
||||
type: 'swimlane',
|
||||
createdAt: new Date(),
|
||||
modifiedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
defaultSwimlaneId[boardId] = String(newId);
|
||||
print(' Created default swimlane for board ' + boardId);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 3. Build list → swimlaneId map ────────────────────────────────────────
|
||||
print('Step 3: Building list → swimlane map...');
|
||||
const listSwimlaneMap = {}; // listId → swimlaneId
|
||||
db.lists.find({}, { _id: 1, swimlaneId: 1 }).forEach(ls => {
|
||||
listSwimlaneMap[String(ls._id)] = ls.swimlaneId || '';
|
||||
});
|
||||
|
||||
// ── 4. Ensure all lists have a swimlaneId ─────────────────────────────────
|
||||
print('Step 4: Patching lists without swimlaneId...');
|
||||
let listFixed = 0;
|
||||
db.lists.find({ $or: [{ swimlaneId: { $exists: false } }, { swimlaneId: '' }, { swimlaneId: null }] })
|
||||
.forEach(list => {
|
||||
const bid = String(list.boardId);
|
||||
const sid = defaultSwimlaneId[bid] || '';
|
||||
if (!sid) return;
|
||||
db.lists.updateOne({ _id: list._id }, { $set: { swimlaneId: sid } });
|
||||
listSwimlaneMap[String(list._id)] = sid;
|
||||
listFixed++;
|
||||
});
|
||||
print(' Lists patched: ' + listFixed);
|
||||
|
||||
// ── 5. Ensure all cards have a swimlaneId and a valid listId ──────────────
|
||||
print('Step 5: Patching cards without swimlaneId or with invalid listId...');
|
||||
const allListIds = new Set(db.lists.find({}, { _id: 1 }).map(l => String(l._id)));
|
||||
// First list per board for orphan fallback
|
||||
const firstListByBoard = {};
|
||||
db.lists.find({}, { _id: 1, boardId: 1, swimlaneId: 1 }).sort({ sort: 1 }).forEach(l => {
|
||||
if (!firstListByBoard[l.boardId]) firstListByBoard[l.boardId] = l;
|
||||
});
|
||||
|
||||
let cardFixed = 0;
|
||||
let cardOrphaned = 0;
|
||||
// Boards that need a "Rescued Data" swimlane
|
||||
const rescuedSwimlanes = {};
|
||||
|
||||
function getOrCreateRescuedSwimlane(boardId) {
|
||||
if (rescuedSwimlanes[boardId]) return rescuedSwimlanes[boardId];
|
||||
let sw = db.swimlanes.findOne({ boardId, title: /rescued.*data/i });
|
||||
if (!sw) {
|
||||
const id = new ObjectId();
|
||||
db.swimlanes.insertOne({
|
||||
_id: id,
|
||||
title: 'Rescued Data (Missing Swimlane)',
|
||||
boardId,
|
||||
archived: false,
|
||||
sort: 9999999,
|
||||
type: 'swimlane',
|
||||
color: 'red',
|
||||
createdAt: new Date(),
|
||||
modifiedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
// Also create a list inside that swimlane for the orphaned cards
|
||||
const listId = new ObjectId();
|
||||
db.lists.insertOne({
|
||||
_id: listId,
|
||||
title: 'Rescued Cards',
|
||||
boardId,
|
||||
swimlaneId: String(id),
|
||||
archived: false,
|
||||
sort: 0,
|
||||
type: 'list',
|
||||
createdAt: new Date(),
|
||||
modifiedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
rescuedSwimlanes[boardId] = { swimlaneId: String(id), listId: String(listId) };
|
||||
} else {
|
||||
// Find or create rescue list
|
||||
let rescList = db.lists.findOne({ boardId, swimlaneId: String(sw._id), title: /rescued/i });
|
||||
if (!rescList) {
|
||||
const listId = new ObjectId();
|
||||
db.lists.insertOne({
|
||||
_id: listId, title: 'Rescued Cards', boardId,
|
||||
swimlaneId: String(sw._id), archived: false, sort: 0,
|
||||
type: 'list', createdAt: new Date(), modifiedAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
rescuedSwimlanes[boardId] = { swimlaneId: String(sw._id), listId: String(listId) };
|
||||
} else {
|
||||
rescuedSwimlanes[boardId] = { swimlaneId: String(sw._id), listId: String(rescList._id) };
|
||||
}
|
||||
}
|
||||
return rescuedSwimlanes[boardId];
|
||||
}
|
||||
|
||||
db.cards.find({}).forEach(card => {
|
||||
const bid = String(card.boardId);
|
||||
const lid = String(card.listId || '');
|
||||
const sid = String(card.swimlaneId || '');
|
||||
let newSid = sid;
|
||||
let newLid = lid;
|
||||
let changed = false;
|
||||
|
||||
// Fix missing swimlaneId
|
||||
if (!newSid) {
|
||||
if (lid && listSwimlaneMap[lid]) {
|
||||
newSid = listSwimlaneMap[lid];
|
||||
} else {
|
||||
newSid = defaultSwimlaneId[bid] || '';
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Fix invalid listId (points to a list that doesn't exist)
|
||||
if (lid && !allListIds.has(lid)) {
|
||||
const rescued = getOrCreateRescuedSwimlane(bid);
|
||||
newLid = rescued.listId;
|
||||
newSid = rescued.swimlaneId;
|
||||
changed = true;
|
||||
cardOrphaned++;
|
||||
}
|
||||
|
||||
// Fix missing listId
|
||||
if (!newLid && firstListByBoard[bid]) {
|
||||
const fl = firstListByBoard[bid];
|
||||
newLid = String(fl._id);
|
||||
newSid = fl.swimlaneId || newSid;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
db.cards.updateOne({ _id: card._id }, { $set: { swimlaneId: newSid, listId: newLid } });
|
||||
cardFixed++;
|
||||
}
|
||||
});
|
||||
print(' Cards patched: ' + cardFixed + ' (' + cardOrphaned + ' orphaned → Rescued Data swimlane)');
|
||||
|
||||
// ── 6. Stamp boards with migrationVersion ─────────────────────────────────
|
||||
print('Step 6: Stamping boards with migrationVersion ' + SCHEMA_VER + '...');
|
||||
db.boards.updateMany(
|
||||
{ migrationVersion: { $lt: SCHEMA_VER } },
|
||||
{ $set: { migrationVersion: SCHEMA_VER, comprehensiveMigrationCompleted: true, fixMissingListsCompleted: true } }
|
||||
);
|
||||
db.boards.updateMany(
|
||||
{ migrationVersion: { $exists: false } },
|
||||
{ $set: { migrationVersion: SCHEMA_VER, comprehensiveMigrationCompleted: true, fixMissingListsCompleted: true } }
|
||||
);
|
||||
|
||||
// ── 7. Convert CollectionFS attachment records → Meteor-Files format ────────
|
||||
print('Step 7: Converting cfs.attachments.filerecord → attachments (Meteor-Files)...');
|
||||
let cfsAttachConverted = 0;
|
||||
if (db.getCollectionNames().includes('cfs.attachments.filerecord')) {
|
||||
db.getCollection('cfs.attachments.filerecord').find({}).forEach(record => {
|
||||
// Skip if already in new attachments collection
|
||||
if (db.attachments.findOne({ _id: record._id })) return;
|
||||
|
||||
const original = record.original || {};
|
||||
const meta = record.meta || {};
|
||||
const now = new Date();
|
||||
|
||||
db.attachments.insertOne({
|
||||
_id: record._id,
|
||||
name: original.name || record.filename || String(record._id),
|
||||
size: original.size || 0,
|
||||
type: original.type || 'application/octet-stream',
|
||||
extension: (original.name || '').split('.').pop() || '',
|
||||
extensionWithDot: (original.name || '').includes('.')
|
||||
? '.' + (original.name || '').split('.').pop() : '',
|
||||
meta: {
|
||||
boardId: meta.boardId || '',
|
||||
cardId: meta.cardId || '',
|
||||
listId: meta.listId || '',
|
||||
swimlaneId: meta.swimlaneId || '',
|
||||
userId: meta.userId || '',
|
||||
source: 'cfs-migration',
|
||||
cfsOriginalId: String(record._id),
|
||||
originalFilename: original.name || '',
|
||||
},
|
||||
path: '', // set later by GridFS→filesystem migration
|
||||
versions: { original: { path: '', size: original.size || 0,
|
||||
type: original.type || 'application/octet-stream',
|
||||
storage: 'fs' } },
|
||||
userId: meta.userId || '',
|
||||
uploadedAt: record.uploadedAt || record.createdAt || now,
|
||||
uploadedAtOstrio: record.uploadedAt || record.createdAt || now,
|
||||
updatedAt: now,
|
||||
public: false,
|
||||
collectionName: 'attachments',
|
||||
});
|
||||
cfsAttachConverted++;
|
||||
});
|
||||
}
|
||||
print(' CFS attachments converted: ' + cfsAttachConverted);
|
||||
|
||||
// ── 8. Convert CollectionFS avatar records → Meteor-Files format ────────────
|
||||
print('Step 8: Converting cfs.avatars.filerecord → avatars (Meteor-Files)...');
|
||||
let cfsAvatarConverted = 0;
|
||||
if (db.getCollectionNames().includes('cfs.avatars.filerecord')) {
|
||||
db.getCollection('cfs.avatars.filerecord').find({}).forEach(record => {
|
||||
if (db.avatars.findOne({ _id: record._id })) return;
|
||||
|
||||
const original = record.original || {};
|
||||
const now = new Date();
|
||||
|
||||
db.avatars.insertOne({
|
||||
_id: record._id,
|
||||
name: original.name || String(record._id),
|
||||
size: original.size || 0,
|
||||
type: original.type || 'image/jpeg',
|
||||
extension: (original.name || '').split('.').pop() || '',
|
||||
extensionWithDot: (original.name || '').includes('.')
|
||||
? '.' + (original.name || '').split('.').pop() : '',
|
||||
meta: {
|
||||
source: 'cfs-migration',
|
||||
cfsOriginalId: String(record._id),
|
||||
originalFilename: original.name || '',
|
||||
},
|
||||
path: '',
|
||||
versions: { original: { path: '', size: original.size || 0,
|
||||
type: original.type || 'image/jpeg',
|
||||
storage: 'fs' } },
|
||||
userId: record.userId || record.meta && record.meta.userId || '',
|
||||
uploadedAt: record.uploadedAt || record.createdAt || now,
|
||||
uploadedAtOstrio: record.uploadedAt || record.createdAt || now,
|
||||
updatedAt: now,
|
||||
public: false,
|
||||
collectionName: 'avatars',
|
||||
});
|
||||
cfsAvatarConverted++;
|
||||
});
|
||||
}
|
||||
print(' CFS avatars converted: ' + cfsAvatarConverted);
|
||||
|
||||
// ── 9. Fix all existing attachment/avatar path references ──────────────────
|
||||
// Normalise legacy /cfs/files/... URLs to /cdn/storage/... in all card fields
|
||||
print('Step 9: Normalising legacy file URL references in cards...');
|
||||
let urlsFixed = 0;
|
||||
db.cards.find({ coverId: /^\/cfs\/files\// }).forEach(card => {
|
||||
const newId = card.coverId.replace(/^.*\/([^/]+)$/, '$1').replace(/\?.*$/, '');
|
||||
db.cards.updateOne({ _id: card._id }, { $set: { coverId: newId } });
|
||||
urlsFixed++;
|
||||
});
|
||||
print(' Card coverIds normalised: ' + urlsFixed);
|
||||
|
||||
// ── 10. Write completion marker ────────────────────────────────────────────
|
||||
db.getCollection('_wekan_migration').replaceOne(
|
||||
{ _id: 'schema_v843' },
|
||||
{
|
||||
_id: 'schema_v843',
|
||||
schemaVersion: SCHEMA_VER,
|
||||
completedAt: new Date(),
|
||||
stats: {
|
||||
listsPatched: listFixed,
|
||||
cardsPatched: cardFixed,
|
||||
cardsOrphaned: cardOrphaned,
|
||||
cfsAttachmentsConverted: cfsAttachConverted,
|
||||
cfsAvatarsConverted: cfsAvatarConverted,
|
||||
urlsNormalised: urlsFixed,
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
print('');
|
||||
print('=== Schema v8.43 migration complete ===');
|
||||
print(' Lists patched: ' + listFixed);
|
||||
print(' Cards patched: ' + cardFixed);
|
||||
print(' Cards rescued: ' + cardOrphaned);
|
||||
print(' CFS attach conv: ' + cfsAttachConverted);
|
||||
print(' CFS avatar conv: ' + cfsAvatarConverted);
|
||||
print(' URLs normalised: ' + urlsFixed);
|
||||
+269
-105
@@ -1,133 +1,297 @@
|
||||
#!/bin/bash
|
||||
|
||||
# MongoDB Control Script
|
||||
# Starts MongoDB 7.x server only
|
||||
# WeKan Database Control Script
|
||||
#
|
||||
# All snap platforms now use FerretDB 2 + PostgreSQL 16.
|
||||
# bin/mongod is a wrapper that initialises PostgreSQL and starts FerretDB.
|
||||
#
|
||||
# On first start after upgrading from an old snap that bundled MongoDB:
|
||||
# 1. Detects WiredTiger files at SNAP_COMMON (legacy MongoDB data).
|
||||
# 2. Temporarily starts the previous snap revision's mongod on port 27020.
|
||||
# 3. Dumps all data with mongodump.
|
||||
# 4. Stops old mongod.
|
||||
# 5. Starts FerretDB + PostgreSQL via bin/mongod.
|
||||
# 6. Restores data to FerretDB with mongorestore.
|
||||
# 7. Applies schema v8.43 upgrades (swimlaneId, orphan cards, file URLs).
|
||||
# 8. Migrates CollectionFS / GridFS binary files to
|
||||
# $SNAP_COMMON/files/attachments/ and $SNAP_COMMON/files/avatars/.
|
||||
# 9. Renames WiredTiger → WiredTiger.migrated so the step is never repeated.
|
||||
|
||||
# get wekan/mongo settings
|
||||
# ── Read snap settings ──────────────────────────────────────────────────────
|
||||
echo "Reading snap settings..."
|
||||
source $SNAP/bin/wekan-read-settings
|
||||
source "$SNAP/bin/wekan-read-settings"
|
||||
|
||||
# Debug: Show what we got from snap settings
|
||||
echo "Snap settings loaded:"
|
||||
echo " MONGODB_BIND_IP: '${MONGODB_BIND_IP}'"
|
||||
echo " MONGODB_PORT: '${MONGODB_PORT}'"
|
||||
echo " MONGODB_BIND_IP: '${MONGODB_BIND_IP}'"
|
||||
echo " MONGODB_PORT: '${MONGODB_PORT}'"
|
||||
echo " MONGODB_BIND_UNIX_SOCKET: '${MONGODB_BIND_UNIX_SOCKET}'"
|
||||
|
||||
# Debug: Check snap settings directly
|
||||
echo "Direct snap settings check:"
|
||||
echo " mongodb-port: $(snapctl get mongodb-port 2>/dev/null || echo 'not set')"
|
||||
echo " mongodb-bind-ip: $(snapctl get mongodb-bind-ip 2>/dev/null || echo 'not set')"
|
||||
echo " mongodb-bind-unix-socket: $(snapctl get mongodb-bind-unix-socket 2>/dev/null || echo 'not set')"
|
||||
|
||||
if [ "true" == "${DISABLE_MONGODB}" ]; then
|
||||
echo "mongodb is disabled. Stop service"
|
||||
snapctl stop --disable ${SNAP_NAME}.mongodb
|
||||
if [ "true" = "${DISABLE_MONGODB}" ]; then
|
||||
echo "Database service is disabled. Stopping."
|
||||
snapctl stop --disable "${SNAP_NAME}.mongodb"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# make sure we have set minimum env variables for locale
|
||||
if [ -z "${LANG}" ]; then
|
||||
export LANG=en_US.UTF-8
|
||||
fi
|
||||
|
||||
# ── Locale / path setup ─────────────────────────────────────────────────────
|
||||
[ -z "${LANG}" ] && export LANG=en_US.UTF-8
|
||||
export LC_ALL=C
|
||||
export PATH=/snap/${SNAP_NAME}/current/usr/bin:/snap/${SNAP_NAME}/current/bin:${PATH}
|
||||
export LD_LIBRARY_PATH=/snap/${SNAP_NAME}/current/lib:/snap/${SNAP_NAME}/current/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}
|
||||
export PATH="/snap/${SNAP_NAME}/current/bin:/snap/${SNAP_NAME}/current/usr/bin:${PATH}"
|
||||
export LD_LIBRARY_PATH="/snap/${SNAP_NAME}/current/lib:/snap/${SNAP_NAME}/current/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}"
|
||||
|
||||
# If temporary settings log exists, delete it
|
||||
if [ -f ${SNAP_COMMON}/settings.log ]; then
|
||||
rm ${SNAP_COMMON}/settings.log
|
||||
fi
|
||||
# Remove stale settings log
|
||||
[ -f "${SNAP_COMMON}/settings.log" ] && rm "${SNAP_COMMON}/settings.log"
|
||||
|
||||
# Set MongoDB log destination to snapcommon for log file detection
|
||||
export MONGO_LOG_DESTINATION="snapcommon"
|
||||
# ── Effective port ──────────────────────────────────────────────────────────
|
||||
FERRET_PORT="${MONGODB_PORT:-27017}"
|
||||
|
||||
# Set MongoDB data directory
|
||||
export MONGO_DATA_DIR="${SNAP_COMMON}"
|
||||
|
||||
# Create MongoDB data directory if it doesn't exist
|
||||
if [ ! -d "$MONGO_DATA_DIR" ]; then
|
||||
mkdir -p "$MONGO_DATA_DIR"
|
||||
chmod 755 "$MONGO_DATA_DIR"
|
||||
fi
|
||||
|
||||
# Set MongoDB log file path
|
||||
export MONGO_LOG_FILE="${SNAP_COMMON}/mongodb.log"
|
||||
|
||||
# Build bind options from snap settings
|
||||
BIND_OPTIONS=""
|
||||
if [ "nill" != "${MONGODB_BIND_UNIX_SOCKET}" ] && [ -n "${MONGODB_BIND_UNIX_SOCKET}" ]; then
|
||||
BIND_OPTIONS+=" --unixSocketPrefix ${MONGODB_BIND_UNIX_SOCKET}"
|
||||
fi
|
||||
if [ -n "${MONGODB_BIND_IP}" ]; then
|
||||
BIND_OPTIONS+=" --bind_ip ${MONGODB_BIND_IP}"
|
||||
else
|
||||
BIND_OPTIONS+=" --bind_ip 127.0.0.1"
|
||||
fi
|
||||
if [ -n "${MONGODB_PORT}" ]; then
|
||||
BIND_OPTIONS+=" --port ${MONGODB_PORT}"
|
||||
else
|
||||
BIND_OPTIONS+=" --port 27019"
|
||||
fi
|
||||
|
||||
# Debug: Show what settings we're using
|
||||
echo "MongoDB settings:"
|
||||
echo " MONGODB_BIND_IP: ${MONGODB_BIND_IP:-127.0.0.1}"
|
||||
echo " MONGODB_PORT: ${MONGODB_PORT:-27017}"
|
||||
echo " MONGODB_BIND_UNIX_SOCKET: ${MONGODB_BIND_UNIX_SOCKET:-not set}"
|
||||
echo " BIND_OPTIONS: ${BIND_OPTIONS}"
|
||||
|
||||
# Check if MongoDB is already running
|
||||
check_mongodb_running() {
|
||||
local port="${MONGODB_PORT:-27017}"
|
||||
local bind_ip="${MONGODB_BIND_IP:-127.0.0.1}"
|
||||
|
||||
# Check if MongoDB is already running on the configured port
|
||||
if netstat -tuln 2>/dev/null | grep -q ":${port} "; then
|
||||
echo "MongoDB is already running on port ${port}"
|
||||
# Prevent multiple instances
|
||||
check_port_in_use() {
|
||||
if netstat -tuln 2>/dev/null | grep -q ":${FERRET_PORT} "; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Alternative check using lsof if netstat is not available
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
if lsof -i ":${port}" >/dev/null 2>&1; then
|
||||
echo "MongoDB is already running on port ${port} (detected via lsof)"
|
||||
return 0
|
||||
fi
|
||||
if command -v lsof >/dev/null 2>&1 && lsof -i ":${FERRET_PORT}" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if there's a PID file and the process is still running
|
||||
if [ -f "${SNAP_COMMON}/mongodb.pid" ]; then
|
||||
local pid=$(cat "${SNAP_COMMON}/mongodb.pid" 2>/dev/null)
|
||||
if [ -f "${SNAP_COMMON}/ferretdb.pid" ]; then
|
||||
local pid
|
||||
pid=$(cat "${SNAP_COMMON}/ferretdb.pid" 2>/dev/null)
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo "MongoDB is already running (PID: $pid)"
|
||||
return 0
|
||||
else
|
||||
# Remove stale PID file
|
||||
rm -f "${SNAP_COMMON}/mongodb.pid"
|
||||
fi
|
||||
rm -f "${SNAP_COMMON}/ferretdb.pid"
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Cleanup function to remove PID file on exit
|
||||
cleanup() {
|
||||
rm -f "${SNAP_COMMON}/mongodb.pid"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Check if MongoDB is already running
|
||||
if check_mongodb_running; then
|
||||
echo "MongoDB is already running. Exiting to prevent multiple instances."
|
||||
if check_port_in_use; then
|
||||
echo "Database already running on port ${FERRET_PORT}. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Start MongoDB 7.x server
|
||||
echo "Starting MongoDB 7.x server..."
|
||||
# Create PID file
|
||||
echo $$ > "${SNAP_COMMON}/mongodb.pid"
|
||||
exec /snap/${SNAP_NAME}/current/bin/mongod \
|
||||
--dbpath="$MONGO_DATA_DIR" \
|
||||
--logpath="$MONGO_LOG_FILE" \
|
||||
--logappend $BIND_OPTIONS
|
||||
# PID-file cleanup on exit
|
||||
cleanup() { rm -f "${SNAP_COMMON}/ferretdb.pid"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Helper: wait for mongod-compatible port ─────────────────────────────────
|
||||
wait_for_mongo_port() {
|
||||
local port="$1"
|
||||
local label="$2"
|
||||
echo "Waiting for ${label} on port ${port}..."
|
||||
for i in $(seq 1 90); do
|
||||
if "/snap/${SNAP_NAME}/current/bin/mongosh" \
|
||||
--quiet --port "$port" \
|
||||
--eval "db.adminCommand('ping')" >/dev/null 2>&1; then
|
||||
echo "${label} is ready."
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "ERROR: ${label} did not become ready within 90 s." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Migration: MongoDB WiredTiger → FerretDB ────────────────────────────────
|
||||
WIREDTIGER_MARKER="${SNAP_COMMON}/WiredTiger"
|
||||
MIGRATION_DONE_MARKER="${SNAP_COMMON}/WiredTiger.migrated"
|
||||
DUMP_DIR="${SNAP_COMMON}/_migration_dump"
|
||||
MIGRATION_LOG="${SNAP_COMMON}/migration.log"
|
||||
ATTACH_DIR="${SNAP_COMMON}/files/attachments"
|
||||
AVATAR_DIR="${SNAP_COMMON}/files/avatars"
|
||||
|
||||
run_mongodb_to_ferretdb_migration() {
|
||||
echo "=== Starting MongoDB → FerretDB migration ===" | tee -a "$MIGRATION_LOG"
|
||||
echo "$(date)" | tee -a "$MIGRATION_LOG"
|
||||
|
||||
# Find old mongod binary in the previous snap revision.
|
||||
# 'snap refresh' keeps the previous revision in /snap/<name>/<rev>/.
|
||||
OLD_MONGOD=""
|
||||
if [ -f "/snap/${SNAP_NAME}/previous/bin/mongod" ]; then
|
||||
# Check it is the real MongoDB binary (the new wrapper contains 'ferretdb')
|
||||
if ! grep -q 'ferretdb' "/snap/${SNAP_NAME}/previous/bin/mongod" 2>/dev/null; then
|
||||
OLD_MONGOD="/snap/${SNAP_NAME}/previous/bin/mongod"
|
||||
fi
|
||||
fi
|
||||
# Also check by revision number (snap keeps at least 2 revisions)
|
||||
if [ -z "$OLD_MONGOD" ]; then
|
||||
for rev_dir in $(ls /snap/${SNAP_NAME}/ 2>/dev/null | grep -E '^[0-9]+$' | sort -rn); do
|
||||
candidate="/snap/${SNAP_NAME}/${rev_dir}/bin/mongod"
|
||||
if [ -f "$candidate" ] && ! grep -q 'ferretdb' "$candidate" 2>/dev/null; then
|
||||
OLD_MONGOD="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -z "$OLD_MONGOD" ]; then
|
||||
echo "WARNING: Cannot find old MongoDB binary in any previous snap revision." | tee -a "$MIGRATION_LOG"
|
||||
echo " Skipping data migration. Your old MongoDB data is preserved at:" | tee -a "$MIGRATION_LOG"
|
||||
echo " $SNAP_COMMON (WiredTiger format)" | tee -a "$MIGRATION_LOG"
|
||||
echo " To migrate manually:" | tee -a "$MIGRATION_LOG"
|
||||
echo " 1. Install mongodb-community or use Docker: mongo:8" | tee -a "$MIGRATION_LOG"
|
||||
echo " 2. mongodump --dbpath=$SNAP_COMMON --out=$DUMP_DIR" | tee -a "$MIGRATION_LOG"
|
||||
echo " 3. mongorestore --uri=mongodb://127.0.0.1:${FERRET_PORT} $DUMP_DIR" | tee -a "$MIGRATION_LOG"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Found old MongoDB binary: $OLD_MONGOD" | tee -a "$MIGRATION_LOG"
|
||||
|
||||
# Start old MongoDB on a temp port (avoid conflict with FerretDB target port)
|
||||
OLD_PORT=27020
|
||||
rm -rf "$DUMP_DIR"
|
||||
mkdir -p "$DUMP_DIR"
|
||||
|
||||
echo "Starting old MongoDB on port ${OLD_PORT}..." | tee -a "$MIGRATION_LOG"
|
||||
"$OLD_MONGOD" \
|
||||
--dbpath="$SNAP_COMMON" \
|
||||
--port="$OLD_PORT" \
|
||||
--bind_ip=127.0.0.1 \
|
||||
--logpath="${SNAP_COMMON}/old_mongod_migration.log" \
|
||||
--logappend \
|
||||
--noauth &
|
||||
OLD_MONGOD_PID=$!
|
||||
|
||||
# Wait for old MongoDB to start
|
||||
for i in $(seq 1 90); do
|
||||
if "/snap/${SNAP_NAME}/current/bin/mongosh" \
|
||||
--quiet --port "$OLD_PORT" \
|
||||
--eval "db.adminCommand('ping')" >/dev/null 2>&1; then
|
||||
echo "Old MongoDB is ready on port ${OLD_PORT}." | tee -a "$MIGRATION_LOG"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
if [ "$i" -eq 90 ]; then
|
||||
echo "ERROR: Old MongoDB failed to start within 90 s. Migration aborted." | tee -a "$MIGRATION_LOG"
|
||||
kill "$OLD_MONGOD_PID" 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Dump all data from old MongoDB
|
||||
echo "Dumping data from old MongoDB..." | tee -a "$MIGRATION_LOG"
|
||||
"/snap/${SNAP_NAME}/current/bin/mongodump" \
|
||||
--port="$OLD_PORT" \
|
||||
--out="$DUMP_DIR" \
|
||||
--quiet 2>>"$MIGRATION_LOG"
|
||||
DUMP_EXIT=$?
|
||||
|
||||
# Stop old MongoDB
|
||||
echo "Stopping old MongoDB..." | tee -a "$MIGRATION_LOG"
|
||||
kill "$OLD_MONGOD_PID" 2>/dev/null
|
||||
wait "$OLD_MONGOD_PID" 2>/dev/null
|
||||
|
||||
if [ "$DUMP_EXIT" -ne 0 ]; then
|
||||
echo "ERROR: mongodump failed (exit $DUMP_EXIT). Migration aborted." | tee -a "$MIGRATION_LOG"
|
||||
return 1
|
||||
fi
|
||||
echo "Dump complete: $DUMP_DIR" | tee -a "$MIGRATION_LOG"
|
||||
return 0
|
||||
}
|
||||
|
||||
restore_dump_to_ferretdb() {
|
||||
echo "Restoring dump to FerretDB on port ${FERRET_PORT}..." | tee -a "$MIGRATION_LOG"
|
||||
"/snap/${SNAP_NAME}/current/bin/mongorestore" \
|
||||
--uri="mongodb://127.0.0.1:${FERRET_PORT}" \
|
||||
--drop \
|
||||
--quiet \
|
||||
"$DUMP_DIR" 2>>"$MIGRATION_LOG"
|
||||
local exit_code=$?
|
||||
if [ "$exit_code" -ne 0 ]; then
|
||||
echo "ERROR: mongorestore failed (exit $exit_code)." | tee -a "$MIGRATION_LOG"
|
||||
return 1
|
||||
fi
|
||||
echo "Restore complete." | tee -a "$MIGRATION_LOG"
|
||||
return 0
|
||||
}
|
||||
|
||||
apply_schema_and_migrate_files() {
|
||||
# ── Phase A: mongosh schema upgrade script (swimlaneIds, CFS→Meteor-Files) ─
|
||||
local mongosh_script="/snap/${SNAP_NAME}/current/bin/migrate-schema-v843.js"
|
||||
if [ -f "$mongosh_script" ] && [ -f "/snap/${SNAP_NAME}/current/bin/mongosh" ]; then
|
||||
echo "Applying schema v8.43 upgrades via mongosh..." | tee -a "$MIGRATION_LOG"
|
||||
"/snap/${SNAP_NAME}/current/bin/mongosh" \
|
||||
--quiet \
|
||||
--port "$FERRET_PORT" \
|
||||
--file "$mongosh_script" 2>>"$MIGRATION_LOG"
|
||||
echo "Schema migration done." | tee -a "$MIGRATION_LOG"
|
||||
else
|
||||
echo "WARNING: mongosh schema script not found — skipping schema upgrade." | tee -a "$MIGRATION_LOG"
|
||||
fi
|
||||
|
||||
# ── Phase B: GridFS / CollectionFS binary files → filesystem ──────────────
|
||||
# Uses migrate-mongodb-to-ferretdb.mjs (bundled in the 'migration' directory
|
||||
# together with its 'mongodb' npm dependency) and the snap's Node.js binary.
|
||||
local node_bin="/snap/${SNAP_NAME}/current/usr/bin/node"
|
||||
[ -f "$node_bin" ] || node_bin=$(which node 2>/dev/null)
|
||||
local migrate_script="/snap/${SNAP_NAME}/current/migration/migrate-mongodb-to-ferretdb.mjs"
|
||||
|
||||
if [ ! -f "$node_bin" ] || [ ! -f "$migrate_script" ]; then
|
||||
echo "WARNING: migration script or node not found — GridFS files stay in FerretDB." | tee -a "$MIGRATION_LOG"
|
||||
echo " WeKan's backward-compatibility layer will serve them until manually migrated." | tee -a "$MIGRATION_LOG"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$ATTACH_DIR" "$AVATAR_DIR"
|
||||
echo "Migrating GridFS/CollectionFS files to filesystem ($ATTACH_DIR)..." | tee -a "$MIGRATION_LOG"
|
||||
|
||||
# SOURCE and TARGET both point to FerretDB — data is already there after
|
||||
# mongorestore. The script reads GridFS chunks and writes them to filesystem,
|
||||
# then updates the attachment/avatar records in place (idempotent).
|
||||
NODE_PATH="/snap/${SNAP_NAME}/current/migration/node_modules" \
|
||||
WRITABLE_PATH="$SNAP_COMMON" \
|
||||
MIGRATION_PORT=8082 \
|
||||
SOURCE_MONGO_URL="mongodb://127.0.0.1:${FERRET_PORT}/wekan" \
|
||||
TARGET_MONGO_URL="mongodb://127.0.0.1:${FERRET_PORT}/wekan" \
|
||||
FORCE_MIGRATE=true \
|
||||
"$node_bin" "$migrate_script" 2>>"$MIGRATION_LOG"
|
||||
|
||||
echo "File migration done." | tee -a "$MIGRATION_LOG"
|
||||
}
|
||||
|
||||
# ── Main startup logic ──────────────────────────────────────────────────────
|
||||
NEED_MIGRATION=false
|
||||
if [ -f "$WIREDTIGER_MARKER" ] && [ ! -f "$MIGRATION_DONE_MARKER" ]; then
|
||||
NEED_MIGRATION=true
|
||||
fi
|
||||
|
||||
if [ "$NEED_MIGRATION" = "true" ]; then
|
||||
echo "=== WiredTiger (MongoDB) data detected — migration required ==="
|
||||
# Phase 1: dump from old MongoDB
|
||||
if run_mongodb_to_ferretdb_migration; then
|
||||
HAVE_DUMP=true
|
||||
else
|
||||
HAVE_DUMP=false
|
||||
echo "Migration dump skipped or failed; starting FerretDB with existing data." | tee -a "$MIGRATION_LOG"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Start FerretDB + PostgreSQL (always) ────────────────────────────────────
|
||||
echo "Starting FerretDB 2 + PostgreSQL 16..."
|
||||
echo $$ > "${SNAP_COMMON}/ferretdb.pid"
|
||||
|
||||
# bin/mongod is the wrapper script written by snapcraft's database part
|
||||
"/snap/${SNAP_NAME}/current/bin/mongod" &
|
||||
FERRETDB_PID=$!
|
||||
|
||||
# Wait for FerretDB to accept connections
|
||||
wait_for_mongo_port "$FERRET_PORT" "FerretDB"
|
||||
FERRET_READY=$?
|
||||
|
||||
if [ "$NEED_MIGRATION" = "true" ] && [ "$FERRET_READY" -eq 0 ] && [ "$HAVE_DUMP" = "true" ]; then
|
||||
# Phase 2: restore dump into FerretDB
|
||||
if restore_dump_to_ferretdb; then
|
||||
# Phase 3: schema upgrades + Phase 4: GridFS → filesystem (combined)
|
||||
apply_schema_and_migrate_files
|
||||
# Mark migration complete so we never re-run it
|
||||
mv "$WIREDTIGER_MARKER" "$MIGRATION_DONE_MARKER"
|
||||
echo "=== Migration complete. Old WiredTiger data renamed to WiredTiger.migrated ===" | tee -a "$MIGRATION_LOG"
|
||||
rm -rf "$DUMP_DIR"
|
||||
else
|
||||
echo "ERROR: Restore failed. FerretDB running but data may be incomplete." | tee -a "$MIGRATION_LOG"
|
||||
echo " Manual intervention required. Dump preserved at: $DUMP_DIR" | tee -a "$MIGRATION_LOG"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Database ready on port ${FERRET_PORT}."
|
||||
|
||||
# Stay alive — snap service manager watches this process
|
||||
wait $FERRETDB_PID
|
||||
+204
-48
@@ -22,11 +22,29 @@ issues: https://github.com/wekan/wekan/issues
|
||||
source-code: https://github.com/wekan/wekan
|
||||
website: https://wekan.fi
|
||||
|
||||
# Use platforms instead of architectures for core24 base
|
||||
# Platforms supported by the snap (database bundled inside):
|
||||
# ALL platforms — FerretDB 2 + PostgreSQL 16
|
||||
# amd64, arm64 — FerretDB 2 prebuilt static binary (GitHub Releases)
|
||||
# + mongosh + mongodb-database-tools (backup/restore/migrate)
|
||||
# armhf, s390x, ppc64el — FerretDB 2 built from Go source
|
||||
# Upgrading from an older snap that bundled MongoDB: mongodb-control
|
||||
# automatically migrates existing WiredTiger data to FerretDB on first start.
|
||||
platforms:
|
||||
amd64:
|
||||
build-on: amd64
|
||||
build-for: amd64
|
||||
arm64:
|
||||
build-on: arm64
|
||||
build-for: arm64
|
||||
armhf:
|
||||
build-on: armhf
|
||||
build-for: armhf
|
||||
s390x:
|
||||
build-on: s390x
|
||||
build-for: s390x
|
||||
ppc64el:
|
||||
build-on: ppc64el
|
||||
build-for: ppc64el
|
||||
|
||||
plugs:
|
||||
mongodb-plug:
|
||||
@@ -77,43 +95,156 @@ apps:
|
||||
|
||||
|
||||
parts:
|
||||
mongodb:
|
||||
source: https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu2204-7.0.31.tgz
|
||||
plugin: dump
|
||||
# database: FerretDB 2 + PostgreSQL 16 on ALL platforms.
|
||||
#
|
||||
# FerretDB speaks the MongoDB wire protocol so WeKan/Meteor connect to it
|
||||
# identically to MongoDB. OpLog is emulated via --replication-set-name=rs0
|
||||
# which gives Meteor the real-time pub/sub it needs.
|
||||
#
|
||||
# amd64, arm64 — FerretDB 2 prebuilt static binary (from GitHub Releases)
|
||||
# + mongosh 2.8.2 and mongodb-database-tools 100.13.0
|
||||
# (both work against FerretDB's MongoDB wire protocol,
|
||||
# used for backup / restore / migration)
|
||||
# + PostgreSQL 16 (storage backend, via stage-packages)
|
||||
#
|
||||
# armhf, s390x, ppc64el — FerretDB 2 built from Go source (no prebuilt
|
||||
# binaries exist for these platforms yet) + PostgreSQL 16
|
||||
#
|
||||
# Upgrade path from MongoDB (snap):
|
||||
# On first startup after snap refresh, mongodb-control detects whether
|
||||
# the SNAP_COMMON directory still contains WiredTiger (old MongoDB) data.
|
||||
# If found it:
|
||||
# 1. Starts the previous snap revision's mongod briefly on a temp port.
|
||||
# 2. Dumps all data with mongodump.
|
||||
# 3. Stops old mongod.
|
||||
# 4. Starts FerretDB + PostgreSQL.
|
||||
# 5. Restores data with mongorestore.
|
||||
# 6. Applies schema v8.43 upgrades via mongosh script.
|
||||
# 7. Migrates GridFS files to $SNAP_COMMON/files/attachments|avatars/.
|
||||
# 8. Renames WiredTiger → WiredTiger.migrated to mark completion.
|
||||
#
|
||||
# A DATABASE_BACKEND file (always "ferretdb") is written at build time so
|
||||
# mongodb-control can confirm the expected backend without calling dpkg.
|
||||
database:
|
||||
plugin: nil
|
||||
build-packages:
|
||||
- wget
|
||||
- ca-certificates
|
||||
stage-packages:
|
||||
- libssl3
|
||||
- libcurl4
|
||||
- libstemmer0d
|
||||
- zlib1g
|
||||
- libsnappy1v5
|
||||
- libyaml-cpp0.8
|
||||
- libpcre3
|
||||
- libpcrecpp0v5
|
||||
- libboost-system1.74.0
|
||||
- libboost-iostreams1.74.0
|
||||
- libboost-filesystem1.74.0
|
||||
- libboost-program-options1.74.0
|
||||
- libgoogle-perftools4
|
||||
# PostgreSQL 16 is the FerretDB storage backend on all platforms.
|
||||
# FerretDB itself is a static Go binary with no runtime library deps.
|
||||
- postgresql-16
|
||||
- postgresql-client-16
|
||||
override-build: |
|
||||
SNAP_ARCH=$(dpkg --print-architecture)
|
||||
mkdir -p $SNAPCRAFT_PART_INSTALL/bin $SNAPCRAFT_PART_INSTALL/usr
|
||||
|
||||
FERRETDB_VERSION="2.7.0"
|
||||
|
||||
# ── FerretDB binary ──────────────────────────────────────────────
|
||||
case "$SNAP_ARCH" in
|
||||
amd64)
|
||||
# FerretDB 2 prebuilt static binary — amd64
|
||||
wget -q -O "$SNAPCRAFT_PART_INSTALL/bin/ferretdb" \
|
||||
"https://github.com/FerretDB/FerretDB/releases/download/v${FERRETDB_VERSION}/ferretdb-linux-amd64"
|
||||
chmod +x "$SNAPCRAFT_PART_INSTALL/bin/ferretdb"
|
||||
# mongosh 2.8.2 (MongoDB-shell, works against FerretDB)
|
||||
wget -q https://downloads.mongodb.com/compass/mongosh-2.8.2-linux-x64.tgz
|
||||
tar xzf mongosh-2.8.2-linux-x64.tgz --strip-components=1 -C $SNAPCRAFT_PART_INSTALL
|
||||
rm mongosh-2.8.2-linux-x64.tgz
|
||||
# mongodb-database-tools 100.13.0 (mongodump/mongorestore, work with FerretDB)
|
||||
wget -q https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2404-x86_64-100.13.0.tgz
|
||||
tar xzf mongodb-database-tools-ubuntu2404-x86_64-100.13.0.tgz --strip-components=1 -C $SNAPCRAFT_PART_INSTALL
|
||||
rm mongodb-database-tools-ubuntu2404-x86_64-100.13.0.tgz
|
||||
;;
|
||||
|
||||
arm64)
|
||||
# FerretDB 2 prebuilt static binary — arm64
|
||||
wget -q -O "$SNAPCRAFT_PART_INSTALL/bin/ferretdb" \
|
||||
"https://github.com/FerretDB/FerretDB/releases/download/v${FERRETDB_VERSION}/ferretdb-linux-arm64"
|
||||
chmod +x "$SNAPCRAFT_PART_INSTALL/bin/ferretdb"
|
||||
# mongosh 2.8.2 arm64
|
||||
wget -q https://downloads.mongodb.com/compass/mongosh-2.8.2-linux-arm64.tgz
|
||||
tar xzf mongosh-2.8.2-linux-arm64.tgz --strip-components=1 -C $SNAPCRAFT_PART_INSTALL
|
||||
rm mongosh-2.8.2-linux-arm64.tgz
|
||||
# mongodb-database-tools 100.13.0 arm64
|
||||
wget -q https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2404-arm64-100.13.0.tgz
|
||||
tar xzf mongodb-database-tools-ubuntu2404-arm64-100.13.0.tgz --strip-components=1 -C $SNAPCRAFT_PART_INSTALL
|
||||
rm mongodb-database-tools-ubuntu2404-arm64-100.13.0.tgz
|
||||
;;
|
||||
|
||||
armhf|s390x|ppc64el)
|
||||
# No prebuilt FerretDB 2 binaries for these architectures.
|
||||
# Build from Go source using Go 1.24.13 (Ubuntu 24.04 ships Go 1.22, too old).
|
||||
GOVERSION="1.24.13"
|
||||
case "$SNAP_ARCH" in
|
||||
armhf) GO_DL_ARCH="armv6l" ;;
|
||||
s390x) GO_DL_ARCH="s390x" ;;
|
||||
ppc64el) GO_DL_ARCH="ppc64le" ;;
|
||||
esac
|
||||
wget -q "https://dl.google.com/go/go${GOVERSION}.linux-${GO_DL_ARCH}.tar.gz"
|
||||
tar xzf "go${GOVERSION}.linux-${GO_DL_ARCH}.tar.gz" -C /tmp
|
||||
rm "go${GOVERSION}.linux-${GO_DL_ARCH}.tar.gz"
|
||||
export PATH="/tmp/go/bin:$PATH"
|
||||
export GOPATH="$(mktemp -d)"
|
||||
CGO_ENABLED=0 GOBIN="$SNAPCRAFT_PART_INSTALL/bin" \
|
||||
go install "github.com/FerretDB/FerretDB/v2/cmd/ferretdb@v${FERRETDB_VERSION}"
|
||||
rm -rf "$GOPATH" /tmp/go
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Shared mongod wrapper (all platforms) ────────────────────────
|
||||
# mongodb-control execs bin/mongod; this wrapper starts PostgreSQL
|
||||
# and FerretDB so no changes are needed in the control script.
|
||||
cat > "$SNAPCRAFT_PART_INSTALL/bin/mongod" << 'WRAPPER'
|
||||
#!/bin/sh
|
||||
# FerretDB 2 + PostgreSQL 16 startup wrapper (all snap platforms).
|
||||
# Called by mongodb-control; replaces the MongoDB mongod binary.
|
||||
PG_DATA="${SNAP_COMMON}/postgresql"
|
||||
PG_LOG="${SNAP_COMMON}/postgresql.log"
|
||||
PG_BIN="$SNAP/usr/lib/postgresql/16/bin"
|
||||
|
||||
# Initialize PostgreSQL data directory on first run
|
||||
if [ ! -f "$PG_DATA/PG_VERSION" ]; then
|
||||
mkdir -p "$PG_DATA"
|
||||
"$PG_BIN/initdb" -D "$PG_DATA" --encoding=UTF8 --auth=trust
|
||||
printf 'listen_addresses = '"'"'127.0.0.1'"'"'\n' >> "$PG_DATA/postgresql.conf"
|
||||
printf 'unix_socket_directories = '"'"'%s'"'"'\n' "${SNAP_COMMON}" >> "$PG_DATA/postgresql.conf"
|
||||
fi
|
||||
|
||||
# Start PostgreSQL (wait for readiness before continuing)
|
||||
"$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" start -w
|
||||
|
||||
# Create FerretDB role and database (idempotent)
|
||||
"$PG_BIN/psql" -h 127.0.0.1 -U postgres \
|
||||
-c "CREATE ROLE ferretdb WITH LOGIN PASSWORD 'ferretdb';" 2>/dev/null || true
|
||||
"$PG_BIN/psql" -h 127.0.0.1 -U postgres \
|
||||
-c "CREATE DATABASE wekan OWNER ferretdb;" 2>/dev/null || true
|
||||
|
||||
# Start FerretDB on MongoDB wire protocol port with OpLog emulation
|
||||
exec "$SNAP/bin/ferretdb" \
|
||||
--listen-addr=127.0.0.1:27017 \
|
||||
--replication-set-name=rs0 \
|
||||
--postgresql-url="postgres://ferretdb:ferretdb@127.0.0.1:5432/wekan"
|
||||
WRAPPER
|
||||
chmod +x "$SNAPCRAFT_PART_INSTALL/bin/mongod"
|
||||
|
||||
# ── Build-time backend marker ────────────────────────────────────
|
||||
echo "ferretdb" > "$SNAPCRAFT_PART_INSTALL/DATABASE_BACKEND"
|
||||
stage:
|
||||
- bin
|
||||
- usr
|
||||
- DATABASE_BACKEND
|
||||
prime:
|
||||
- bin
|
||||
- usr
|
||||
|
||||
mongosh:
|
||||
source: https://downloads.mongodb.com/compass/mongosh-2.5.3-linux-x64.tgz
|
||||
plugin: dump
|
||||
|
||||
mongotools:
|
||||
source: https://fastdl.mongodb.org/tools/db/mongodb-database-tools-ubuntu2404-x86_64-100.12.2.tgz
|
||||
plugin: dump
|
||||
- DATABASE_BACKEND
|
||||
|
||||
|
||||
wekan:
|
||||
source: .
|
||||
plugin: npm
|
||||
npm-node-version: 22.22.0
|
||||
npm-node-version: 22.22.2
|
||||
npm-include-node: true
|
||||
build-packages:
|
||||
- build-essential
|
||||
@@ -164,34 +295,40 @@ parts:
|
||||
##node build.js
|
||||
##cd ../../../../../..
|
||||
# Cleanup
|
||||
# Read version from package.json and set snap version
|
||||
WEKAN_VERSION=$(python3 -c "import json; print(json.load(open('package.json'))['version'])")
|
||||
snapcraftctl set-version "${WEKAN_VERSION}"
|
||||
|
||||
# Detect target architecture and map to bundle/Node.js names
|
||||
SNAP_ARCH=$(dpkg --print-architecture)
|
||||
case "$SNAP_ARCH" in
|
||||
amd64) WEKAN_ARCH="amd64" NODE_ARCH="x64" ;;
|
||||
arm64) WEKAN_ARCH="arm64" NODE_ARCH="arm64" ;;
|
||||
armhf) WEKAN_ARCH="armhf" NODE_ARCH="armv7l" ;;
|
||||
ppc64el) WEKAN_ARCH="ppc64le" NODE_ARCH="ppc64le" ;;
|
||||
s390x) WEKAN_ARCH="s390x" NODE_ARCH="s390x" ;;
|
||||
*) echo "Unsupported snap architecture: $SNAP_ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Download and extract the pre-built WeKan bundle for this architecture
|
||||
mkdir .build
|
||||
cd .build
|
||||
wget https://github.com/wekan/wekan/releases/download/v${WEKAN_VERSION}/wekan-${WEKAN_VERSION}-amd64.zip
|
||||
unzip wekan-${WEKAN_VERSION}-amd64.zip
|
||||
rm wekan-${WEKAN_VERSION}-amd64.zip
|
||||
wget -q https://github.com/wekan/wekan/releases/download/v${WEKAN_VERSION}/wekan-${WEKAN_VERSION}-${WEKAN_ARCH}.zip
|
||||
unzip -q wekan-${WEKAN_VERSION}-${WEKAN_ARCH}.zip
|
||||
rm wekan-${WEKAN_VERSION}-${WEKAN_ARCH}.zip
|
||||
cd ..
|
||||
##cd .build/bundle
|
||||
##find . -type d -name '*-garbage*' | xargs rm -rf
|
||||
##find . -name '*phantom*' | xargs rm -rf
|
||||
##find . -name '.*.swp' | xargs rm -f
|
||||
##find . -name '*.swp' | xargs rm -f
|
||||
##cd ../..
|
||||
# Add fibers multi arch
|
||||
#cd .build/bundle/programs/server/node_modules/fibers/bin
|
||||
#curl https://releases.wekan.team/fibers-multi.7z -o fibers-multi.7z
|
||||
#7z x fibers-multi.7z
|
||||
#rm fibers-multi.7z
|
||||
#cd ../../../../../../..
|
||||
# Copy Node.js 22 to Snap (matches Meteor 3 bundle requirements)
|
||||
NODE_VERSION="22.22.0"
|
||||
wget https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz
|
||||
tar -xf node-v${NODE_VERSION}-linux-x64.tar.gz node-v${NODE_VERSION}-linux-x64/bin/node
|
||||
rm node-v${NODE_VERSION}-linux-x64.tar.gz
|
||||
|
||||
# Download the matching Node.js 22.22.2 binary for this architecture
|
||||
NODE_VERSION="22.22.2"
|
||||
wget -q https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz
|
||||
tar -xf node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz \
|
||||
node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node
|
||||
rm node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz
|
||||
mkdir -p $SNAPCRAFT_PART_INSTALL/bin
|
||||
cp -p node-v${NODE_VERSION}-linux-x64/bin/node $SNAPCRAFT_PART_INSTALL/bin/
|
||||
rm -rf node-v${NODE_VERSION}-linux-x64
|
||||
cp -p node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node $SNAPCRAFT_PART_INSTALL/bin/
|
||||
rm -rf node-v${NODE_VERSION}-linux-${NODE_ARCH}
|
||||
|
||||
# Install the WeKan bundle into the snap
|
||||
cp -r .build/bundle/* $SNAPCRAFT_PART_INSTALL/
|
||||
cp .build/bundle/.node_version.txt $SNAPCRAFT_PART_INSTALL/
|
||||
rm -f $SNAPCRAFT_PART_INSTALL/lib/node_modules/wekan
|
||||
@@ -220,6 +357,25 @@ parts:
|
||||
source: snap-src
|
||||
plugin: dump
|
||||
|
||||
# migration-tools: bundles releases/migrate-mongodb-to-ferretdb.mjs and its
|
||||
# only npm dependency (mongodb ^6) so mongodb-control can run it with the
|
||||
# snap's bundled Node.js without requiring internet access at runtime.
|
||||
migration-tools:
|
||||
source: releases
|
||||
plugin: nil
|
||||
build-packages:
|
||||
- nodejs
|
||||
- npm
|
||||
override-build: |
|
||||
mkdir -p "$SNAPCRAFT_PART_INSTALL/migration"
|
||||
cp migrate-mongodb-to-ferretdb.mjs package.json "$SNAPCRAFT_PART_INSTALL/migration/"
|
||||
cd "$SNAPCRAFT_PART_INSTALL/migration"
|
||||
npm install --omit=dev --prefer-offline 2>/dev/null || npm install --omit=dev
|
||||
stage:
|
||||
- migration
|
||||
prime:
|
||||
- migration
|
||||
|
||||
caddy:
|
||||
plugin: nil
|
||||
build-packages:
|
||||
|
||||
Reference in new issue
Block a user