Maintenance: Allow building container images with preinstalled addon packages.

Approved-by: Martin Gruner <mg@zammad.com>

Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
Rolf Schmidt
2026-09-10 11:41:29 +02:00
parent ac93615eba
commit f6114a6aca
37 changed files with 909 additions and 82 deletions

2
.gitignore vendored
View File

@@ -124,6 +124,8 @@ schema.graphql
# Packages
*.zpm
!.gitlab/docker/*.zpm
/packages/
# Devcontainer
/.devcontainer/with-mailserver/certs/*

View File

@@ -1,4 +1,4 @@
docker:
.template_docker:
image: docker
stage: test
interruptible: false
@@ -13,37 +13,57 @@ docker:
cache: []
variables:
ZAMMAD_IMAGE_TAG: "ci-${CI_JOB_ID}-${CI_COMMIT_SHORT_SHA}"
ZAMMAD_IMAGE_REPO: "$CI_REGISTRY/zammad/zammad"
ZAMMAD_IMAGE_REPO: "zammad"
ZAMMAD_IMAGE: "${ZAMMAD_IMAGE_REPO}:${ZAMMAD_IMAGE_TAG}"
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- docker push $ZAMMAD_IMAGE
script: |
git clone --depth=1 https://github.com/zammad/zammad-docker-compose.git
cd zammad-docker-compose
- git clone --depth=1 https://github.com/zammad/zammad-docker-compose.git
# Make the stack use the image that was just built.
echo "IMAGE_REPO=${ZAMMAD_IMAGE_REPO}" > .env
echo "VERSION=${ZAMMAD_IMAGE_TAG}" >> .env
cat .env
- echo "IMAGE_REPO=${ZAMMAD_IMAGE_REPO}" > zammad-docker-compose/.env
- echo "VERSION=${ZAMMAD_IMAGE_TAG}" >> zammad-docker-compose/.env
- cat zammad-docker-compose/.env
# Use dynamic stack name and no exposed port to avoid conflicts.
cat - > docker-compose.override.yml <<COMPOSE_OVERRIDE_FILE
---
name: zammad-docker-ci-${ZAMMAD_IMAGE_TAG}
services:
zammad-nginx:
ports: !reset []
COMPOSE_OVERRIDE_FILE
.github/tests/setup/default.sh
DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
docker compose ps
# Ensure we are running with the correct image
docker compose ps | grep ${ZAMMAD_IMAGE}
- |
cat - > zammad-docker-compose/docker-compose.override.yml <<COMPOSE_OVERRIDE_FILE
---
name: zammad-docker-ci-${ZAMMAD_IMAGE_TAG}
services:
zammad-nginx:
ports: !reset []
COMPOSE_OVERRIDE_FILE
after_script:
- cd zammad-docker-compose
- docker compose down --timeout 0 --volumes
- docker image rm ${ZAMMAD_IMAGE}
docker base tests:
extends: .template_docker
script:
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- .github/tests/setup/default.sh
- DISABLE_EXTERNAL_TESTS=1 .github/tests/default.sh
- docker compose ps
# Ensure we are running with the correct image
- docker compose ps | grep ${ZAMMAD_IMAGE}
docker addon management:
extends: .template_docker
script:
- mkdir -p packages/install packages/uninstall
# part 1: test package installation
- cp .gitlab/docker/*.zpm packages/install/
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- echo "ELASTICSEARCH_ENABLED=false" >> .env
- docker compose run --rm zammad-init
- |
docker compose run --rm zammad-railsserver bundle exec rails r "Setting.where(name: ['zammad_test_a_addon_up', 'zammad_test_b_addon_up']).count == 2 || raise('Setting not found')"
- cd ..
# part 2: test package uninstallation
- mv packages/install/*.zpm packages/uninstall
- docker build --build-arg COMMIT_SHA=${CI_COMMIT_SHORT_SHA} --pull -t $ZAMMAD_IMAGE .
- cd zammad-docker-compose
- docker compose run --rm zammad-init
- |
docker compose run --rm zammad-railsserver bundle exec rails r "Setting.where(name: ['zammad_test_a_addon_down', 'zammad_test_b_addon_down']).count == 2 || raise('Setting not found')"

View File

@@ -0,0 +1,39 @@
---
name: ZammadTestA
version: 1.0.0
vendor: Zammad GmbH
license: GNU AFFERO GENERAL PUBLIC LICENSE
url: http://zammad.com/
change_log: []
dependencies:
ZammadTestB: ">= 1.0.0"
description:
- language: en
text: Test package for CI
- language: de
text: Testpaket für die CI
buildhost: zammad42
builddate: 2026-08-19 15:29:46 UTC
files:
- location: db/addon/zammad_test_a/20251112113130_zammad_test_a_settings.rb
permission: 644
content: |
class ZammadTestASettings < ActiveRecord::Migration[8.0]
def self.up
Setting.create_if_not_exists(
title: 'Zammad Test A Addon Up',
name: 'zammad_test_a_addon_up',
area: 'Core',
description: 'Verify that the addon was installed.',
)
end
def self.down
Setting.create_if_not_exists(
title: 'Zammad Test A Addon Down',
name: 'zammad_test_a_addon_down',
area: 'Core',
description: 'Verify that the addon was removed.',
)
end
end

View File

@@ -0,0 +1,31 @@
{
"name": "ZammadTestA",
"version": "1.0.0",
"vendor": "Zammad GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://zammad.com/",
"change_log": [],
"dependencies": {
"ZammadTestB": ">= 1.0.0"
},
"description": [
{
"language": "en",
"text": "Test package for CI"
},
{
"language": "de",
"text": "Testpaket für die CI"
}
],
"buildhost": "zammad42",
"builddate": "2026-08-19 15:29:46 UTC",
"files": [
{
"location": "db/addon/zammad_test_a/20251112113130_zammad_test_a_settings.rb",
"permission": 644,
"content": "Y2xhc3MgWmFtbWFkVGVzdEFTZXR0aW5ncyA8IEFjdGl2ZVJlY29yZDo6TWlncmF0aW9uWzguMF0KICBkZWYgc2VsZi51cAogICAgU2V0dGluZy5jcmVhdGVfaWZfbm90X2V4aXN0cygKICAgICAgdGl0bGU6ICAgICAgICdaYW1tYWQgVGVzdCBBIEFkZG9uIFVwJywKICAgICAgbmFtZTogICAgICAgICd6YW1tYWRfdGVzdF9hX2FkZG9uX3VwJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIGluc3RhbGxlZC4nLAogICAgKQogIGVuZAoKICBkZWYgc2VsZi5kb3duCiAgICBTZXR0aW5nLmNyZWF0ZV9pZl9ub3RfZXhpc3RzKAogICAgICB0aXRsZTogICAgICAgJ1phbW1hZCBUZXN0IEEgQWRkb24gRG93bicsCiAgICAgIG5hbWU6ICAgICAgICAnemFtbWFkX3Rlc3RfYV9hZGRvbl9kb3duJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIHJlbW92ZWQuJywKICAgICkKICBlbmQKZW5kCg==",
"encode": "base64"
}
]
}

View File

@@ -0,0 +1,37 @@
---
name: ZammadTestB
version: 1.0.0
vendor: Zammad GmbH
license: GNU AFFERO GENERAL PUBLIC LICENSE
url: http://zammad.com/
change_log: []
description:
- language: en
text: Test package for CI
- language: de
text: Testpaket für die CI
buildhost: zammad42
builddate: 2026-08-19 15:29:46 UTC
files:
- location: db/addon/zammad_test_b/20251112113130_zammad_test_b_settings.rb
permission: 644
content: |
class ZammadTestBSettings < ActiveRecord::Migration[8.0]
def self.up
Setting.create_if_not_exists(
title: 'Zammad Test B Addon Up',
name: 'zammad_test_b_addon_up',
area: 'Core',
description: 'Verify that the addon was installed.',
)
end
def self.down
Setting.create_if_not_exists(
title: 'Zammad Test B Addon Down',
name: 'zammad_test_b_addon_down',
area: 'Core',
description: 'Verify that the addon was removed.',
)
end
end

View File

@@ -0,0 +1,28 @@
{
"name": "ZammadTestB",
"version": "1.0.0",
"vendor": "Zammad GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://zammad.com/",
"change_log": [],
"description": [
{
"language": "en",
"text": "Test package for CI"
},
{
"language": "de",
"text": "Testpaket für die CI"
}
],
"buildhost": "zammad42",
"builddate": "2026-08-19 15:29:46 UTC",
"files": [
{
"location": "db/addon/zammad_test_b/20251112113130_zammad_test_b_settings.rb",
"permission": 644,
"content": "Y2xhc3MgWmFtbWFkVGVzdEJTZXR0aW5ncyA8IEFjdGl2ZVJlY29yZDo6TWlncmF0aW9uWzguMF0KICBkZWYgc2VsZi51cAogICAgU2V0dGluZy5jcmVhdGVfaWZfbm90X2V4aXN0cygKICAgICAgdGl0bGU6ICAgICAgICdaYW1tYWQgVGVzdCBCIEFkZG9uIFVwJywKICAgICAgbmFtZTogICAgICAgICd6YW1tYWRfdGVzdF9iX2FkZG9uX3VwJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIGluc3RhbGxlZC4nLAogICAgKQogIGVuZAoKICBkZWYgc2VsZi5kb3duCiAgICBTZXR0aW5nLmNyZWF0ZV9pZl9ub3RfZXhpc3RzKAogICAgICB0aXRsZTogICAgICAgJ1phbW1hZCBUZXN0IEIgQWRkb24gRG93bicsCiAgICAgIG5hbWU6ICAgICAgICAnemFtbWFkX3Rlc3RfYl9hZGRvbl9kb3duJywKICAgICAgYXJlYTogICAgICAgICdDb3JlJywKICAgICAgZGVzY3JpcHRpb246ICdWZXJpZnkgdGhhdCB0aGUgYWRkb24gd2FzIHJlbW92ZWQuJywKICAgICkKICBlbmQKZW5kCg==",
"encode": "base64"
}
]
}

41
.gitlab/docker/yaml_to_zpm.rb Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
# Converts all package definitions in YAML form found next to this script to the
# JSON based .zpm format, written to a file of the same base name. The content of
# all entries in "files" is transcoded to base64.
#
# ruby yaml_to_zpm.rb
#
# ZammadTestA-1.0.0.yaml -> ZammadTestA-1.0.0.zpm
require 'base64'
require 'json'
require 'yaml'
def convert(source, target)
package = YAML.safe_load_file(source, aliases: true)
raise "Expected a YAML mapping as package definition in #{source}." if !package.is_a?(Hash)
Array(package['files']).each do |file|
next if !file.is_a?(Hash) || !file.key?('content')
file['encode'] = 'base64'
file['content'] = Base64.strict_encode64(file['content'].to_s)
end
File.write(target, "#{JSON.pretty_generate(package)}\n")
end
sources = Dir.glob('*.{yaml,yml}', base: __dir__).sort
raise 'No YAML package definitions found.' if sources.empty?
sources.each do |source|
target = "#{File.basename(source, '.*')}.zpm"
convert(File.join(__dir__, source), File.join(__dir__, target))
puts "#{source} -> #{target}"
end

View File

@@ -40,6 +40,9 @@ RUN npm -g install corepack && corepack enable pnpm && \
FROM base AS build
ARG COMMIT_SHA
# Optional additional build information, e.g. the customer image name when building
# images with addon packages. Shown in the version string of the instance.
ARG BUILD_LABEL
SHELL ["/bin/bash", "-o", "errexit", "-o", "pipefail", "-c"]
@@ -72,10 +75,29 @@ RUN if [ -z "${COMMIT_SHA}" ]; then \
exit 1; \
fi; \
COMMIT_SHA_SHORT=$(echo "${COMMIT_SHA}" | cut -c 1-8); \
echo "$(tr -d '\n' < VERSION)-${COMMIT_SHA_SHORT}.docker" > VERSION; \
echo "$(tr -d '\n' < VERSION)-${COMMIT_SHA_SHORT}${BUILD_LABEL:+.${BUILD_LABEL}}.docker" > VERSION; \
echo 'Updated build information in VERSION:'; \
cat VERSION
# Special handling for zpm addons, if present:
# - Unpack addon packages provided in packages/install/*.zpm (registered in the database on container start)
# and packages/uninstall/*.zpm (removed from the database on container start).
# - Install additional gems provided by addon packages (Gemfile.local.*).
# Deployment mode is disabled to allow updating the Gemfile.lock with the additional gems.
# - Regenerate the GraphQL frontend API in case addon packages extend the GraphQL schema.
# A rake task is used instead of 'rails generate' because rake skips eager loading,
# which would require a database connection.
RUN if compgen -G "packages/*/*.zpm" > /dev/null; then \
ruby script/build/unpack_addon_packages.rb; \
if compgen -G "Gemfile.local.*" > /dev/null; then \
BUNDLE_DEPLOYMENT=false bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache; \
fi; \
mkdir -p tmp && touch db/schema.rb; \
RAILS_LOG_TO_STDOUT= ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql://zammad:/zammad ZAMMAD_GRAPHQL_INTROSPECTION=true bundle exec rake zammad:graphql:introspection > app/graphql/graphql_introspection.json; \
pnpm exec graphql-codegen -c .graphql_code_generator.js; \
fi
# Don't require Redis or Postgres (use fake DATABASE_URL to make Rails validation happy).
RUN touch db/schema.rb && \
ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql://zammad:/zammad bundle exec rake assets:precompile

View File

@@ -2,6 +2,7 @@
class PackagesController < ApplicationController
prepend_before_action :authenticate_and_authorize!
before_action :prevent_container_environment, only: %i[install uninstall install_api update_api]
# GET /api/v1/packages
def index
@@ -56,6 +57,12 @@ class PackagesController < ApplicationController
private
def prevent_container_environment
return if !Zammad::Deployment.container?
raise Exceptions::UnprocessableContent, __('Installing, updating or uninstalling packages is not possible in container environments.')
end
def api_packages
@api_packages ||= Package.api_packages_hash({ version_name: Package.api_version_name })
end

View File

@@ -373,7 +373,7 @@ class SessionsController < ApplicationController
config['es_enabled'] = SearchIndexBackend.enabled?
# Hide the admin packages interface in environments like docker, where installed packages do not persist.
config['admin_packages'] = ENV['ZAMMAD_DOCKER'].blank?
config['admin_packages'] = !Zammad::Deployment.container?
# remember if we can switch back to user
if session[:switched_from_user_id]

View File

@@ -239,5 +239,6 @@ export interface ConfigList {
vectordb_enabled: boolean
websocket_backend: string
websocket_port: string
// This is also required for packages (addons) in docker environments, as their settings cannot be part of this generated file (no database at image build time).
[key: string]: unknown
}

View File

@@ -54,30 +54,6 @@ returns:
=begin
install all packages located under auto_install/*.zpm
Package.auto_install
=end
def self.auto_install
path = "#{@@root}/auto_install/"
return if !File.exist?(path)
data = []
Dir.foreach(path) do |entry|
if entry.include?('.zpm') && entry !~ %r{^\.}
data.push entry
end
end
data.each do |file|
install(file: "#{path}/#{file}")
end
data
end
=begin
remove all linked files in application
note: will not take down package migrations, use Package.unlink instead
@@ -216,6 +192,11 @@ or
package = Package.install(string: zpm_as_string)
Optionally, writing the package files to the file system can be skipped, e.g. in
container environments where the files are already part of the image:
package = Package.install(file: '/path/to/package.zpm', write_files: false)
returns
package # record of newly created package
@@ -227,6 +208,8 @@ subsequently in a separate step.
=end
def self.install(data)
write_files = data.fetch(:write_files, true)
if data[:file]
json = _read_file(data[:file], true)
package = JSON.parse(json)
@@ -265,6 +248,8 @@ subsequently in a separate step.
version: package_db.version,
migration_not_down: true,
reinstall: data[:reinstall],
replacement: true,
remove_files: write_files,
)
end
@@ -283,17 +268,7 @@ subsequently in a separate step.
end
# write files
package['files'].each do |file|
if !allowed_file_path?(file['location'])
raise "Can't create file, because of not allowed file location: #{file['location']}!"
end
ensure_no_duplicate_files!(package_db.name, file['location'])
permission = file['permission'] || '644'
content = Base64.decode64(file['content'])
_write_file(file['location'], permission, content)
end
_install_files(package_db, package['files'], write_files)
# update package state
package_db.reload
@@ -304,6 +279,113 @@ subsequently in a separate step.
package_db
end
def self._install_files(package_db, files, write_files)
files.each do |file|
if !allowed_file_path?(file['location'])
raise "Can't create file, because of not allowed file location: #{file['location']}!"
end
ensure_no_duplicate_files!(package_db.name, file['location'])
next if !write_files
permission = file['permission'] || '644'
content = Base64.decode64(file['content'])
_write_file(file['location'], permission, content)
end
end
=begin
install or update all packages located in the given directory, in dependency order
Package.install_dir('packages/install')
Optionally without writing the package files to the file system (see Package.install):
Package.install_dir('packages/install', write_files: false)
Already installed packages with the same or a newer version are skipped.
Migrations will not be executed (see Package.install).
=end
def self.install_dir(directory, write_files: true)
_sort_by_dependencies(_packages_in_dir(directory)).each do |package|
installed = Package.find_by(name: package['name'])
if installed && Gem::Version.new(installed.version) >= Gem::Version.new(package['version'])
logger.info "Package #{package['name']}-#{package['version']} is already installed."
next
end
logger.info "Installing package #{package['name']}-#{package['version']}..."
install(file: package['zpm_file'], write_files: write_files)
# Dependency and duplicate file checks of subsequent packages must see this package.
Auth::RequestCache.clear
end
end
=begin
uninstall all packages located in the given directory, in reverse dependency order
Package.uninstall_dir('packages/uninstall')
Optionally without removing the package files from the file system (see Package.uninstall):
Package.uninstall_dir('packages/uninstall', remove_files: false)
Packages which are not installed are skipped. The installed version is uninstalled,
regardless of the version of the .zpm file in the directory.
Down migrations are executed (see Package.uninstall).
=end
def self.uninstall_dir(directory, remove_files: true)
_sort_by_dependencies(_packages_in_dir(directory)).reverse_each do |package|
installed = Package.find_by(name: package['name'])
if !installed
logger.info "Package #{package['name']} is not installed."
next
end
logger.info "Uninstalling package #{installed.name}-#{installed.version}..."
uninstall(name: installed.name, version: installed.version, remove_files: remove_files)
# Dependency checks of subsequent packages must no longer see this package.
Auth::RequestCache.clear
end
end
def self._packages_in_dir(directory)
Rails.root.join(directory).glob('*.zpm').map do |zpm_file|
JSON.parse(File.read(zpm_file)).merge('zpm_file' => zpm_file.to_s)
end
end
def self._sort_by_dependencies(packages)
sorted_packages = []
remaining_packages = packages
while remaining_packages.any?
ready_packages = remaining_packages.select do |package|
(package['dependencies'] || {}).keys.none? do |dependency_name|
remaining_packages.any? { |candidate| candidate['name'] == dependency_name }
end
end
raise "Circular dependencies between packages: #{remaining_packages.pluck('name').join(', ')}!" if ready_packages.empty?
sorted_packages += ready_packages
remaining_packages -= ready_packages
end
sorted_packages
end
def self.ensure_dependencies_install!(dependencies)
return if dependencies.blank?
@@ -446,6 +528,11 @@ or
package = Package.uninstall(string: zpm_as_string)
Optionally, removing the package files from the file system can be skipped, e.g. in
container environments where the files are part of the image:
package = Package.uninstall(name: 'package', version: '0.1.1', remove_files: false)
returns
package # record of newly created package
@@ -461,7 +548,8 @@ returns
package = JSON.parse(json_file)
end
ensure_dependencies_uninstall!(package['name']) if !data[:reinstall]
# on reinstall/replacement the package stays available to dependent packages
ensure_dependencies_uninstall!(package['name']) if !data[:reinstall] && !data[:replacement]
# down migrations
if !data[:migration_not_down]
@@ -473,7 +561,7 @@ returns
version: package['version'],
)
if record.state == 'installed'
if record.state == 'installed' && data.fetch(:remove_files, true)
package['files'].each do |file|
permission = file['permission'] || '644'
content = Base64.decode64(file['content'])

View File

@@ -87,6 +87,19 @@ if [ "$1" = 'zammad-init' ]; then
bundle exec rails r "Locale.sync; Translation.sync"
fi
# uninstall addon packages staged in packages/uninstall/ (runs their down migrations)
if compgen -G "packages/uninstall/*.zpm" > /dev/null; then
echo "uninstalling addon packages..."
bundle exec rake zammad:package:uninstall_dir packages/uninstall
fi
# install / update addon packages staged in packages/install/
if compgen -G "packages/install/*.zpm" > /dev/null; then
echo "installing / updating addon packages..."
bundle exec rake zammad:package:install_dir packages/install
bundle exec rake zammad:package:migrate
fi
# es config
echo "changing settings..."
if [ "${ELASTICSEARCH_ENABLED}" == "false" ]; then

View File

@@ -47,6 +47,3 @@ Locale.sync
Translation.sync
Calendar.init_setup
# install all packages in auto_install
Package.auto_install

View File

@@ -14,7 +14,7 @@ Channel.create_if_not_exists(
},
},
preferences: { online_service_disable: true },
active: ENV['ZAMMAD_DOCKER'].present?,
active: Zammad::Deployment.container?,
)
Channel.create_if_not_exists(
area: 'Email::Notification',
@@ -24,5 +24,5 @@ Channel.create_if_not_exists(
},
},
preferences: { online_service_disable: true },
active: ENV['ZAMMAD_DOCKER'].blank?,
active: !Zammad::Deployment.container?,
)

View File

@@ -9572,6 +9572,10 @@ msgstr ""
msgid "Installed packages"
msgstr ""
#: app/controllers/packages_controller.rb:58
msgid "Installing, updating or uninstalling packages is not possible in container environments."
msgstr ""
#: app/models/ai/agent/type/custom_ai_agent.rb:34
msgid "Instruction"
msgstr ""

View File

@@ -25,7 +25,7 @@ returns
def self.available_driver
# Sendmail is unsupported in Docker and SaaS environments, so we hide it in those cases.
if ENV['ZAMMAD_DOCKER'].present? || Setting.get('system_online_service')
if Zammad::Deployment.container? || Setting.get('system_online_service')
return {
inbound: {
imap: __('IMAP'),

View File

@@ -40,6 +40,7 @@ class Zammad::SettingTypesGenerator < Rails::Generators::Base
'auth_openid_connect_credentials.display_name'?: string
es_enabled?: boolean
#{fields.join("\n")}
// This is also required for packages (addons) in docker environments, as their settings cannot be part of this generated file (no database at image build time).
[key: string]: unknown
}
MSG

View File

@@ -0,0 +1,15 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
namespace :zammad do
namespace :graphql do
desc 'Output the GraphQL introspection information as JSON'
task introspection: :environment do
raise 'GraphQL schema generation is disabled for security reasons. To enable it in your system, set the following ENV ZAMMAD_GRAPHQL_INTROSPECTION=true' if !Gql::ZammadSchema.introspection_enabled?
result = Gql::ZammadSchema.execute(GraphQL::Introspection::INTROSPECTION_QUERY, variables: {}, context: { is_graphql_introspection_generator: true })
raise "GraphQL schema could not be successfully generated: #{result['errors'].first['message']}" if result['errors']
puts JSON.pretty_generate(result)
end
end
end

View File

@@ -18,6 +18,8 @@ module Tasks
ARGUMENT_COUNT = 1
def self.task_handler
abort_in_container_environment!
filename = ArgvHelper.argv[1]
if filename.blank?
abort "Error: Please provide a valid filename: #{usage}"

View File

@@ -17,6 +17,8 @@ module Tasks
ARGUMENT_COUNT = 3
def self.task_handler
abort_in_container_environment!
package_name = ArgvHelper.argv[1]
version_name = ArgvHelper.argv[2]
prod_mode = ArgvHelper.argv[3] == 'prod'

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package/install_dir'
Tasks::Zammad::Package::InstallDir.register_rake_task

View File

@@ -0,0 +1,38 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class InstallDir < Tasks::Zammad::PackageCommand
def self.usage
"#{super} /path/to/directory"
end
def self.description
'Install or update all Zammad addon packages from a directory in dependency order'
end
ARGUMENT_COUNT = 1
def self.task_handler
directory = ArgvHelper.argv[1]
if directory.blank?
abort "Error: Please provide a valid directory: #{usage}"
end
if !File.directory?(directory)
abort "Could not find directory #{directory}."
end
puts "Installing all packages in #{directory} (without package migrations)..."
::Package.install_dir(directory, write_files: !::Zammad::Deployment.container?)
puts 'done.'
puts "Please run package migrations now via 'zammad:package:migrate'."
end
end
end
end
end

View File

@@ -18,6 +18,8 @@ module Tasks
ARGUMENT_COUNT = 1
def self.task_handler
abort_in_container_environment!
package_name = ArgvHelper.argv[1]
if package_name.blank?
abort "Error: Please provide a valid package name: #{usage}"

View File

@@ -12,6 +12,8 @@ module Tasks
end
def self.task_handler
abort_in_container_environment!
puts 'Reinstalling all packages...'
::Package.find_each do |package|
puts "Reinstalling '#{package.name}' (#{package.version})..."

View File

@@ -18,6 +18,8 @@ module Tasks
ARGUMENT_COUNT = 1
def self.task_handler
abort_in_container_environment!
name = ArgvHelper.argv[1]
if name.blank?
abort "Error: please provide a package name: #{ArgvHelper.argv[0]} MyPackage"

View File

@@ -11,6 +11,8 @@ module Tasks
end
def self.task_handler
abort_in_container_environment!
::Package.pluck(:name, :version).each do |name, version|
puts "Removing files of Package '#{name}'..."

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package/uninstall_dir'
Tasks::Zammad::Package::UninstallDir.register_rake_task

View File

@@ -0,0 +1,37 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class UninstallDir < Tasks::Zammad::PackageCommand
def self.usage
"#{super} /path/to/directory"
end
def self.description
'Uninstall all Zammad addon packages from a directory in reverse dependency order'
end
ARGUMENT_COUNT = 1
def self.task_handler
directory = ArgvHelper.argv[1]
if directory.blank?
abort "Error: Please provide a valid directory: #{usage}"
end
if !File.directory?(directory)
abort "Could not find directory #{directory}."
end
puts "Uninstalling all packages in #{directory} (including package migrations)..."
::Package.uninstall_dir(directory, remove_files: !::Zammad::Deployment.container?)
puts 'done.'
end
end
end
end
end

View File

@@ -17,6 +17,8 @@ module Tasks
ARGUMENT_COUNT = 3
def self.task_handler
abort_in_container_environment!
package_name = ArgvHelper.argv[1]
version_name = ArgvHelper.argv[2]
prod_mode = ArgvHelper.argv[3] == 'prod'

View File

@@ -7,6 +7,10 @@ class Tasks::Zammad::PackageCommand < Tasks::Zammad::Command
ENV['PACKAGES_TOKEN'] || Setting.get('packages_token')
end
def self.abort_in_container_environment!
abort 'Error: Installing, updating or uninstalling packages is not possible in container environments.' if ::Zammad::Deployment.container?
end
def self.validate_args!(version_name, mode)
abort "Error: Invalid parameter version name '#{version_name}'!" if version_name.blank? || version_name !~ %r{^(\d+)\.(\d+)\.x\.?$}
abort "Error: Invalid parameter execution mode '#{mode}'!" if %w[dry prod].exclude?(mode)

12
lib/zammad/deployment.rb Normal file
View File

@@ -0,0 +1,12 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
module Zammad
module Deployment
class << self
def container?
# Dockerfile sets this ENV for reliable detection.
%w[1 true].include?(ENV['ZAMMAD_DOCKER'])
end
end
end
end

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
# Unpacks all addon package files into the application source tree:
# - packages/install/*.zpm: all files, the packages are registered in the database on container start.
# - packages/uninstall/*.zpm: all files, so that the down migrations can run on container start.
# The .zpm files stay in place, they are processed by the container init.
# Runs during the container image build, before assets:precompile - without a database.
require 'base64'
require 'fileutils'
require 'json'
ROOT_DIR = File.expand_path('../..', __dir__)
def read_packages(directory)
Dir[File.join(ROOT_DIR, directory, '*.zpm')].map do |zpm_file|
JSON.parse(File.read(zpm_file)).merge('zpm_file' => zpm_file)
end
end
def locations(packages)
packages.flat_map { |package| package['files'].map { |file| file['location'] } }
end
def unpack(package)
puts "Unpacking #{package['name']}-#{package['version']} (#{File.basename(package['zpm_file'])})..."
package['files'].each do |file|
location = file['location']
raise "Not allowed file location: #{location}!" if location.include?('..') || location.start_with?('/')
path = File.join(ROOT_DIR, location)
FileUtils.mkdir_p(File.dirname(path))
File.binwrite(path, Base64.decode64(file['content']))
File.chmod((file['permission'] || '644').to_s.to_i(8), path)
end
end
install_packages = read_packages('packages/install')
uninstall_packages = read_packages('packages/uninstall')
if install_packages.empty? && uninstall_packages.empty?
puts 'No addon packages found in packages/install/ or packages/uninstall/, skipping.'
exit 0
end
# This script runs without ActiveSupport, so Array#pluck is not available.
conflicts = install_packages.map { |package| package['name'] } & uninstall_packages.map { |package| package['name'] } # rubocop:disable Rails/Pluck
raise "Packages staged for both installation and uninstallation: #{conflicts.join(', ')}!" if conflicts.any?
# Files of outgoing packages stay in the image for their down migrations. They must not
# overwrite files of incoming packages, so the build fails instead of producing an ambiguous image.
overlapping = locations(install_packages) & locations(uninstall_packages)
raise "Files provided by packages staged for both installation and uninstallation: #{overlapping.join(', ')}!" if overlapping.any?
(install_packages + uninstall_packages).each { |package| unpack(package) }
puts 'done.'

View File

@@ -0,0 +1,65 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Tasks::Zammad::Package::UninstallDir do
describe '.description' do
it 'returns the description' do
expect(described_class.description).to eq('Uninstall all Zammad addon packages from a directory in reverse dependency order')
end
end
describe '.task_handler' do
let(:directory) { Dir.mktmpdir('package-uninstall-dir-task', Rails.root.join('tmp')) }
before do
allow(ArgvHelper).to receive(:argv).and_return(argv)
allow(Package).to receive(:uninstall_dir)
end
after do
FileUtils.remove_entry(directory)
end
context 'without directory' do
let(:argv) { %w[zammad:package:uninstall_dir] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output(%r{Please provide a valid directory}).to_stderr
end
end
context 'with missing directory' do
let(:argv) { %w[zammad:package:uninstall_dir /nonexistent/directory] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output(%r{Could not find directory}).to_stderr
end
end
context 'with existing directory' do
let(:argv) { ['zammad:package:uninstall_dir', directory] }
it 'uninstalls all packages in the directory including their files', :aggregate_failures do
expect { described_class.task_handler }.to output(%r{Uninstalling all packages in #{Regexp.escape(directory)}}).to_stdout
expect(Package).to have_received(:uninstall_dir).with(directory, remove_files: true)
end
context 'when running in a container environment' do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('ZAMMAD_DOCKER').and_return('true')
end
it 'uninstalls all packages in the directory without touching the file system', :aggregate_failures do
expect { described_class.task_handler }.to output(%r{done\.}).to_stdout
expect(Package).to have_received(:uninstall_dir).with(directory, remove_files: false)
end
end
end
end
end

View File

@@ -0,0 +1,37 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Zammad::Deployment do
before do
allow(ENV).to receive(:[]).with('ZAMMAD_DOCKER').and_return(env_value)
end
describe '.container?' do
subject(:container) { described_class.container? }
context 'without ZAMMAD_DOCKER' do
let(:env_value) { nil }
it { is_expected.to be false }
end
# The Dockerfile sets 'true'; '1' is accepted for hand-written container setups.
%w[1 true].each do |value|
context "with ZAMMAD_DOCKER set to #{value.inspect}" do
let(:env_value) { value }
it { is_expected.to be true }
end
end
# Only the documented values count, so that an explicit opt-out is not misread as a container.
['0', 'false', '', 'yes', 'docker'].each do |value|
context "with ZAMMAD_DOCKER set to #{value.inspect}" do
let(:env_value) { value }
it { is_expected.to be false }
end
end
end
end

View File

@@ -137,24 +137,190 @@ RSpec.describe Package, type: :model do
end
end
context 'when auto installing' do
context 'when installing a package without writing files' do
before do
FileUtils.mkdir_p(Rails.root.join('auto_install'))
Rails.root.join('example.rb').delete if Rails.root.join('example.rb').exist?
end
location = Rails.root.join('auto_install/unittest.zpm')
file = File.new(location, 'wb')
file.write(package_zpm_json)
file.close
it 'does install the package without touching the file system' do
expect { described_class.install(string: package_zpm_json, write_files: false) }
.to change(described_class, :count)
.and change(Store, :count)
.and not_change { Rails.root.join('example.rb').exist? }
end
end
context 'when uninstalling a package without removing files' do
before do
described_class.install(string: package_zpm_json)
end
it 'does uninstall the package without touching the file system' do
expect { described_class.uninstall(string: package_zpm_json, remove_files: false) }
.to change(described_class, :count).by(-1)
.and not_change { Rails.root.join('example.rb').exist? }
end
end
context 'when installing packages from a directory' do
let(:install_dir) { Rails.root.join('tmp/spec_package_install_dir') }
let(:dependent_package_zpm_json) { get_package_structure('UnitTestSampleDependent', '[]', '1.0.1', "{ \"#{package_name}\": \">= 1.0.1\" }") }
before do
FileUtils.mkdir_p(install_dir)
# File names are chosen so that plain glob order would install the dependent package first.
File.write(install_dir.join('01_dependent.zpm'), dependent_package_zpm_json)
File.write(install_dir.join('02_dependency.zpm'), package_zpm_json)
end
after do
Rails.root.join('auto_install/unittest.zpm').delete
FileUtils.rm_rf(install_dir)
Rails.root.join('example.rb.save').delete if Rails.root.join('example.rb.save').exist?
end
it 'does install package' do
expect { described_class.auto_install }
.to change(described_class, :count)
.and change(Store, :count)
it 'does install all packages in dependency order' do
expect { described_class.install_dir(install_dir.to_s) }
.to change(described_class, :count).by(2)
end
it 'does not install already installed packages again' do
described_class.install_dir(install_dir.to_s)
expect { described_class.install_dir(install_dir.to_s) }
.to not_change(described_class, :count)
end
it 'does install packages without touching the file system' do
Rails.root.join('example.rb').delete if Rails.root.join('example.rb').exist?
expect { described_class.install_dir(install_dir.to_s, write_files: false) }
.to change(described_class, :count).by(2)
.and not_change { Rails.root.join('example.rb').exist? }
end
context 'with a chain of dependencies' do
let(:chained_package_zpm_json) { get_package_structure('UnitTestSampleChained', '[]', '1.0.1', '{ "UnitTestSampleDependent": ">= 1.0.1" }') }
before do
File.write(install_dir.join('00_chained.zpm'), chained_package_zpm_json)
end
it 'does install all packages in dependency order' do
expect { described_class.install_dir(install_dir.to_s) }
.to change(described_class, :count).by(3)
end
end
context 'when upgrading a package another installed package depends on' do
before do
described_class.install_dir(install_dir.to_s)
Auth::RequestCache.clear
File.write(install_dir.join('02_dependency.zpm'), new_package_zpm_json)
end
it 'does upgrade the package' do
expect { described_class.install_dir(install_dir.to_s) }
.to not_change(described_class, :count)
.and change { described_class.find_by(name: package_name).version }.to('1.0.2')
end
end
end
context 'when uninstalling packages from a directory' do
let(:uninstall_dir) { Rails.root.join('tmp/spec_package_uninstall_dir') }
let(:dependent_package_zpm_json) { get_package_structure('UnitTestSampleDependent', '[]', '1.0.1', "{ \"#{package_name}\": \">= 1.0.1\" }") }
before do
FileUtils.mkdir_p(uninstall_dir)
# File names are chosen so that plain glob order would uninstall the dependency first, which must fail.
File.write(uninstall_dir.join('01_dependency.zpm'), package_zpm_json)
File.write(uninstall_dir.join('02_dependent.zpm'), dependent_package_zpm_json)
described_class.install(string: package_zpm_json)
Auth::RequestCache.clear
described_class.install(string: dependent_package_zpm_json)
Auth::RequestCache.clear
end
after do
FileUtils.rm_rf(uninstall_dir)
end
it 'does uninstall all packages in reverse dependency order' do
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change(described_class, :count).by(-2)
end
it 'does skip packages which are not installed' do
described_class.uninstall_dir(uninstall_dir.to_s)
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to not_change(described_class, :count)
end
it 'does uninstall packages without touching the file system' do
expect { described_class.uninstall_dir(uninstall_dir.to_s, remove_files: false) }
.to change(described_class, :count).by(-2)
.and not_change { Rails.root.join('example.rb').exist? }
end
it 'does uninstall the installed version regardless of the staged version' do
File.write(uninstall_dir.join('01_dependency.zpm'), get_package_structure(package_name, package_zpm_files_json, '9.9.9'))
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change(described_class, :count).by(-2)
end
context 'when an installed package outside the directory depends on a staged package' do
before do
uninstall_dir.join('02_dependent.zpm').delete
end
it 'does raise an error and keep the packages' do
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to raise_error(%r{required dependencies})
.and not_change(described_class, :count)
end
end
context 'with package migrations' do
let(:migration_root) { Dir.mktmpdir('package-uninstall-dir', Rails.root.join('tmp')) }
let(:migration_dir) { File.join(migration_root, 'db/addon', package_name.underscore) }
let(:role) { create(:role) }
before do
FileUtils.mkdir_p(migration_dir)
File.write(File.join(migration_dir, '20260101000000_uninstall_dir_package_test.rb'), <<~MIGRATION)
class UninstallDirPackageTest < ActiveRecord::Migration[8.0]
def self.up
Role.find(#{role.id}).update!(name: 'Package Migrated')
end
def self.down
Role.find(#{role.id}).update!(name: 'Package Reverted')
end
end
MIGRATION
allow(Package::Migration).to receive(:root).and_return(migration_root)
Package::Migration.migrate(package_name)
end
after do
FileUtils.remove_entry(migration_root)
end
it 'does execute the down migrations of the uninstalled packages', :aggregate_failures do
expect(role.reload.name).to eq('Package Migrated')
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change { Package::Migration.where(name: package_name.underscore).count }.by(-1)
expect(role.reload.name).to eq('Package Reverted')
end
end
end

View File

@@ -74,4 +74,45 @@ RSpec.describe 'Packages', type: :request do
expect(json_response['error']).to eq('User authorization failed.')
end
end
describe 'request handling in container environments' do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with('ZAMMAD_DOCKER').and_return('true')
authenticated_as(admin)
end
it 'does packages index with admin' do
get '/api/v1/packages', as: :json
expect(response).to have_http_status(:ok)
expect(json_response['packages']).to be_truthy
end
it 'refuses package install' do
post '/api/v1/packages', as: :json
expect(response).to have_http_status(:unprocessable_content)
expect(json_response['error']).to eq('Installing, updating or uninstalling packages is not possible in container environments.')
end
it 'refuses package uninstall' do
delete '/api/v1/packages', as: :json
expect(response).to have_http_status(:unprocessable_content)
end
it 'refuses package install via api' do
post '/api/v1/packages/api', as: :json
expect(response).to have_http_status(:unprocessable_content)
end
it 'refuses package update via api' do
put '/api/v1/packages/api', as: :json
expect(response).to have_http_status(:unprocessable_content)
end
end
end