Updated release scripts. Part 2.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-03-27 22:45:52 +02:00
1 parent f660817627
commit 07eb062474
15 files changed
+2523 -240

No files matched your search

+159 -37
View File
@@ -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
View File
@@ -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
+82
View File
@@ -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
View File
@@ -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:
+57
View File
@@ -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."
+55
View File
@@ -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"
+797
View File
@@ -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__ &nbsp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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);
});
+17
View File
@@ -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
View File
@@ -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 ""
+105
View File
@@ -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
+89
View File
@@ -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."
+169
View File
@@ -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);
+327
View File
@@ -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
View File
@@ -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
View File
@@ -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: