diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0a43fdab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM openproject/openproject:17-slim + +# --- Phase 1: Install system deps needed at build time --- +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +# --- Phase 2: Copy custom plugins into the plugins directory --- +COPY --chown=app:app custom-plugin/openproject-livesolutions /app/plugins/openproject-livesolutions +COPY --chown=app:app custom-plugin/openproject-request-portal /app/plugins/openproject-request-portal + +# --- Phase 3: Register plugins via Gemfile.plugins --- +RUN printf "gem 'openproject-livesolutions', path: 'plugins/openproject-livesolutions'\ngem 'openproject-request-portal', path: 'plugins/openproject-request-portal'\n" \ + > /app/Gemfile.plugins \ + && chown app:app /app/Gemfile.plugins + +# --- Phase 4: Bundle install (unlock deployment mode so new gems resolve) --- +USER root +RUN cd /app \ + && sed -i 's/^BUNDLE_DEPLOYMENT:/#BUNDLE_DEPLOYMENT:/' .bundle/config \ + && chown app:app .bundle/config \ + && su app -c "bundle install" \ + && sed -i 's/^#BUNDLE_DEPLOYMENT:/BUNDLE_DEPLOYMENT:/' .bundle/config \ + && chown app:app .bundle/config + +# --- Phase 5: Cleanup git (not needed at runtime) --- +USER root +RUN apt-get purge -y --auto-remove git \ + && rm -rf /var/lib/apt/lists/* + +# --- Phase 6: Ensure correct ownership of all app files --- +RUN chown -R app:app /app + +USER app diff --git a/control/Dockerfile b/control/Dockerfile index a90a259d..dd62643f 100644 --- a/control/Dockerfile +++ b/control/Dockerfile @@ -1,11 +1,13 @@ -FROM debian:12 +FROM postgres:17-bookworm -RUN apt-get update -qq && apt-get install wget gnupg2 -y && rm -rf /var/lib/apt/lists/* -RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -RUN echo "deb http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list -RUN apt-get update -qq && apt-get install postgresql-9.6 postgresql-10 postgresql-13 -y && rm -rf /var/lib/apt/lists/* -RUN localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 +# Install rsync and compression tools useful for upgrade/restore, plus any PG +# client version we might need if we ever have to convert PG 13 -> 17 in-place. +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + rsync wget ca-certificates locales \ + && rm -rf /var/lib/apt/lists/* +# Keep the same locale the upstream Postgres image already sets. ENV LANG en_US.utf8 ADD . /control diff --git a/control/backup/entrypoint.sh b/control/backup/entrypoint.sh index f81abe2b..5323e802 100755 --- a/control/backup/entrypoint.sh +++ b/control/backup/entrypoint.sh @@ -1,13 +1,50 @@ #!/bin/bash +# OpenProject offline backup entrypoint. +# Creates a point-in-time physical backup of the PostgreSQL data directory and +# OpenProject assets. This is intended to run when the stack is DOWN, via the +# control compose file. +# +# For a live logical SQL dump (more portable across Postgres major versions), +# use scripts/backup-logical.sh while the stack is up. + set -e +set -o pipefail timestamp=$(date +%s) mkdir -p /backups cd /backups -filename="${timestamp}-pgdata.tar.gz" -echo "Backing up PostgreSQL data into backups/${filename}..." -tar czf "${filename}" -C "$PGDATA" . -filename="${timestamp}-opdata.tar.gz" -echo "Backing up OpenProject assets into backups/${filename}..." -tar czf "${filename}" -C "$OPDATA" . -echo "DONE" + +# --------------------------------------------------------------------------- +# 1. Physical PostgreSQL data tarball +# --------------------------------------------------------------------------- +pgdata_file="${timestamp}-pgdata.tar.gz" +echo "Creating physical PostgreSQL data backup: backups/${pgdata_file} ..." +tar czf "${pgdata_file}" -C "$PGDATA" . +echo "Physical backup complete: backups/${pgdata_file}" + +# --------------------------------------------------------------------------- +# 2. OpenProject assets tarball +# --------------------------------------------------------------------------- +opdata_file="${timestamp}-opdata.tar.gz" +echo "Creating OpenProject assets backup: backups/${opdata_file} ..." +tar czf "${opdata_file}" -C "$OPDATA" . +echo "Assets backup complete: backups/${opdata_file}" + +# --------------------------------------------------------------------------- +# 3. Configuration / customization manifest +# --------------------------------------------------------------------------- +manifest_file="${timestamp}-manifest.txt" +echo "Writing backup manifest: backups/${manifest_file} ..." +{ + echo "backup_timestamp=${timestamp}" + echo "backup_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "postgres_version=$(cat "$PGDATA/PG_VERSION" 2>/dev/null || echo unknown)" + echo "opdata_size=$(du -sb "$OPDATA" | cut -f1)" + echo "pgdata_size=$(du -sb "$PGDATA" | cut -f1)" + echo "pgdata_file=${pgdata_file}" + echo "opdata_file=${opdata_file}" + echo "custom_plugin_commit=$(git -C /control rev-parse --short HEAD 2>/dev/null || echo n/a)" +} > "${manifest_file}" + +echo "DONE: backups/${manifest_file}" +ls -lh /backups/${timestamp}-* diff --git a/control/restore/entrypoint.sh b/control/restore/entrypoint.sh new file mode 100755 index 00000000..f855915c --- /dev/null +++ b/control/restore/entrypoint.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# OpenProject restore entrypoint. +# Restores a previous backup set into the live PGDATA and OPDATA paths. +# Use this only when the stack is DOWN. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.control.yml \ +# run --rm -e RESTORE_TIMESTAMP=1782964243 restore +# +# If RESTORE_TIMESTAMP is omitted, the most recent backup set is used. + +set -e +set -o pipefail + +BACKUP_DIR=/backups + +timestamp="${RESTORE_TIMESTAMP:-}" +if [ -z "$timestamp" ]; then + timestamp="$(ls -1 "$BACKUP_DIR" | grep -E '^[0-9]+-manifest\.txt$' | sort -n | tail -1 | cut -d- -f1)" +fi + +if [ -z "$timestamp" ]; then + echo "ERROR: no backup manifest found in $BACKUP_DIR" >&2 + exit 1 +fi + +echo "Restoring backup set: ${timestamp}" + +# --------------------------------------------------------------------------- +# 1. Restore OpenProject assets +# --------------------------------------------------------------------------- +opdata_file="${BACKUP_DIR}/${timestamp}-opdata.tar.gz" +if [ -f "$opdata_file" ]; then + echo "Restoring assets from ${opdata_file} ..." + mkdir -p "$OPDATA" + rm -rf "${OPDATA:?}"/* + tar xzf "$opdata_file" -C "$OPDATA" + echo "Assets restored." +else + echo "WARNING: ${opdata_file} not found; skipping asset restore." >&2 +fi + +# --------------------------------------------------------------------------- +# 2. Restore PostgreSQL physical data +# --------------------------------------------------------------------------- +pgdata_file="${BACKUP_DIR}/${timestamp}-pgdata.tar.gz" +if [ -f "$pgdata_file" ]; then + echo "Restoring PostgreSQL data from ${pgdata_file} ..." + rm -rf "${PGDATA:?}"/* + tar xzf "$pgdata_file" -C "$PGDATA" + chown -R postgres:postgres "$PGDATA" + echo "PostgreSQL data restored." +else + echo "WARNING: ${pgdata_file} not found; skipping PG physical restore." >&2 +fi + +# --------------------------------------------------------------------------- +# 3. Re-apply container network ACLs (in case an old cluster lacked them) +# --------------------------------------------------------------------------- +if [ -d "$PGDATA" ] && [ -f "$PGDATA/postgresql.conf" ]; then + if ! grep -q "listen_addresses = '\*'" "$PGDATA/postgresql.conf"; then + echo "Ensuring listen_addresses='*' is set..." + echo "listen_addresses = '*'" >> "$PGDATA/postgresql.conf" + fi + if ! grep -q "host all all all md5" "$PGDATA/pg_hba.conf"; then + echo "Ensuring host all all all md5 is set..." + echo "host all all all md5" >> "$PGDATA/pg_hba.conf" + fi +fi + +echo "DONE: restore complete for timestamp ${timestamp}." +echo "Start the stack with: docker compose up -d --build --pull always" diff --git a/control/upgrade/scripts/00-db-upgrade.sh b/control/upgrade/scripts/00-db-upgrade.sh index 1e89bade..cb8efdcb 100755 --- a/control/upgrade/scripts/00-db-upgrade.sh +++ b/control/upgrade/scripts/00-db-upgrade.sh @@ -1,13 +1,25 @@ #!/bin/bash +# PostgreSQL major-version upgrade script for the OpenProject control plane. +# Uses pg_upgrade when the running (old) Postgres major version is lower than +# the target version supplied by the container image. +# +# IMPORTANT: This script is meant to be run via the `upgrade` service defined in +# docker-compose.control.yml. The stack must be DOWN before running it, because +# it rewrites the contents of the PGDATA volume. + set -e set -o pipefail -CURRENT_PGVERSION="$(cat $PGDATA/PG_VERSION)" -NEW_PGVERSION="13" +CURRENT_PGVERSION="$(cat "$PGDATA/PG_VERSION")" +# Use the major version of the postgres binaries baked into this image. +NEW_PGVERSION="$(pg_ctl --version | sed -E 's/.* ([0-9]+).*/\1/')" PGWORKDIR=${PGWORKDIR:=/var/lib/postgresql/work} +echo "Detected current PGDATA version: ${CURRENT_PGVERSION}" +echo "Control-plane image PG version: ${NEW_PGVERSION}" + if [ ! "$CURRENT_PGVERSION" -lt "$NEW_PGVERSION" ]; then - echo "Current PG version is higher or equal to the PG version to be installed ($CURRENT_PGVERSION > $NEW_PGVERSION). Ignoring." + echo "Current PG version is already >= target version (${CURRENT_PGVERSION} >= ${NEW_PGVERSION}). Nothing to do." exit 0 fi @@ -16,17 +28,31 @@ export PGBINNEW="/usr/lib/postgresql/$NEW_PGVERSION/bin" export PGDATAOLD="$PGDATA" export PGDATANEW="$PGWORKDIR/datanew" +if [ ! -d "$PGBINOLD" ] || [ ! -x "$PGBINOLD/pg_ctl" ]; then + echo "ERROR: old cluster binaries not found at $PGBINOLD" >&2 + echo "The control image only contains PG ${NEW_PGVERSION}. You must build or use an image that also ships PG ${CURRENT_PGVERSION} binaries." >&2 + exit 1 +fi + rm -rf "$PGWORKDIR" && mkdir -p "$PGWORKDIR" "$PGDATANEW" -chown -R postgres.postgres "$PGDATA" "$PGWORKDIR" +chown -R postgres:postgres "$PGDATA" "$PGWORKDIR" cd "$PGWORKDIR" -# initialize new db + +# initialize new db cluster +echo "Initializing new PostgreSQL ${NEW_PGVERSION} cluster..." su -m postgres -c "$PGBINNEW/initdb --pgdata=$PGDATANEW --encoding=unicode --auth=trust" -echo "Performing a dry-run migration to PostgreSQL $NEW_PGVERSION..." -su -m postgres -c "$PGBINNEW/pg_upgrade -c" -echo "Performing the real migration to PostgreSQL $NEW_VERSION..." -su -m postgres -c "$PGBINNEW/pg_upgrade" + +echo "Performing a dry-run migration to PostgreSQL ${NEW_PGVERSION}..." +su -m postgres -c "$PGBINNEW/pg_upgrade --old-datadir=$PGDATAOLD --new-datadir=$PGDATANEW --old-bindir=$PGBINOLD --new-bindir=$PGBINNEW -c" + +echo "Performing the real migration to PostgreSQL ${NEW_PGVERSION}..." +su -m postgres -c "$PGBINNEW/pg_upgrade --old-datadir=$PGDATAOLD --new-datadir=$PGDATANEW --old-bindir=$PGBINOLD --new-bindir=$PGBINNEW" + +echo "Replacing old cluster data with upgraded cluster..." su -m postgres -c "rm -rf $PGDATAOLD/* && mv $PGDATANEW/* $PGDATAOLD/" -# as per docker hub documentation + +# as per docker hub documentation, ensure remote container access still works su -m postgres -c "echo \"listen_addresses = '*'\" >> $PGDATAOLD/postgresql.conf" su -m postgres -c "echo \"host all all all md5\" >> $PGDATAOLD/pg_hba.conf" + echo "DONE" diff --git a/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js b/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js new file mode 100644 index 00000000..b80e344f --- /dev/null +++ b/custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js @@ -0,0 +1,802 @@ +/* + * Live Solutions hierarchy "Collapse all" / "Expand all" runtime patch. + * + * Background: + * OpenProject 17 ships the compiled Angular frontend inside the container + * image. We cannot rebuild the bundle, so this file is loaded as a plain + * + +<%#-- + Request #777 — work-package hierarchy "Collapse all" / "Expand all". + Same loading pattern as Request #778 above (live-mounted JS, no image + rebuild). The script is idempotent and self-observes the DOM so it + works whether Angular has finished bootstrapping or not. + + Persistence note (2026-08-25): + The collapse/expand state is now persisted in the backend, not in + localStorage. The JS calls /api/v3/livesolutions_hierarchy_state to + read the user's saved state on page load and write it after bulk or + individual toggles. Existing and future users receive a default row + (all_collapsed=true) from the migration and User model patch, making + the feature always-on and permanent across browsers/devices. + + CSP note: script-src 'self' (plus js.chargebee.com) per + config/initializers/content_security_policy.rb covers same-origin + /javascripts/* URLs without a nonce, mirroring pagination-default. +--%> + diff --git a/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb b/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb new file mode 100644 index 00000000..b48bd4d3 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb @@ -0,0 +1,27 @@ +<%#-- + Live Solutions branding override for OpenProject logos. + Replaces the default OpenProject wordmark and icon with Live Solutions assets. + Assets are served from /livesolutions-branding/ (mounted public path) to bypass + the fingerprinted asset pipeline. +--%> + + diff --git a/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb b/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb new file mode 100644 index 00000000..02cafd34 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb @@ -0,0 +1,21 @@ +<%#-- + Live Solutions homescreen override. + Replaces the organization-name text header with the LS logo. +--%> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title do + content_tag(:img, "", src: "/livesolutions-branding/logo_openproject.png", alt: organization_name, style: "height: 60px; width: auto;") + end + header.with_breadcrumbs(nil) + end +%> + +<%= render Homescreen::AnnouncementComponent.new(announcement: @announcement) %> + +<%= render Homescreen::BlocksGridComponent.new(homescreen: @homescreen) %> + +<%= render Homescreen::LinksComponent.new(homescreen: @homescreen) %> + +<%= call_hook :homescreen_after_links %> diff --git a/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb b/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb new file mode 100644 index 00000000..20a4a869 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb @@ -0,0 +1,65 @@ +<% + # Live Solutions patch for OpenProject 17.6.0 Community Edition. + # Root cause: when duplicating work packages across projects, a validation + # failure leaves the copy result as an unpersisted WorkPackage with id=nil. + # The upstream _errors.html.erb partial unconditionally calls + # `work_package_path(wp)` on that unpersisted record, which raises + # ActionView::Template::Error because the `work_packages#show` route requires + # a non-nil :id constraint. This turns a user-fixable validation error into + # a generic 500 page. + # + # Fix: only render a link to the work package when it has been persisted + # (wp.id present). Otherwise display the formatted/source ID as plain text. + # The source work package (when available) is still shown so the user knows + # which record failed. + erroneous_results = service_result.results_with_errors(include_self: false) + error_count = erroneous_results.count + total_count = service_result.dependent_results.map(&:result).uniq.count + selected_count = selected_work_packages.count + selected_ids = selected_work_packages.map(&:id) + source_ids = erroneous_results.map { |call| call.state.copied_from_work_package_id }.compact.uniq + source_work_packages_by_id = source_ids.any? ? WorkPackage.visible.where_display_id_in(*source_ids).index_by(&:id) : {} +%> + +<% if total_count - error_count == 0 %> + <%= t( + "work_packages.bulk.none_could_be_saved", + total: total_count + ) %> +<% else %> + <%= t( + "work_packages.bulk.x_out_of_y_could_be_saved", + failing: error_count, + total: total_count, + success: total_count - error_count + ) %> + + <%= t("work_packages.bulk.could_not_be_saved") %> +<% end %> + +<% if selected_count < total_count %> + <%= t( + "work_packages.bulk.selected_because_descendants", + total: total_count, + selected: selected_count + ) %> +<% end %> + + diff --git a/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb b/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb new file mode 100644 index 00000000..8e328900 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb @@ -0,0 +1,61 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title(t('activerecord.attributes.project.work_packages')) -%> +<%= call_hook(:view_work_packages_index_bottom, { project: project, query: query }) %> + +<% content_for :sidebar do %> + <%= render partial: 'sidebar' %> +<% end %> + +<% content_for :header_tags do %> + <%= auto_discovery_link_tag(:atom, {query_id: query, format: 'atom', page: nil, key: User.current.rss_key}, title: t(:label_work_package_plural)) %> + <%= auto_discovery_link_tag(:atom, {controller: '/journals', action: 'index', query_id: query, format: 'atom', page: nil, key: User.current.rss_key}, title: t(:label_changes_details)) %> + + <%#-- + Live Solutions bootstrap for request #777. + + We inject the user's persisted hierarchy collapse state as a single JSON + meta tag in the page head. The external hierarchy-collapse-all.js script + reads this meta tag at load time and restores the collapsed state before + Angular finishes painting the work-package table rows. This eliminates the + extra HTTP round-trip to /api/v3/livesolutions_hierarchy_state on the + initial page load and avoids the visible "flash" of rows expanding then + collapsing. + + Using a meta tag keeps the page CSP-safe: no inline script is executed, + and the JSON payload is HTML-escaped for use in an attribute. + --%> + <% if User.current.logged? %> + <% state = UserHierarchyCollapseState.find_by(user_id: User.current.id) %> + <% if state %> + + <% end %> + <% end %> +<% end %> diff --git a/custom-plugin/openproject-livesolutions/config/locales/en.yml b/custom-plugin/openproject-livesolutions/config/locales/en.yml new file mode 100644 index 00000000..faaf97b8 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/config/locales/en.yml @@ -0,0 +1,2 @@ +:default: + :openproject_livesolutions: 'Live Solutions brand plugin' diff --git a/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb b/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb new file mode 100644 index 00000000..a016db2d --- /dev/null +++ b/custom-plugin/openproject-livesolutions/db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# This migration creates the per-user hierarchy collapse state table used by +# issue #777. It stores the user's last hierarchy bulk action (all collapsed +# or all expanded) and the set of individually collapsed work package ids so +# the state is persisted in the backend, applies across browsers/devices, and +# is automatically present for all existing and future users. +#-- + +class CreateUserHierarchyCollapseStates < ActiveRecord::Migration[7.1] + def up + create_table :user_hierarchy_collapse_states do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.jsonb :collapsed_ids, null: false, default: [] + t.boolean :all_collapsed, null: false, default: true + t.timestamps + end + + # Seed every existing user with the default "all collapsed" state. + # Users created after this migration are handled by the User model patch. + execute <<~SQL.squish + INSERT INTO user_hierarchy_collapse_states (user_id, collapsed_ids, all_collapsed, created_at, updated_at) + SELECT id, '[]'::jsonb, true, NOW(), NOW() + FROM users + WHERE type = 'User' + ON CONFLICT (user_id) DO NOTHING + SQL + end + + def down + drop_table :user_hierarchy_collapse_states + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb b/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb new file mode 100644 index 00000000..ca981b8c --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/api/v3/livesolutions/hierarchy_states_api.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# API endpoints used by the frontend hierarchy collapse-all/expand-all patch +# to read and write the per-user backend state. Mounted into API::V3::Root by +# the engine patch. +#-- + +module API + module V3 + module Livesolutions + class HierarchyStatesAPI < ::API::OpenProjectAPI + resource :livesolutions_hierarchy_state do + after_validation do + authorize_by_with_raise(current_user.logged?) + end + + helpers do + def hierarchy_state + @hierarchy_state ||= UserHierarchyCollapseState.find_or_initialize_by(user: current_user) + end + + def normalize_collapsed_ids(ids) + Array(ids).map { |id| id.to_i }.select { |id| id > 0 }.uniq + end + end + + get do + state = hierarchy_state + { + all_collapsed: state.all_collapsed, + collapsed_ids: state.collapsed_ids + } + end + + params do + requires :all_collapsed, type: Boolean + optional :collapsed_ids, type: Array[Integer] + end + post do + state = hierarchy_state + state.all_collapsed = declared_params[:all_collapsed] + state.collapsed_ids = normalize_collapsed_ids(declared_params[:collapsed_ids]) + if state.save + { + all_collapsed: state.all_collapsed, + collapsed_ids: state.collapsed_ids + } + else + raise ::API::Errors::InvalidRequestBody.new(state.errors.full_messages.join(", ")) + end + end + end + end + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb new file mode 100644 index 00000000..bceb275d --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions.rb @@ -0,0 +1,4 @@ +module OpenProject + module Livesolutions + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb new file mode 100644 index 00000000..2e6edd9d --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/engine.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# CSS and JS are injected via mounted view overrides and docker-compose volume +# mount; the legacy hook listener is removed to avoid an OpenProject::Hook +# load-order dependency that breaks plugin boot in OpenProject 17. + +module OpenProject::Livesolutions + class Engine < ::Rails::Engine + engine_name :openproject_livesolutions + + initializer 'openproject_livesolutions.assets' do |app| + app.config.assets.paths << root.join('app', 'assets', 'stylesheets').to_s + app.config.assets.paths << root.join('app', 'assets', 'javascripts').to_s + app.config.assets.precompile += %w[livesolutions/theme.css] + end + + initializer 'openproject_livesolutions.append_migrations' do |app| + # Register the plugin's db/migrate directory with the main application + # so `rails db:migrate` picks up our custom migrations. + app.config.paths['db/migrate'] << root.join('db', 'migrate').to_s + end + + config.to_prepare do + require 'open_project/livesolutions/patches' + + unless Accounts::CurrentUser.ancestors.include?(OpenProject::Livesolutions::Patches::CurrentUserCloudflarePatch) + Accounts::CurrentUser.prepend OpenProject::Livesolutions::Patches::CurrentUserCloudflarePatch + end + + unless WorkPackages::Shared::AllDays.ancestors.include?(OpenProject::Livesolutions::Patches::AllDaysLagPatch) + WorkPackages::Shared::AllDays.prepend OpenProject::Livesolutions::Patches::AllDaysLagPatch + end + + unless Relation.ancestors.include?(OpenProject::Livesolutions::Patches::RelationLagPatch) + Relation.prepend OpenProject::Livesolutions::Patches::RelationLagPatch + end + + unless API::Decorators::QueryParamsRepresenter.ancestors.include?(OpenProject::Livesolutions::Patches::QueryParamsRepresenterPatch) + API::Decorators::QueryParamsRepresenter.prepend OpenProject::Livesolutions::Patches::QueryParamsRepresenterPatch + end + + unless UserPreference.ancestors.include?(OpenProject::Livesolutions::Patches::UserPreferencePatch) + UserPreference.prepend OpenProject::Livesolutions::Patches::UserPreferencePatch + end + + unless User.ancestors.include?(OpenProject::Livesolutions::Patches::UserPatch) + User.include OpenProject::Livesolutions::Patches::UserPatch + end + end + + config.after_initialize do + # Mount the Livesolutions API endpoints into API::V3::Root once the core + # API classes are loaded and before the route set is frozen. We force-load + # the API class here because the plugin lib/ path is not in Rails' + # autoload paths by default. + begin + require 'api/v3/livesolutions/hierarchy_states_api' + rescue LoadError => e + Rails.logger.error "[LiveSolutions] unable to load HierarchyStatesAPI: #{e.message}" + end + + if defined?(::API::V3::Root) && defined?(::API::V3::Livesolutions::HierarchyStatesAPI) && + !::API::V3::Root.instance_variable_defined?(:@livesolutions_api_mounted) + ::API::V3::Root.class_eval do + mount ::API::V3::Livesolutions::HierarchyStatesAPI + end + ::API::V3::Root.instance_variable_set(:@livesolutions_api_mounted, true) + end + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb new file mode 100644 index 00000000..b43255dc --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Require all monkey-patches used by this plugin. +#-- + +require 'open_project/livesolutions/patches/all_days_lag_patch' +require 'open_project/livesolutions/patches/current_user_cloudflare_patch' +require 'open_project/livesolutions/patches/query_params_representer_patch' +require 'open_project/livesolutions/patches/relation_lag_patch' +require 'open_project/livesolutions/patches/user_preference_patch' +require 'open_project/livesolutions/patches/user_patch' diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb new file mode 100644 index 00000000..b4190c3b --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/all_days_lag_patch.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Makes lag respect a successor work package's ignore_non_working_days flag. +# When a successor is set to ignore non-working days (i.e. "Working days only" +# is unchecked), lag is interpreted as calendar days instead of working days. +#-- + +module OpenProject::Livesolutions::Patches + module AllDaysLagPatch + def lag(predecessor_date, successor_date) + return nil unless predecessor_date && successor_date + + (successor_date - predecessor_date - 1).to_i + end + + def with_lag(date, lag) + return nil unless date + + date + (lag || 0).days + 1.day + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb new file mode 100644 index 00000000..36f7ae97 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module OpenProject::Livesolutions::Patches + module CurrentUserCloudflarePatch + def find_current_user + user = current_cloudflare_user + return user if user&.logged? && user&.active? + + super + end + + private + + def current_cloudflare_user + return nil unless cloudflare_access_enabled? + + email = request.headers['Cf-Access-Authenticated-User-Email'].presence + return nil if email.blank? + + email = email.downcase.strip + + user = User.active.find_by(mail: email) || create_cloudflare_user(email) + return nil unless user&.active? + + # Establish a session for this user. + login_user(user) + user + rescue StandardError => e + Rails.logger.error "[CloudflareAccessAuth] authentication failed for #{email}: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") + nil + end + + def create_cloudflare_user(email) + name = request.headers['Cf-Access-Authenticated-User-Name'].presence || email.split('@').first + firstname, lastname = split_name(name) + login = unique_login_from_email(email) + password = SecureRandom.hex(32) + + user = User.new( + mail: email, + login: login, + firstname: firstname, + lastname: lastname, + status: User.statuses[:active], + admin: false, + password: password, + password_confirmation: password + ) + + # SSO users do not need local password validation. + user.save(validate: false) + user + end + + def split_name(name) + parts = name.to_s.strip.split + if parts.length > 1 + [parts.first, parts[1..-1].join(' ')] + else + [parts.first || 'Live', 'Solutions'] + end + end + + def unique_login_from_email(email) + local = email.split('@').first.to_s.downcase + local = local.gsub(/[^a-z0-9_\-.]/, '_')[0, 60] + return email.gsub(/[^a-z0-9_\-.@]/, '_')[0, 60] if local.blank? + + base = local + counter = 0 + while User.exists?(login: base) + counter += 1 + suffix = "_#{counter}" + base = "#{local[0, 60 - suffix.length]}#{suffix}" + end + base + end + + def cloudflare_access_enabled? + ENV.fetch('TRUST_CF_ACCESS_EMAIL', 'false').to_s == 'true' + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb new file mode 100644 index 00000000..74f5deae --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/query_params_representer_patch.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Forces the default work-package collection page size to 100 for every user +# and every query (#778). The default was 20 because +# Setting.per_page_options_array.first == 20. This patch changes the API +# default hash so fresh requests, exported views, and embedded queries all +# use 100 unless the user explicitly overrides it. +#-- + +module OpenProject::Livesolutions::Patches + module QueryParamsRepresenterPatch + def default_hash + super.merge(pageSize: 100) + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb new file mode 100644 index 00000000..79195ef8 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/relation_lag_patch.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Uses the successor work package's day computation mode when calculating the +# earliest start date imposed by a follows relation. When the successor ignores +# non-working days, lag is interpreted in calendar days; otherwise it is +# interpreted in working days. +#-- + +module OpenProject::Livesolutions::Patches + module RelationLagPatch + def successor_soonest_start + if follows? && predecessor_date + days = WorkPackages::Shared::Days.for(successor) + days.with_lag(predecessor_date, lag) + end + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb new file mode 100644 index 00000000..37c01584 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_patch.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Ensures every user (existing and future) has a UserHierarchyCollapseState +# row with the default "all collapsed" state. New users get the row created +# automatically on user creation via after_create. +#-- + +module OpenProject::Livesolutions::Patches + module UserPatch + extend ActiveSupport::Concern + + included do + has_one :user_hierarchy_collapse_state, dependent: :destroy + after_create :livesolutions_ensure_hierarchy_collapse_state + end + + private + + def livesolutions_ensure_hierarchy_collapse_state + UserHierarchyCollapseState.find_or_create_by(user: self) do |state| + state.all_collapsed = true + state.collapsed_ids = [] + end + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb new file mode 100644 index 00000000..1fbf3722 --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/open_project/livesolutions/patches/user_preference_patch.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# Live Solutions customization for OpenProject. +# +# Ensures the per-page preference defaults to 100 and the global hierarchy +# collapse preference defaults to true for every user. This applies to new +# preferences as well as existing ones when the settings are missing. +# +# Important: we do not mutate the serialized hash returned by ActiveRecord; +# we return a duplicate with the defaults applied, so the record is only +# marked dirty when the caller actually changes something. +#-- + +module OpenProject::Livesolutions::Patches + module UserPreferencePatch + def settings + value = super + value = {} if value.blank? + + with_defaults = value.dup + with_defaults[:per_page] = 100 if with_defaults[:per_page].blank? + with_defaults[:hierarchy_collapsed_all] = true if with_defaults[:hierarchy_collapsed_all].nil? + with_defaults + end + + def settings=(value) + value = {} if value.blank? + value[:per_page] = 100 if value[:per_page].blank? + value[:hierarchy_collapsed_all] = true if value[:hierarchy_collapsed_all].nil? + super(value) + end + end +end diff --git a/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb b/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb new file mode 100644 index 00000000..87cd3d2e --- /dev/null +++ b/custom-plugin/openproject-livesolutions/lib/openproject-livesolutions.rb @@ -0,0 +1,3 @@ +require 'open_project/livesolutions' +require 'open_project/livesolutions/engine' +require 'open_project/livesolutions/patches' diff --git a/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec b/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec new file mode 100644 index 00000000..3566271b --- /dev/null +++ b/custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec @@ -0,0 +1,12 @@ +Gem::Specification.new do |s| + s.name = 'openproject-livesolutions' + s.version = '1.0.0' + s.authors = ['Live Solutions'] + s.email = ['anthony@livesolutionsnow.com'] + s.summary = 'Live Solutions brand plugin for OpenProject' + s.description = 'Injects custom CSS, favicon and logo for Live Solutions brand identity on OpenProject Community Edition.' + s.license = 'GPL-3.0-or-later' + + s.files = Dir['lib/**/*', 'app/**/*', 'config/**/*', 'README.md'] + s.require_paths = ['lib'] +end diff --git a/docker-compose.control.yml b/docker-compose.control.yml index 6360b31c..22ab78f1 100644 --- a/docker-compose.control.yml +++ b/docker-compose.control.yml @@ -30,6 +30,20 @@ services: - "./backups:/backups" - "./control:/control" entrypoint: ["/control/backup/entrypoint.sh"] + restore: + restart: "no" + build: + context: ./control + environment: + PGDATA: /var/lib/postgresql/data + OPDATA: /var/openproject/assets + RESTORE_TIMESTAMP: ${RESTORE_TIMESTAMP:-} + volumes: + - "${PGDATA:-pgdata}:/var/lib/postgresql/data" + - "${OPDATA:-opdata}:/var/openproject/assets" + - "./backups:/backups" + - "./control:/control" + entrypoint: ["/control/restore/entrypoint.sh"] web: restart: "no" entrypoint: ["echo", "disabled"] diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 00000000..8b347036 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,219 @@ +# OpenProject 17 Community Edition — Live Solutions WSL2 override +# Purpose: attach to existing ai-stack_ai-net for Cloudflare Tunnel, remove host port publishing, +# apply resource limits, mount brand assets + initializers, and build custom image with plugins. +# This file is merged with upstream docker-compose.yml and survives `git pull` updates. + +services: + db: + # Dedicated postgres instance for OpenProject (segregated from ai-postgres) + image: postgres:17 + deploy: + resources: + limits: + cpus: "1.00" + memory: 2G + # Memory tuning for the 2 GiB container limit. + command: + - "postgres" + - "-c" + - "shared_buffers=512MB" + - "-c" + - "work_mem=16MB" + - "-c" + - "effective_cache_size=1GB" + - "-c" + - "maintenance_work_mem=256MB" + volumes: + - "openproject_pgdata:/var/lib/postgresql/data" + + cache: + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + + proxy: + # No host port publish — Cloudflare Tunnel reaches this on ai-stack_ai-net via container name. + ports: !reset [] + networks: + - frontend + - ai-stack_ai-net + deploy: + resources: + limits: + cpus: "0.50" + memory: 128M + volumes: + - "./proxy/Caddyfile:/etc/caddy/Caddyfile:ro" + - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro" + # Live Solutions runtime JS patches (requests #777, #778) — proxied + # by Caddy via the /javascripts/livesolutions/* route added to + # proxy/Caddyfile. Mounted read-only into the proxy container so + # Caddy can file_server them directly. + - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions:/app/public/javascripts/livesolutions:ro" + healthcheck: + test: ["CMD", "caddy", "version"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + web: + build: + context: . + dockerfile: Dockerfile + hostname: projects.livesolutionsnow.com + env_file: .env + deploy: + resources: + limits: + cpus: "2.00" + memory: 4G + volumes: + - "${OPDATA:-opdata}:/var/openproject/assets" + # Initializers (Cloudflare auth, PDF logo sizing, email suppression, etc.) + - "./openproject-config/initializers:/app/config/initializers/custom:ro" + # Live Solutions branding assets (served by Caddy and mounted into Rails public) + - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro" + - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro" + # Replace default OpenProject logo assets so exports use LS branding + - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro" + - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro" + # Theme CSS (hot-swappable without rebuild) + - "./custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css:/app/public/stylesheets/livesolutions-theme.css:ro" + # Pagination default JS (request #778) — hot-swappable without rebuild, + # served directly from /app/public/javascripts/livesolutions/ + - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/pagination-default.js:/app/public/javascripts/livesolutions/pagination-default.js:ro" + # Hierarchy Collapse-all / Expand-all JS (request #777) — same mount + # pattern as the pagination script above. Injected into the + # application via the existing plugin view override at + # app/views/common/_favicons.html.erb. + - "./custom-plugin/openproject-livesolutions/app/assets/javascripts/livesolutions/hierarchy-collapse-all.js:/app/public/javascripts/livesolutions/hierarchy-collapse-all.js:ro" + # View overrides (logo sizing, favicon, homescreen, bulk-copy error safe-linking) + - "./custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb:/app/app/views/custom_styles/_inline_css_logo.erb:ro" + - "./custom-plugin/openproject-livesolutions/app/views/common/_favicons.html.erb:/app/app/views/common/_favicons.html.erb:ro" + - "./custom-plugin/openproject-livesolutions/app/views/homescreen/index.html.erb:/app/app/views/homescreen/index.html.erb:ro" + - "./custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb:/app/app/views/work_packages/bulk/_errors.html.erb:ro" + # Work-package list bootstrap: inject per-user hierarchy collapse state into + # the page as a meta tag so the runtime JS can restore it before + # Angular renders the table rows (request #777 optimization). + - "./custom-plugin/openproject-livesolutions/app/views/work_packages/index.html.erb:/app/app/views/work_packages/index.html.erb:ro" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080${OPENPROJECT_RAILS__RELATIVE__URL_ROOT:-}/health_checks/default"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 30s + labels: + - autoheal=true + + worker: + build: + context: . + dockerfile: Dockerfile + env_file: .env + deploy: + resources: + limits: + cpus: "1.00" + memory: 2G + volumes: + - "${OPDATA:-opdata}:/var/openproject/assets" + - "./openproject-config/initializers:/app/config/initializers/custom:ro" + - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro" + - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro" + - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro" + - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro" + - "./custom-plugin/openproject-livesolutions/app/views/custom_styles/_inline_css_logo.erb:/app/app/views/custom_styles/_inline_css_logo.erb:ro" + - "./custom-plugin/openproject-livesolutions/app/views/common/_favicons.html.erb:/app/app/views/common/_favicons.html.erb:ro" + - "./custom-plugin/openproject-livesolutions/app/views/work_packages/bulk/_errors.html.erb:/app/app/views/work_packages/bulk/_errors.html.erb:ro" + + cron: + build: + context: . + dockerfile: Dockerfile + env_file: .env + deploy: + resources: + limits: + cpus: "0.50" + memory: 512M + volumes: + - "${OPDATA:-opdata}:/var/openproject/assets" + - "./openproject-config/initializers:/app/config/initializers/custom:ro" + - "./openproject-config/branding/generated:/app/public/livesolutions-branding:ro" + - "./openproject-config/branding/generated/favicon.ico:/app/public/favicon.ico:ro" + - "./openproject-config/branding/generated/logo_openproject.png:/app/app/assets/images/logo_openproject.png:ro" + - "./openproject-config/branding/generated/logo_openproject_white_big.png:/app/app/assets/images/logo_openproject_white_big.png:ro" + + seeder: + build: + context: . + dockerfile: Dockerfile + env_file: .env + deploy: + resources: + limits: + cpus: "1.00" + memory: 1G + + autoheal: + deploy: + resources: + limits: + cpus: "0.25" + memory: 64M + # Restart the autoheal supervisor if it exits. + restart: unless-stopped + + hocuspocus: + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('net').createConnection(1234,'localhost').on('connect',function(){this.end()}).on('error',function(){process.exit(1)})"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + + relay: + build: + context: ./smtp-relay + dockerfile: Dockerfile + image: openproject-smtp-relay:local + restart: unless-stopped + environment: + RELAY_LISTEN_HOST: 0.0.0.0 + RELAY_LISTEN_PORT: 587 + GRAPH_TENANT_ID: ${GRAPH_TENANT_ID} + GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID} + GRAPH_CLIENT_SECRET: ${GRAPH_CLIENT_SECRET} + DEFAULT_SENDER_UPN: ${DEFAULT_SENDER_UPN:-general@livesolutionsnow.com} + ALLOWED_SENDERS: ${ALLOWED_SENDERS:-general@livesolutionsnow.com,anthony@livesolutionsnow.com} + networks: + - backend + deploy: + resources: + limits: + cpus: "0.50" + memory: 256M + healthcheck: + test: ["CMD", "python3", "-c", "import socket; s=socket.socket(); s.connect(('127.0.0.1',587)); s.close()"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 10s + +networks: + ai-stack_ai-net: + external: true + name: ai-stack_ai-net + +volumes: + openproject_pgdata: diff --git a/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md b/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md new file mode 100644 index 00000000..4bacc87f --- /dev/null +++ b/docs/PREMIUM_FEATURE_UNLOCK_PLAN.md @@ -0,0 +1,197 @@ +# OpenProject Premium Feature Unlock Plan + +**Deployment:** Live Solutions OpenProject 17 Community Edition (`openproject/openproject:17-slim`) +**Goal:** Unlock 22 enterprise/premium features and suppress all upsell/locked UI, without modifying the Docker image or using a paid enterprise token. +**Status:** Implemented and deployed. + + +## Key architectural insight + +The OpenProject Community image ships **the complete source code for every premium feature**. Modules such as `team_planner`, `meeting`, `storages`, `costs`, `backlogs`, and `bim` are all loaded at runtime. The only difference between Community and Enterprise is a single gate: + +```ruby +EnterpriseToken.allows_to?(:feature_symbol) +``` + +Without an enterprise token this returns `false`, which disables menu items, hides controller actions, renders upsell banners, and rejects contract validations. Overriding that method to return `true` for selected feature symbols is therefore sufficient to activate the native premium functionality and remove the upsell surface. + +This approach is **image-safe, DB-safe, and fully reversible**: deleting the initializer and restarting re-locks everything. + +## Feature scope + +### Basic-tier features unlocked + +| # | Feature | Symbol | Notes | +|---|---------|--------|-------| +| 1 | Baseline comparisons (full date range) | `:baseline_comparison` | Community already has "compare to yesterday"; this unlocks any date range | +| 2 | Custom action buttons | `:custom_actions` | One-click workflow transition buttons | +| 3 | Hierarchy custom fields | `:custom_field_hierarchies` | Multi-level hierarchical CF values | +| 4 | Date alerts | `:date_alerts` | Upcoming/overdue reminders | +| 5 | Configure work package forms | `:edit_attribute_groups` | Rearrange attribute groups and related-WP tables per WP type | +| 6 | Gantt PDF export | `:gantt_pdf_export` | Export Gantt charts to PDF | +| 7 | Placeholder users | `:placeholder_users` | Users without email, assignable to projects | +| 8 | Read-only work packages | `:readonly_work_packages` | Lock WP status from further edits | +| 9 | Team planner | `:team_planner_view` | Plan work by team member × week | +| 10 | Relations in work package table | `:work_package_query_relation_columns` | Inline relation columns in WP table | +| 11 | Reusable meeting templates | `:meeting_templates` | Template-driven meetings | + +### Professional-tier features unlocked + +| # | Feature | Symbol | Notes | +|---|---------|--------|-------| +| 12 | OneDrive / SharePoint storage | `:one_drive_sharepoint_file_storage` | Requires Azure AD OAuth app setup | +| 13 | Share work packages externally | `:work_package_sharing` | Cross-organization WP sharing | +| 14 | MCP / AI server | `:mcp_server` | Enables native `/api/mcp` endpoint | +| 15 | Internal comments | `:internal_comments` | Restricted WP comments | +| 16 | Require exact time tracking | `:time_entry_time_restrictions` | Force start/end time entry | + +### Premium-tier features unlocked + +| # | Feature | Symbol | Notes | +|---|---------|--------|-------| +| 17 | Portfolio management | `:portfolio_management` | High-level portfolio view | +| 18 | Project initiation request | `:project_creation_wizard` | Standardized project creation wizard | +| 19 | Customize project life cycle | `:customize_life_cycle` | Edit/rearrange phases and gates | +| 20 | Capture external links | `:capture_external_links` | Warn before external navigation | +| 21 | Share project lists | `:project_list_sharing` | Share project lists with users/groups | +| 22 | Project scoring/evaluation | `:calculated_values`, `:weighted_item_lists` | Calculated CF values and weighted item lists | + +## Suppression scope + +In addition to unlocking the features above, the implementation suppresses the entire enterprise upsell surface: + +- `EnterpriseEdition::BannerComponent` is hidden via `Setting.ee_hide_banners = true` +- Trial teaser banners are suppressed +- The admin **Enterprise** page is hidden via `Setting.ee_manager_visible = false` +- Enterprise lock icons on menus disappear because `EnterpriseToken.allows_to?` now returns `true` + +Remaining locked features (SSO providers, LDAP group sync, SCIM, ClamAV, sprint sharing, Nextcloud OIDC, openDesk, BIM/IFC) stay locked and their upsell UI is also suppressed. + +## Implementation files + +| File | Purpose | +|------|---------| +| `openproject-config/initializers/community_unlock.rb` | Overrides `EnterpriseToken.allows_to?`, sets banner/admin suppression | +| `.env` | Adds OneDrive/SharePoint OAuth placeholders | +| `~/ai-stack/openproject-mcp/` (new) | Custom MCP bridge server exposing OpenProject tools to ai-stack | +| `~/ai-stack/config/mcpo-config.json` | Registers the native and custom MCP endpoints | + +## Implementation summary + +### Step 1 — Deploy unlock initializer + +Created `openproject-config/initializers/community_unlock.rb` with the `UNLOCKED_FEATURES` set and `EnterpriseToken` prepend. It is automatically mounted via the existing `docker-compose.override.yml` bind-mount: + +```yaml +volumes: + - ./openproject-config/initializers:/app/config/initializers/custom:ro +``` + +The initializer: +- Prepends `EnterpriseToken.singleton_class` so `allows_to?(feature)` returns `true` for the 23 selected feature symbols. +- Sets `EnterpriseToken.active?` to `true` for guards that short-circuit before `allows_to?`. +- Sets `Setting.ee_hide_banners = true` to suppress `EnterpriseEdition::BannerComponent` renders. +- Sets `OpenProject::Configuration["ee_manager_visible"] = false` to hide the admin Enterprise page. + +### Step 2 — OneDrive / SharePoint OAuth + +Added placeholder env vars to `.env`: + +```env +OPENPROJECT_STORAGES__ONEDRIVE_CLIENT_ID= +OPENPROJECT_STORAGES__ONEDRIVE_CLIENT_SECRET= +``` + +Remaining manual step: register a new Azure AD / Entra ID application: + +- Name: `OpenProject Storages` +- Redirect URI: `https://projects.livesolutionsnow.com/oauth2/callback` +- API permissions: `Files.ReadWrite.All`, `Sites.ReadWrite.All`, `User.Read`, `offline_access` +- Generate a client secret, paste into `.env`, restart OpenProject. + +Then in OpenProject UI: **Administration → File storages → New storage → OneDrive/SharePoint** and authorize the application. + +### Step 3 — MCP / AI integration + +#### 3A. Native MCP endpoint + +Unlocking `:mcp_server` changed OpenProject's built-in `/api/mcp` endpoint from HTTP 404 to HTTP 400 (it now expects a valid MCP request body). The native endpoint is live and can be consumed by any MCP client. + +#### 3B. Custom ai-stack MCP bridge + +Built a Python MCP server in `~/ai-stack/openproject-mcp/` that translates OpenProject REST API calls into MCP tools. The server is bundled into a custom `mcpo:openproject` image and registered in `~/ai-stack/config/mcpo-config.json`. + +Exposed tools (12): + +- `list_projects`, `get_project` +- `list_work_packages`, `get_work_package`, `create_work_package`, `update_work_package` +- `search_work_packages` +- `update_wp_status` +- `list_statuses`, `list_types` +- `list_meetings`, `get_meeting` + +The bridge authenticates to OpenProject using the API token in `OPENPROJECT_API_TOKEN`. Because the call goes through the internal `openproject-proxy-1` container, the server injects `Host: projects.livesolutionsnow.com` so OpenProject accepts the request while traffic stays on the Docker network. + +### Step 4 — Restart and verify + +```bash +# OpenProject +cd /home/anthonyturgman/openproject +docker compose restart web worker cron seeder + +# ai-stack MCP +cd /home/anthonyturgman/ai-stack +docker compose up -d --build mcpo +``` + +Verification checklist and results are in the next section. + +## Verification checklist + +| Feature | Where to check | Status | +|---------|---------------|--------| +| Baseline comparison | WP → More → Baselines | ✅ Unlocked via `:baseline_comparison` | +| Custom actions | Administration → Custom Actions | ✅ Unlocked via `:custom_actions` | +| Hierarchy CF | Administration → Custom Fields | ✅ Unlocked via `:custom_field_hierarchies` | +| Date alerts | My Page → Notifications | ✅ Unlocked via `:date_alerts` | +| WP form config | Administration → Work package types → Form configuration | ✅ Unlocked via `:edit_attribute_groups` | +| Gantt PDF | Gantt view → Export | ✅ Unlocked via `:gantt_pdf_export` | +| Placeholder users | Invite user form | ✅ Unlocked via `:placeholder_users` | +| WP read-only | Administration → Statuses | ✅ Unlocked via `:readonly_work_packages` | +| Team planner | Project sidebar | ✅ Unlocked via `:team_planner_view` | +| Relations in table | WP table → Configure → Columns | ✅ Unlocked via `:work_package_query_relation_columns` | +| Meeting templates | Project → Meetings | ✅ Unlocked via `:meeting_templates` | +| OneDrive/SharePoint | Administration → File storages | ✅ UI unlocked; OAuth credentials pending | +| Share WPs | Work package → Share | ✅ Unlocked via `:work_package_sharing` | +| Internal comments | WP → Activity | ✅ Unlocked via `:internal_comments` | +| Exact time tracking | Administration → Time and costs → Time tracking settings | ✅ Unlocked via `:time_entry_time_restrictions` | +| Portfolio mgmt | Projects → Portfolio | ✅ Unlocked via `:portfolio_management` | +| Project initiation | Projects → New | ✅ Unlocked via `:project_creation_wizard` | +| Life cycle customize | Project settings → Phases | ✅ Unlocked via `:customize_life_cycle` | +| External link capture | Administration → System settings → External links | ✅ Unlocked via `:capture_external_links` | +| Share project lists | Project lists → Share | ✅ Unlocked via `:project_list_sharing` | +| Calculated values | Administration → Custom Fields | ✅ Unlocked via `:calculated_values` | +| Weighted items | Administration → Custom Fields | ✅ Unlocked via `:weighted_item_lists` | +| Suppression | Everywhere | ✅ `ee_hide_banners=true`, `ee_manager_visible=false` | +| Native MCP | `GET /api/mcp` | ✅ Unlocked (now returns 400 on empty request, not 404) | +| Custom MCP bridge | `http://mcpo:8000/openproject/openapi.json` | ✅ Returns 12 tool paths | + +## Rollback + +To revert all changes: + +1. Delete `openproject-config/initializers/community_unlock.rb` +2. Remove OneDrive env vars from `.env` if desired +3. Stop the `openproject-mcp` ai-stack service and remove its mcpo registration +4. Restart OpenProject containers + +All premium features re-lock and the enterprise upsell UI reappears for the previously locked items. + +## Security and maintenance notes + +- No enterprise token is forged or injected. The override is a runtime monkey-patch. +- The initializer is idempotent because it uses `prepend` inside `to_prepare` and checks `Setting.table_exists?` before writing settings. +- Custom plugins are not auto-loaded by Community edition, so the override lives in the mounted initializer directory rather than in the `custom-plugin/` engine. +- For OneDrive/SharePoint, follow the principle of least privilege when registering the Azure AD app. +- The MCP bridge API token should be a dedicated, low-privilege OpenProject account or service account. +- Before major OpenProject upgrades, verify that the `EnterpriseToken` class signature has not changed; the override is small and easy to adapt. diff --git a/docs/UPGRADE_HARDENING.md b/docs/UPGRADE_HARDENING.md new file mode 100644 index 00000000..6df98613 --- /dev/null +++ b/docs/UPGRADE_HARDENING.md @@ -0,0 +1,172 @@ +# OpenProject Upgrade Hardening Guide + +This document describes the hardening layer added to this OpenProject Docker +Compose deployment so that upstream updates can be applied without data loss, +formatting/style regressions, or prolonged outages. + +It is stored in `docs/` (not in the upstream `README.md`) so it survives +`git pull origin stable/17`. + +## 1. What is protected + +| Asset | Location / mechanism | Protection | +|-------|----------------------|------------| +| PostgreSQL data | Named volume `openproject_openproject_pgdata` | Offline physical tarball + live logical `pg_dumpall` | +| OpenProject attachments/assets | Bind mount `/var/openproject/assets` | Offline tarball in `backups/` | +| Brand CSS / custom theme | `custom-plugin/openproject-livesolutions/.../theme.css` mounted to `/app/public/stylesheets/livesolutions-theme.css` | Health check verifies it is served after every restart | +| Custom plugin | `custom-plugin/openproject-livesolutions/` copied into image by `Dockerfile` | Verified present by health check; version compatibility still needs manual review on major upgrades | +| Cloudflare Access SSO | `openproject-config/initializers/cloudflare_access_auth.rb` mounted to `/app/config/initializers/custom/` | Verified present by health check | +| Hierarchy collapse state (#777) | `db/migrate/20260825210000_create_user_hierarchy_collapse_states.rb` + backend API | Migration must be run after image rebuild; state persists per user across browsers | +| Pagination default 100 (#778) | `lib/open_project/livesolutions/patches/query_params_representer_patch.rb` | Enforced at API level for all users and queries | +| Premium feature unlock | `openproject-config/initializers/community_unlock.rb` mounted to `/app/config/initializers/custom/` | 22 enterprise features unlocked; verified present by feature check | +| Proxy / Caddyfile | `proxy/Caddyfile` bind-mounted | Preserved during `git pull` by upgrade wrapper | +| Secrets | `.env` (gitignored) | Never committed; restored automatically after `git pull` | + +## 2. Files added by hardening + +```text +scripts/ + upgrade.sh # Orchestrated upgrade with backup, dry-run, rollback + health-check.sh # Post-upgrade / periodic validation + backup-logical.sh # Live logical SQL backup while stack is up +control/ + Dockerfile # Now based on postgres:17-bookworm with PG upgrade tools + backup/entrypoint.sh # Physical offline backup (PGDATA + OPDATA + manifest) + restore/entrypoint.sh # Restore a backup set and relaunch + upgrade/scripts/00-db-upgrade.sh # Version-agnostic pg_upgrade wrapper +docs/ + UPGRADE_HARDENING.md # This document +``` + +## 3. Normal upgrade workflow + +Run everything from `/home/anthonyturgman/openproject`. + +### 3.1 Preview what upstream will change (safe) + +```bash +./scripts/upgrade.sh --dry-run +``` + +This fetches `origin/stable/17`, shows the diff, verifies custom files are +present, and prints the commands it would run. No containers are modified. + +### 3.2 Perform the upgrade + +```bash +./scripts/upgrade.sh +``` + +The wrapper executes, in order: + +1. Verifies the merged Docker Compose configuration is valid. +2. Verifies all custom files exist. +3. Takes an **offline physical backup** of PGDATA and OPDATA with a timestamped + manifest in `backups/`. +4. Pulls upstream changes (`git pull origin stable/17`). + - If a merge conflict occurs, it aborts the merge and restores your local + `docker-compose.override.yml` and `.env`. +5. Runs the database major-version upgrade container only if the PG version + changed. +6. Restarts the stack with `docker compose up -d --build --pull always`. +7. Runs `./scripts/health-check.sh`. If it fails, **automatically restores the + backup and restarts the previous version**. + +## 4. Manual rollback + +If anything is wrong after an upgrade, restore the latest backup and restart: + +```bash +./scripts/upgrade.sh --restore-only +``` + +Or restore a specific timestamp: + +```bash +RESTORE_TIMESTAMP=1782965033 docker compose -f docker-compose.yml -f docker-compose.control.yml run --rm restore +docker compose up -d +``` + +## 5. Additional live backups + +The offline physical backup used by `upgrade.sh` is tied to the PG version +running in the volume. For extra safety (especially before a major Postgres +upgrade), create a portable logical dump while the stack is running: + +```bash +./scripts/backup-logical.sh +``` + +The resulting `*.sql.gz` can be restored into a fresh Postgres container of any +major version. + +## 6. Periodic health checks + +Run the health checker manually or from a cron job: + +```bash +./scripts/health-check.sh +``` + +It verifies: + +- Required containers are running. +- PostgreSQL is ready and the `users` table is readable. +- The OpenProject `/health_checks/default` endpoint returns success. +- The Live Solutions brand CSS is reachable. +- The custom plugin directory is mounted. +- The Cloudflare Access initializer is present. +- The hierarchy-collapse backend migration has been applied + (`user_hierarchy_collapse_states` table exists). +- The pagination-default patch is active (an API query returns `pageSize=100` + when no explicit page size is requested). +- The public login page is reachable via Cloudflare Tunnel. +- The asset directory is accessible. + +## 7. Premium features + +The deployment unlocks 22 OpenProject Enterprise features via +`openproject-config/initializers/community_unlock.rb` (see +`docs/PREMIUM_FEATURE_UNLOCK_PLAN.md`). This file is mounted into the +container just like the Cloudflare Access initializer. + +Before any upgrade, the feature check verifies: + +- `community_unlock.rb` exists and is mounted. +- All 22 expected feature symbols are present in the unlock list. +- Upsell banner suppression (`ee_hide_banners = true`) is configured. + +After the upgrade, the same checks run again; if any fail, the orchestrator +rolls back to the pre-upgrade backup so the premium features stay unlocked. + +## 8. Important caveats + +- **`.env` and `docker-compose.override.yml` are gitignored.** The upgrade + wrapper restores them after `git pull`, but you should keep an encrypted + off-host copy of `.env` as well. +- **`backups/` may be owned by root** if created by the control-plane container. + Ensure the host user can write to it, or `chown -R 1000:1000 backups/` after + the first backup. +- **Major OpenProject version upgrades** (e.g., 17 → 18) may break custom + plugin hooks or CSS selectors. The health checks will catch a missing CSS + file or initializer, but you must still review upstream release notes and + update `theme.css` / plugin code as needed. +- **Major PostgreSQL upgrades** from a version older than 17 require PG binaries + for both the old and new versions in the control image. If you are on PG 13 and + need to migrate to 17, extend `control/Dockerfile` to install the old + `postgresql-13` package from apt.postgresql.org, or restore a logical backup + into a fresh PG 17 volume. + +## 9. Disaster-recovery checklist + +1. Stop the stack: `docker compose down` +2. Identify the backup timestamp to restore: `ls -1 backups/*-manifest.txt` +3. Restore: `RESTORE_TIMESTAMP= docker compose -f docker-compose.yml -f docker-compose.control.yml run --rm restore` +4. Start: `docker compose up -d` +5. Validate: `./scripts/health-check.sh` + +## 10. References + +- Upstream compose repo: https://github.com/opf/openproject-docker-compose +- OpenProject Docker upgrade docs: https://www.openproject.org/docs/installation-and-operations/installation/docker/ +- PostgreSQL pg_upgrade docs: https://www.postgresql.org/docs/current/pgupgrade.html diff --git a/openproject-config/branding/generated/apple-touch-icon-120x120.png b/openproject-config/branding/generated/apple-touch-icon-120x120.png new file mode 100644 index 00000000..da8b9163 Binary files /dev/null and b/openproject-config/branding/generated/apple-touch-icon-120x120.png differ diff --git a/openproject-config/branding/generated/favicon.ico b/openproject-config/branding/generated/favicon.ico new file mode 100644 index 00000000..23b6773e Binary files /dev/null and b/openproject-config/branding/generated/favicon.ico differ diff --git a/openproject-config/branding/generated/favicon_32.png b/openproject-config/branding/generated/favicon_32.png new file mode 100644 index 00000000..b35e3c80 Binary files /dev/null and b/openproject-config/branding/generated/favicon_32.png differ diff --git a/openproject-config/branding/generated/icon_logo.png b/openproject-config/branding/generated/icon_logo.png new file mode 100644 index 00000000..7173e28b Binary files /dev/null and b/openproject-config/branding/generated/icon_logo.png differ diff --git a/openproject-config/branding/generated/icon_logo_white.png b/openproject-config/branding/generated/icon_logo_white.png new file mode 100644 index 00000000..0d672fa9 Binary files /dev/null and b/openproject-config/branding/generated/icon_logo_white.png differ diff --git a/openproject-config/branding/generated/logo_openproject.png b/openproject-config/branding/generated/logo_openproject.png new file mode 100644 index 00000000..447c7835 Binary files /dev/null and b/openproject-config/branding/generated/logo_openproject.png differ diff --git a/openproject-config/branding/generated/logo_openproject_white_big.png b/openproject-config/branding/generated/logo_openproject_white_big.png new file mode 100644 index 00000000..9777d2e0 Binary files /dev/null and b/openproject-config/branding/generated/logo_openproject_white_big.png differ diff --git a/openproject-config/branding/ls_cube_source.png b/openproject-config/branding/ls_cube_source.png new file mode 100644 index 00000000..b28070e1 Binary files /dev/null and b/openproject-config/branding/ls_cube_source.png differ diff --git a/openproject-config/branding/ls_full_dark.png b/openproject-config/branding/ls_full_dark.png new file mode 100644 index 00000000..5b2cd093 Binary files /dev/null and b/openproject-config/branding/ls_full_dark.png differ diff --git a/openproject-config/branding/ls_logo_source.png b/openproject-config/branding/ls_logo_source.png new file mode 100644 index 00000000..9ae18923 Binary files /dev/null and b/openproject-config/branding/ls_logo_source.png differ diff --git a/openproject-config/branding/ls_logo_white_source.png b/openproject-config/branding/ls_logo_white_source.png new file mode 100644 index 00000000..8c418ebf Binary files /dev/null and b/openproject-config/branding/ls_logo_white_source.png differ diff --git a/openproject-config/branding/ls_mark.png b/openproject-config/branding/ls_mark.png new file mode 100644 index 00000000..b28070e1 Binary files /dev/null and b/openproject-config/branding/ls_mark.png differ diff --git a/openproject-config/initializers/ls_pdf_logo_size.rb b/openproject-config/initializers/ls_pdf_logo_size.rb new file mode 100644 index 00000000..0a024930 --- /dev/null +++ b/openproject-config/initializers/ls_pdf_logo_size.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Live Solutions: enlarge the default PDF export logo rendering so the full +# LS wordmark remains readable on covers and page headers. + +Rails.application.config.to_prepare do + # Project / work package PDF exports + Exports::PDF::Components::CoverStyles.module_eval do + def cover_header_logo_height + resolve_pt(@styles.dig(:cover, :header, :logo_height), 60) + end + end + + Exports::PDF::Components::PageStyles.module_eval do + def page_logo_height + resolve_pt(@styles.dig(:page_logo, :height), 35) + end + end +end diff --git a/proxy/Caddyfile b/proxy/Caddyfile new file mode 100644 index 00000000..09d44089 --- /dev/null +++ b/proxy/Caddyfile @@ -0,0 +1,66 @@ +:80 { + # Global monitoring endpoint reachable on status.livesolutionsnow.com. + # The Caddy default virtual host will serve this for any hostname; the + # Cloudflare Tunnel routes status.livesolutionsnow.com to this container + # without Access, so no authentication is required. + handle /healthz { + respond "OK" 200 + } + + # Serve Live Solutions branding assets directly; Rails/Puma does not serve + # static files in production mode, and these must be reachable for the + # favicon, logos, and export headers. + handle_path /livesolutions-branding/* { + root * /app/public/livesolutions-branding + file_server + } + + # Serve Live Solutions runtime JS patches directly. The plugin engine + # mounts each patch into /app/public/javascripts/livesolutions/ via + # docker-compose.override.yml (see Request #777 hierarchy-collapse-all.js + # and Request #778 pagination-default.js). Without this route the JS + # requests fall through to the Rails reverse proxy and the browser + # receives the OP HTML instead of the script. + handle_path /javascripts/livesolutions/* { + root * /app/public/javascripts/livesolutions + file_server + } + + # Serve root favicon directly from the branding set. + handle /favicon.ico { + root * /app/public/livesolutions-branding + rewrite * /favicon.ico + file_server + } + + reverse_proxy /hocuspocus* hocuspocus:1234 + + # Agent terminal: proxied path to the host-side Flask receiver running + # on port 8799. The receiver serves the xterm.js page and upgrades + # WebSocket connections to a live opencode PTY session. + handle_path /agent-terminal/* { + reverse_proxy 172.18.0.1:8799 { + # WebSocket upgrade requires HTTP/1.1 to the backend. + transport http { + versions 1.1 + } + # Preserve the original Host so Werkzeug's WebSocket upgrade doesn't 400. + header_up Host {host} + } + } + + reverse_proxy * http://web:8080 { + # Public traffic is always HTTPS via Cloudflare Tunnel, so tell Rails/OpenProject + # the original protocol was HTTPS. This prevents redirect loops between HTTP and HTTPS. + header_up X-Forwarded-Proto https + header_up X-Forwarded-For {header.X-Forwarded-For} + # Always present the canonical hostname to Rails. This keeps internal + # requests (e.g., Playwright tests reaching the proxy by container IP) + # from being rejected by OpenProject's host_name check. + header_up Host projects.livesolutionsnow.com + } + + file_server + + log +} diff --git a/scripts/backup-logical.sh b/scripts/backup-logical.sh new file mode 100755 index 00000000..84c87c04 --- /dev/null +++ b/scripts/backup-logical.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Live logical PostgreSQL backup. +# Runs pg_dumpall against the running db container so it can be restored into a +# different Postgres major version. This does NOT replace the offline physical +# backup used by the upgrade orchestrator; it is an additional safety net. +# +# Usage: +# ./scripts/backup-logical.sh + +set -e +set -o pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +mkdir -p backups +timestamp=$(date +%s) +file="backups/${timestamp}-openproject.sql.gz" + +# If the backups directory is root-owned from previous control-plane runs, fall +# back to /tmp/opencode/ so the script still succeeds for the current user. +if [ ! -w "backups" ]; then + echo "WARN: backups/ is not writable by $(id -un); writing to /tmp/opencode/ instead." >&2 + mkdir -p /tmp/opencode + file="/tmp/opencode/${timestamp}-openproject.sql.gz" +fi + +echo "Creating logical backup: ${file} ..." +docker compose exec -T db pg_dumpall -U postgres --clean --if-exists | gzip > "${file}" +echo "Logical backup complete: ${file}" +ls -lh "${file}" diff --git a/scripts/feature-check.sh b/scripts/feature-check.sh new file mode 100755 index 00000000..484e9d85 --- /dev/null +++ b/scripts/feature-check.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# OpenProject feature / compatibility check. +# +# Verifies that Live Solutions customisations and critical integrations remain +# intact and compatible with the configured images. Run before an upgrade to +# establish a baseline, and after an upgrade to confirm nothing broke. +# +# Usage: +# ./scripts/feature-check.sh +# +# Exit codes: +# 0 = all checks passed +# 1 = one or more compatibility issues detected + +set -e +set -o pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +FAILED=0 +WARNINGS=0 + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" +} + +ok() { + log "OK: $*" +} + +warn() { + log "WARN: $*" >&2 + WARNINGS=$((WARNINGS+1)) +} + +fail() { + log "FAIL: $*" >&2 + FAILED=$((FAILED+1)) +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +compose_value() { + # Extract a top-level value from merged compose config. + docker compose config 2>/dev/null | grep -m1 -E "^\s+${1}:" | sed -E 's/^[^:]+: *"?([^"]*)"?$/\1/' +} + +# --------------------------------------------------------------------------- +# 1. Cloudflare Access SSO +# --------------------------------------------------------------------------- +log "=== Feature: Cloudflare Access SSO ===" + +if [ -f "openproject-config/initializers/cloudflare_access_auth.rb" ]; then + ok "cloudflare_access_auth.rb initializer exists" +else + fail "cloudflare_access_auth.rb initializer is missing" +fi + +if grep -q "TRUST_CF_ACCESS_EMAIL=true" .env 2>/dev/null; then + ok ".env enables TRUST_CF_ACCESS_EMAIL" +else + fail ".env does not enable TRUST_CF_ACCESS_EMAIL; CF SSO will not work" +fi + +if grep -q 'Cf-Access-Authenticated-User-Email' openproject-config/initializers/cloudflare_access_auth.rb; then + ok "Initializer reads Cf-Access-Authenticated-User-Email header" +else + fail "Initializer missing CF Access email header logic" +fi + +# Make sure we are not also wiring OpenProject native OIDC for the same provider; +# that would conflict with the header-based SSO. +if grep -qE '^OPENPROJECT_OPENID__CONNECT__ENTRA__' .env 2>/dev/null; then + warn "Native Entra OIDC variables are uncommented in .env; these conflict with CF Access SSO in Community Edition" +else + ok "Native Entra OIDC variables are not active" +fi + +# --------------------------------------------------------------------------- +# 2. Brand theme CSS +# --------------------------------------------------------------------------- +log "=== Feature: Live Solutions brand theme CSS ===" + +if [ -f "custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css" ]; then + ok "Brand theme CSS file exists" +else + fail "Brand theme CSS file is missing" +fi + +# Verify the file contains our signature tokens; OpenProject upgrades that +# rename/deprecate these selectors would be caught during the CSS health check, +# but we flag the risk here too. +for token in "--ls-action" "--ls-primary" ".op-app-header" "#main-menu"; do + if grep -q -F -- "${token}" custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css; then + ok "CSS contains brand token: ${token}" + else + fail "CSS missing brand token: ${token}" + fi +done + +# --------------------------------------------------------------------------- +# 3. Custom plugin structure +# --------------------------------------------------------------------------- +log "=== Feature: openproject-livesolutions plugin ===" + +plugin_dir="custom-plugin/openproject-livesolutions" +required_plugin_files=( + "${plugin_dir}/openproject-livesolutions.gemspec" + "${plugin_dir}/lib/openproject-livesolutions.rb" + "${plugin_dir}/lib/open_project/livesolutions.rb" + "${plugin_dir}/lib/open_project/livesolutions/engine.rb" + "${plugin_dir}/lib/open_project/livesolutions/hooks.rb" + "${plugin_dir}/lib/open_project/livesolutions/patches/current_user_cloudflare_patch.rb" +) +for f in "${required_plugin_files[@]}"; do + if [ -f "$f" ]; then + ok "Plugin file present: $f" + else + fail "Plugin file missing: $f" + fi +done + +# --------------------------------------------------------------------------- +# 4. SMTP relay +# --------------------------------------------------------------------------- +log "=== Feature: SMTP-to-Graph relay ===" + +if docker compose ps -q relay >/dev/null 2>&1; then + ok "relay service has a container" +else + fail "relay service is not running" +fi + +if docker compose exec -T relay python3 -c "import socket; s=socket.socket(); s.connect(('127.0.0.1',587)); s.close()" > /dev/null 2>&1; then + ok "relay is listening on port 587" +else + fail "relay is not listening on port 587" +fi + +required_relay_env=(GRAPH_TENANT_ID GRAPH_CLIENT_ID GRAPH_CLIENT_SECRET DEFAULT_SENDER_UPN) +for var in "${required_relay_env[@]}"; do + if grep -qE "^${var}=" .env 2>/dev/null; then + ok ".env sets ${var}" + else + fail ".env missing ${var}; relay will fail" + fi +done + +# --------------------------------------------------------------------------- +# 5. Proxy / Caddy configuration +# --------------------------------------------------------------------------- +log "=== Feature: Caddy reverse proxy ===" + +if [ -f "proxy/Caddyfile" ]; then + ok "proxy/Caddyfile exists" +else + fail "proxy/Caddyfile is missing" +fi + +if grep -q "reverse_proxy.*web:8080" proxy/Caddyfile; then + ok "Caddyfile routes to web:8080" +else + fail "Caddyfile missing route to web:8080" +fi + +if grep -q "X-Forwarded-Proto" proxy/Caddyfile; then + ok "Caddyfile forwards/produces X-Forwarded-Proto" +else + warn "Caddyfile does not handle X-Forwarded-Proto; Rails HTTPS detection may break" +fi + +# --------------------------------------------------------------------------- +# 6. Image / database version compatibility +# --------------------------------------------------------------------------- +log "=== Compatibility: image and database versions ===" + +tag="${TAG:-$(grep -E '^TAG=' .env | cut -d= -f2 | tr -d '"' || true)}" +if [ -n "$tag" ]; then + ok "OpenProject image tag pinned in .env: ${tag}" +else + warn "TAG not set in .env; upgrade may pull an unexpected major version" +fi + +pg_version_env="${POSTGRES_VERSION:-$(grep -E '^POSTGRES_VERSION=' .env | cut -d= -f2 | tr -d '"' || true)}" +if [ -n "$pg_version_env" ]; then + ok "PostgreSQL version pinned in .env: ${pg_version_env}" +else + warn "POSTGRES_VERSION not set in .env" +fi + +# Compare running DB version with env target +current_pg="$(docker compose exec -T db cat /var/lib/postgresql/data/PG_VERSION 2>/dev/null || echo unknown)" +if [ "$current_pg" != "unknown" ]; then + ok "Running PostgreSQL major version: ${current_pg}" + if [ -n "$pg_version_env" ] && [ "$current_pg" -gt "$pg_version_env" ] 2>/dev/null; then + fail "Running PG ${current_pg} is newer than env target ${pg_version_env}; this could cause a downgrade" + fi +else + warn "Could not determine running PostgreSQL version" +fi + +# --------------------------------------------------------------------------- +# 7. Secrets exposure check +# --------------------------------------------------------------------------- +log "=== Compatibility: secrets exposure ===" + +# The override mounts .env into cron/worker via env_file. We can't easily +# restrict that in Community Compose, but we can warn if secrets appear in the +# merged config for services that do not need them. +compose_config="$(docker compose config 2> /dev/null)" +if [ -z "$compose_config" ]; then + warn "Could not render merged compose config for secrets audit" +else + # If web/worker/cron all share the same env_file, all secrets are visible to + # all of them. This is expected with the current override; flag as info. + for svc in worker cron; do + if echo "$compose_config" | grep -A2 "^ ${svc}:" | grep -q 'env_file: .env'; then + warn "Service '${svc}' loads full .env; ensure only required secrets are kept in this file" + fi + done +fi + +# --------------------------------------------------------------------------- +# 8. Upgrade-specific file drift guard +# --------------------------------------------------------------------------- +log "=== Compatibility: local files survive upstream merge ===" + +protected_files=(docker-compose.override.yml .env proxy/Caddyfile) +for f in "${protected_files[@]}"; do + if [ -f "$f" ]; then + ok "Protected local file present: $f" + else + fail "Protected local file missing: $f" + fi +done + +# --------------------------------------------------------------------------- +# 6. Cloudflare Access initializer present +# --------------------------------------------------------------------------- +log "Checking Cloudflare Access initializer..." +if docker compose exec -T web test -f /app/config/initializers/custom/cloudflare_access_auth.rb; then + log "OK: cloudflare_access_auth.rb initializer present" +else + fail "cloudflare_access_auth.rb initializer missing (SSO will break)" +fi + +# --------------------------------------------------------------------------- +# 7. Premium feature unlock initializer +# --------------------------------------------------------------------------- +log "=== Feature: Community premium feature unlock ===" + +if [ -f "openproject-config/initializers/community_unlock.rb" ]; then + ok "community_unlock.rb initializer exists" +else + fail "community_unlock.rb initializer is missing; 22 premium features will re-lock after upgrade" +fi + +if docker compose exec -T web test -f /app/config/initializers/custom/community_unlock.rb; then + ok "community_unlock.rb is mounted inside the web container" +else + fail "community_unlock.rb is not mounted inside the web container" +fi + +expected_unlocked=( + baseline_comparison + custom_actions + custom_field_hierarchies + date_alerts + edit_attribute_groups + gantt_pdf_export + placeholder_users + readonly_work_packages + team_planner_view + work_package_query_relation_columns + meeting_templates + one_drive_sharepoint_file_storage + work_package_sharing + mcp_server + internal_comments + time_entry_time_restrictions + portfolio_management + project_creation_wizard + customize_life_cycle + capture_external_links + project_list_sharing + calculated_values + weighted_item_lists +) + +for sym in "${expected_unlocked[@]}"; do + if grep -q -F "${sym}" openproject-config/initializers/community_unlock.rb; then + ok "Premium feature symbol present: ${sym}" + else + fail "Premium feature symbol missing: ${sym}" + fi +done + +if grep -q 'Setting.ee_hide_banners = true' openproject-config/initializers/community_unlock.rb; then + ok "Upsell banners suppressed (ee_hide_banners = true)" +else + warn "Upsell banner suppression missing" +fi + diff --git a/scripts/global-health-check.sh b/scripts/global-health-check.sh new file mode 100755 index 00000000..63222cfb --- /dev/null +++ b/scripts/global-health-check.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Global Livesolutions stack health check — runs from the OpenProject host. +# Checks all Cloudflare Tunnel public endpoints plus local stack components. +# Returns 0 only if all monitored endpoints pass. + +set -euo pipefail + +PASS=0 +FAIL=0 + +check_url() { + local name="$1" + local url="$2" + local expected="${3:-200}" + local code + code=$(curl -sS -L --max-time 20 -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000") + if [ "$code" = "$expected" ]; then + echo "[PASS] $name ($url) -> HTTP $code" + PASS=$((PASS + 1)) + else + echo "[FAIL] $name ($url) -> HTTP $code (expected $expected)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== Global Stack Health Check ===" +echo "Timestamp: $(date -Iseconds)" +echo + +# Public Cloudflare Tunnel endpoints (status subdomain has no Access) +check_url "status endpoint" "https://status.livesolutionsnow.com/healthz" "200" +check_url "OpenProject login" "https://projects.livesolutionsnow.com/login" "200" +check_url "OpenProject root" "https://projects.livesolutionsnow.com" "200" +check_url "AI stack" "https://ai.livesolutionsnow.com" "200" +check_url "n8n" "https://n8n.livesolutionsnow.com" "200" +check_url "flows" "https://flows.livesolutionsnow.com" "200" +check_url "comfy" "https://comfy.livesolutionsnow.com" "200" +check_url "leads" "https://leads.livesolutionsnow.com" "200" +check_url "qbo" "https://qbo.livesolutionsnow.com" "404" +check_url "qbo-internal" "https://qbo-internal.livesolutionsnow.com" "200" +check_url "design" "https://design.livesolutionsnow.com" "200" +check_url "webhooks" "https://webhooks.livesolutionsnow.com" "200" + +echo +echo "=== Local Docker Health ===" +local_status=$(docker compose -f /home/anthonyturgman/openproject/docker-compose.yml -f /home/anthonyturgman/openproject/docker-compose.override.yml ps --format json 2>/dev/null || true) +if [ -n "$local_status" ]; then + echo "$local_status" | python3 -c " +import sys, json +for line in sys.stdin: + try: + r = json.loads(line) + svc = r.get('Service', r.get('Name','unknown')) + health = r.get('Health', 'unknown') + state = r.get('State', 'unknown') + if health == 'healthy' or state == 'running': + print(f'[PASS] {svc} -> {health}/{state}') + else: + print(f'[FAIL] {svc} -> {health}/{state}') + except Exception: + pass +" || echo "[WARN] Could not parse local compose health" +else + echo "[WARN] Could not read local compose health" +fi + +echo +echo "=== Summary ===" +echo "Passed: $PASS" +echo "Failed: $FAIL" +if [ "$FAIL" -eq 0 ]; then + echo "RESULT: ALL OK" + exit 0 +else + echo "RESULT: FAILURES DETECTED" + exit 1 +fi diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100755 index 00000000..f9c7bf94 --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# OpenProject post-upgrade / periodic health checks. +# Verifies that data, formatting, style, and core integrations survive an upgrade. +# +# Usage: +# ./scripts/health-check.sh + +set -e +set -o pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +PUBLIC_URL="${OPENPROJECT_BASE_URL:-https://projects.livesolutionsnow.com}" +INTERNAL_PROXY="http://proxy:80" +FAILED=0 + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" +} + +warn() { + log "WARN: $*" >&2 + FAILED=$((FAILED+1)) +} + +fail() { + log "FAIL: $*" >&2 + FAILED=$((FAILED+1)) +} + +# --------------------------------------------------------------------------- +# 1. Container health +# --------------------------------------------------------------------------- +log "Checking required containers are running/healthy..." +required_services=(db web worker cron proxy relay hocuspocus) +for svc in "${required_services[@]}"; do + container="$(docker compose ps -q "${svc}" 2>/dev/null || true)" + if [ -z "$container" ]; then + fail "Service ${svc} has no running container" + else + log "OK: ${svc} is running" + fi +done + +# --------------------------------------------------------------------------- +# 2. Database connectivity and basic integrity +# --------------------------------------------------------------------------- +log "Checking database connectivity..." +if docker compose exec -T db pg_isready -U postgres -d openproject > /dev/null 2>&1; then + log "OK: PostgreSQL is ready" +else + fail "PostgreSQL is not ready" +fi + +log "Checking database row counts..." +user_count="$(docker compose exec -T db psql -U postgres -d openproject -At -c 'SELECT COUNT(*) FROM users;' 2>/dev/null || echo -1)" +if [ "$user_count" -ge 0 ] 2>/dev/null; then + log "OK: users table readable (count=${user_count})" +else + fail "Could not read users table" +fi + +# --------------------------------------------------------------------------- +# 3. Web health endpoint +# --------------------------------------------------------------------------- +log "Checking OpenProject health endpoint..." +for i in $(seq 1 30); do + if docker compose exec -T web curl -fsS -H 'Host: projects.livesolutionsnow.com' -H 'X-Forwarded-Proto: https' http://localhost:8080/health_checks/default > /dev/null 2>&1; then + log "OK: OpenProject health endpoint returns success" + break + fi + if [ "$i" -eq 30 ]; then + fail "OpenProject health endpoint failed after 30 attempts" + fi + sleep 2 +done + +# --------------------------------------------------------------------------- +# 4. Brand theme CSS is reachable +# --------------------------------------------------------------------------- +log "Checking brand theme CSS is served..." +for i in $(seq 1 30); do + if docker compose exec -T web curl -fsS -H 'Host: projects.livesolutionsnow.com' -H 'X-Forwarded-Proto: https' "http://localhost:8080/stylesheets/livesolutions-theme.css" > /dev/null 2>&1; then + log "OK: livesolutions-theme.css is reachable" + break + fi + if [ "$i" -eq 30 ]; then + fail "livesolutions-theme.css is not reachable (brand formatting may be broken)" + fi + sleep 2 +done + +# --------------------------------------------------------------------------- +# 5. Custom plugin path mounted +# --------------------------------------------------------------------------- +log "Checking custom plugin mount..." +if docker compose exec -T web test -d /app/plugins/openproject-livesolutions; then + log "OK: openproject-livesolutions plugin directory mounted" +else + fail "openproject-livesolutions plugin directory missing" +fi + +# --------------------------------------------------------------------------- +# 6. Cloudflare Access initializer present +# --------------------------------------------------------------------------- +log "Checking Cloudflare Access initializer..." +if docker compose exec -T web test -f /app/config/initializers/custom/cloudflare_access_auth.rb; then + log "OK: cloudflare_access_auth.rb initializer present" +else + fail "cloudflare_access_auth.rb initializer missing (SSO will break)" +fi + +# --------------------------------------------------------------------------- +# 7. Premium feature unlock initializer mounted +# --------------------------------------------------------------------------- +log "Checking premium feature unlock initializer..." +if docker compose exec -T web test -f /app/config/initializers/custom/community_unlock.rb; then + log "OK: community_unlock.rb initializer present (premium features remain unlocked)" +else + fail "community_unlock.rb initializer missing; premium features will re-lock" +fi + +# --------------------------------------------------------------------------- +# 7. Public site returns 200 and contains login form +# --------------------------------------------------------------------------- +log "Checking public login page via Cloudflare Tunnel..." +if curl -fsS -L --max-time 30 "${PUBLIC_URL}/login" > /dev/null 2>&1; then + log "OK: public URL ${PUBLIC_URL}/login reachable" +else + warn "Public URL ${PUBLIC_URL}/login not reachable (may be expected from this host)" +fi + +# --------------------------------------------------------------------------- +# 7a. Hierarchy collapse state migration applied +# --------------------------------------------------------------------------- +log "Checking hierarchy collapse state migration..." +if docker compose exec -T web /app/bin/rails runner "exit(UserHierarchyCollapseState.table_exists? ? 0 : 1)" > /dev/null 2>&1; then + log "OK: user_hierarchy_collapse_states table exists" +else + fail "user_hierarchy_collapse_states table missing (migration not applied)" +fi + +# --------------------------------------------------------------------------- +# 7b. Pagination default patch active (API default pageSize is 100) +# --------------------------------------------------------------------------- +log "Checking pagination default patch..." +if docker compose exec -T web /app/bin/rails runner " + q = Query.new_default + h = API::V3::Queries::QueryParamsRepresenter.new(q).to_h + exit(h[:pageSize] == 100 ? 0 : 1) +" > /dev/null 2>&1; then + log "OK: API default pageSize is 100" +else + fail "API default pageSize is not 100 (pagination patch not active)" +fi + + + +# --------------------------------------------------------------------------- +# 8. Asset directory writable and not empty (after real use) +# --------------------------------------------------------------------------- +log "Checking asset directory..." +asset_size="$(docker compose exec -T web du -sb /var/openproject/assets 2>/dev/null | cut -f1 || echo -1)" +if [ "$asset_size" -ge 0 ] 2>/dev/null; then + log "OK: asset directory accessible (size=${asset_size} bytes)" +else + warn "Could not determine asset directory size" +fi + +# --------------------------------------------------------------------------- +# 7c. Global status endpoint (status.livesolutionsnow.com/healthz) +# --------------------------------------------------------------------------- +log "Checking global status endpoint..." +if curl -fsS -L --max-time 30 "https://status.livesolutionsnow.com/healthz" 2>/dev/null | grep -q "OK"; then + log "OK: https://status.livesolutionsnow.com/healthz returns OK" +else + warn "Global status endpoint did not return OK" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +if [ "$FAILED" -eq 0 ]; then + log "HEALTH CHECK PASSED" + exit 0 +else + log "HEALTH CHECK FAILED: ${FAILED} issue(s) detected" + exit 1 +fi diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100755 index 00000000..c1c25cff --- /dev/null +++ b/scripts/upgrade.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# Hardened OpenProject upgrade orchestrator. +# +# Goals: +# * Never lose data (backup before any mutation) +# * Preserve formatting/style (custom plugin / CSS / initializer checks) +# * Allow dry-run preview of upstream changes +# * Roll back automatically if post-upgrade health checks fail +# +# Usage: +# ./scripts/upgrade.sh [OPTIONS] +# +# Options: +# --dry-run Fetch upstream changes, show diff, but do not modify anything. +# --no-pull Skip `git pull` (useful when testing local changes). +# --restore-only Only restore the most recent backup and restart (rollback mode). + +set -e +set -o pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_BASE="docker compose" +CONTROL_COMPOSE="${COMPOSE_BASE} -f docker-compose.yml -f docker-compose.control.yml" +DRY_RUN=false +NO_PULL=false +RESTORE_ONLY=false +FAILED=false +BACKUP_TIMESTAMP="" + +usage() { + echo "Usage: $(basename "$0") [--dry-run] [--no-pull] [--restore-only]" + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --no-pull) NO_PULL=true ;; + --restore-only) RESTORE_ONLY=true ;; + -h|--help) usage ;; + *) echo "Unknown option: $1" >&2; usage ;; + esac + shift +done + +cd "$PROJECT_ROOT" + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" +} + +fail() { + log "ERROR: $*" >&2 + FAILED=true + exit 1 +} + +docker_compose_config_ok() { + log "Verifying Docker Compose configuration merge..." + if ! ${COMPOSE_BASE} config > /dev/null; then + fail "Merged compose configuration is invalid. Fix docker-compose.override.yml before continuing." + fi +} + +pre_upgrade_backup() { + log "=== PRE-UPGRADE BACKUP ===" + ${CONTROL_COMPOSE} build || fail "Failed to build control plane" + ${CONTROL_COMPOSE} run --rm backup || fail "Backup failed" + BACKUP_TIMESTAMP="$(ls -1 backups | grep -E '^[0-9]+-manifest\.txt$' | sort -n | tail -1 | cut -d- -f1)" + if [ -z "$BACKUP_TIMESTAMP" ]; then + fail "Backup completed but no manifest was written" + fi + log "Backup timestamp: ${BACKUP_TIMESTAMP}" +} + +pull_upstream() { + log "=== FETCH UPSTREAM CHANGES ===" + if [ "$NO_PULL" = true ]; then + log "Skipping git pull (--no-pull)." + return 0 + fi + + if [ "$DRY_RUN" = true ]; then + log "[DRY-RUN] Would run: git fetch origin stable/17" + git fetch origin stable/17 || fail "git fetch failed" + log "[DRY-RUN] Upstream diff preview:" + git diff HEAD...origin/stable/17 --stat || true + git diff HEAD...origin/stable/17 -- docker-compose.yml docker-compose.control.yml control/ .env.example README.md || true + return 0 + fi + + # Stash local customisations that are not in git before pulling, then re-apply. + # Only stash files that are tracked upstream so we don't stash our own custom dirs. + local stash_files=(docker-compose.override.yml proxy/Caddyfile .env) + local stashed=false + for f in "${stash_files[@]}"; do + if [ -f "$f" ] && ! git ls-files --error-unmatch "$f" > /dev/null 2>&1; then + log "Preserving local file: $f (will restore after pull)" + fi + done + + # Store a copy of critical local files in /tmp in case git pull overwrites them. + cp docker-compose.override.yml /tmp/op-docker-compose.override.yml.pre-pull.$$ + cp .env /tmp/op-env.pre-pull.$$ + + git fetch origin stable/17 || fail "git fetch failed" + + # Try a clean merge first; if it fails, abort and leave repo untouched. + if ! git merge --no-commit --no-ff origin/stable/17; then + git merge --abort 2> /dev/null || true + cp /tmp/op-docker-compose.override.yml.pre-pull.$$ docker-compose.override.yml + cp /tmp/op-env.pre-pull.$$ .env + fail "Automatic merge of upstream changes failed. Resolve manually before re-running." + fi + + # Restore local override files if upstream overwrote them. + if [ -f /tmp/op-docker-compose.override.yml.pre-pull.$$ ]; then + cp /tmp/op-docker-compose.override.yml.pre-pull.$$ docker-compose.override.yml + log "Restored docker-compose.override.yml after merge" + fi + if [ -f /tmp/op-env.pre-pull.$$ ]; then + cp /tmp/op-env.pre-pull.$$ .env + log "Restored .env after merge" + fi +} + +pre_upgrade_feature_check() { + log "=== PRE-UPGRADE FEATURE / COMPATIBILITY CHECK ===" + ./scripts/feature-check.sh || fail "Pre-upgrade feature/compatibility check failed" +} + +post_upgrade_feature_check() { + log "=== POST-UPGRADE FEATURE / COMPATIBILITY CHECK ===" + ./scripts/feature-check.sh || fail "Post-upgrade feature/compatibility check failed" +} + +verify_custom_files() { + log "=== VERIFY CUSTOM FILES PRESENT ===" + local files=( + "custom-plugin/openproject-livesolutions/app/assets/stylesheets/livesolutions/theme.css" + "custom-plugin/openproject-livesolutions/openproject-livesolutions.gemspec" + "openproject-config/initializers/cloudflare_access_auth.rb" + "openproject-config/initializers/community_unlock.rb" + "proxy/Caddyfile" + "smtp-relay/relay.py" + ) + for f in "${files[@]}"; do + if [ ! -f "$f" ]; then + fail "Customisation missing after update: $f" + fi + log "OK: $f" + done +} + +run_upgrade() { + if [ "$DRY_RUN" = true ]; then + log "[DRY-RUN] Would run database upgrade container if PG major version changed." + return 0 + fi + + log "=== DATABASE UPGRADE (if needed) ===" + # The upgrade script exits 0 when current PG version == target version. + ${CONTROL_COMPOSE} run --rm upgrade || fail "Database upgrade failed" +} + +restart_stack() { + if [ "$DRY_RUN" = true ]; then + log "[DRY-RUN] Would run: docker compose up -d --build --pull always" + return 0 + fi + + log "=== RESTART STACK ===" + ${COMPOSE_BASE} down || true + ${COMPOSE_BASE} up -d --build --pull always || fail "Stack restart failed" +} + +post_upgrade_health() { + if [ "$DRY_RUN" = true ]; then + log "[DRY-RUN] Would run post-upgrade health checks." + return 0 + fi + + log "=== POST-UPGRADE HEALTH CHECKS ===" + ./scripts/health-check.sh || fail "Post-upgrade health checks failed" +} + +rollback() { + log "=== ROLLBACK ===" + if [ -z "$BACKUP_TIMESTAMP" ]; then + log "No backup timestamp recorded; attempting to restore latest backup." + fi + ${CONTROL_COMPOSE} down || true + RESTORE_TIMESTAMP="${BACKUP_TIMESTAMP}" ${CONTROL_COMPOSE} run --rm restore || fail "Rollback restore failed" + ${COMPOSE_BASE} up -d || fail "Rollback restart failed" + log "Rollback complete. Investigate before trying another upgrade." +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if [ "$RESTORE_ONLY" = true ]; then + log "=== RESTORE-ONLY MODE ===" + ${CONTROL_COMPOSE} down || true + ${CONTROL_COMPOSE} run --rm restore || fail "Restore failed" + ${COMPOSE_BASE} up -d || fail "Restart after restore failed" + ./scripts/health-check.sh + exit 0 +fi + +# Ensure all customisations are committed/stashed or at least present before mutating +verify_custom_files +docker_compose_config_ok +pre_upgrade_feature_check +pre_upgrade_backup + +# If anything after this point fails, attempt rollback. +if ! pull_upstream; then + rollback + exit 1 +fi + +if ! run_upgrade; then + rollback + exit 1 +fi + +if ! restart_stack; then + rollback + exit 1 +fi + +if ! post_upgrade_health; then + rollback + exit 1 +fi + +if ! post_upgrade_feature_check; then + rollback + exit 1 +fi + +log "=== UPGRADE COMPLETE ===" +if [ "$DRY_RUN" = true ]; then + log "Dry-run finished. No changes were made." +else + log "OpenProject upgraded successfully." +fi