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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -124,6 +124,8 @@ schema.graphql
|
||||
|
||||
# Packages
|
||||
*.zpm
|
||||
!.gitlab/docker/*.zpm
|
||||
/packages/
|
||||
|
||||
# Devcontainer
|
||||
/.devcontainer/with-mailserver/certs/*
|
||||
|
||||
@@ -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
|
||||
- |
|
||||
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
|
||||
|
||||
.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}
|
||||
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')"
|
||||
|
||||
39
.gitlab/docker/ZammadTestA-1.0.0.yaml
Normal file
39
.gitlab/docker/ZammadTestA-1.0.0.yaml
Normal 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
|
||||
31
.gitlab/docker/ZammadTestA-1.0.0.zpm
Normal file
31
.gitlab/docker/ZammadTestA-1.0.0.zpm
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
37
.gitlab/docker/ZammadTestB-1.0.0.yaml
Normal file
37
.gitlab/docker/ZammadTestB-1.0.0.yaml
Normal 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
|
||||
28
.gitlab/docker/ZammadTestB-1.0.0.zpm
Normal file
28
.gitlab/docker/ZammadTestB-1.0.0.zpm
Normal 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
41
.gitlab/docker/yaml_to_zpm.rb
Executable 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
|
||||
24
Dockerfile
24
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,3 @@ Locale.sync
|
||||
Translation.sync
|
||||
|
||||
Calendar.init_setup
|
||||
|
||||
# install all packages in auto_install
|
||||
Package.auto_install
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
15
lib/tasks/zammad/graphql_introspection.rake
Normal file
15
lib/tasks/zammad/graphql_introspection.rake
Normal 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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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'
|
||||
|
||||
4
lib/tasks/zammad/package/install_dir.rake
Normal file
4
lib/tasks/zammad/package/install_dir.rake
Normal 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
|
||||
38
lib/tasks/zammad/package/install_dir.rb
Normal file
38
lib/tasks/zammad/package/install_dir.rb
Normal 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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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})..."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}'..."
|
||||
|
||||
|
||||
4
lib/tasks/zammad/package/uninstall_dir.rake
Normal file
4
lib/tasks/zammad/package/uninstall_dir.rake
Normal 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
|
||||
37
lib/tasks/zammad/package/uninstall_dir.rb
Normal file
37
lib/tasks/zammad/package/uninstall_dir.rb
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
12
lib/zammad/deployment.rb
Normal 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
|
||||
59
script/build/unpack_addon_packages.rb
Executable file
59
script/build/unpack_addon_packages.rb
Executable 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.'
|
||||
65
spec/lib/tasks/zammad/package/uninstall_dir_spec.rb
Normal file
65
spec/lib/tasks/zammad/package/uninstall_dir_spec.rb
Normal 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
|
||||
37
spec/lib/zammad/deployment_spec.rb
Normal file
37
spec/lib/zammad/deployment_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user