feat(zammad): store attachments in the SeaweedFS bucket

Zammad 7.1.2 ships app/models/store/provider/s3.rb and pins aws-sdk-s3 as a
Gemfile.lock dependency, so the provider is in the image. It parses bucket,
endpoint and credentials out of a single S3_URL, and passes every remaining
query parameter into Aws.config.update, which is how region and
force_path_style travel.

The access key and secret are percent-encoded including the slash. Zammad reads
them from the URL's userinfo, and a raw slash moves the rest of the credential
into the path: URI.parse then yields host "zammad" with no user at all, so the
deploy would silently address the wrong host and bucket.

storage_provider is a database Setting seeded to DB, not an env var, so a rails
runner flips it. Its validator heads the bucket while the value is written,
which makes a wrong URL fail the deploy instead of quietly writing to disk, and
is why the role now waits for web-svc-seaweedfs.

The scenario's attachment carries a per-run marker in its bytes rather than only
in its filename: Zammad stores an attachment under the sha256 of its content, so
byte-identical uploads reuse the existing object and no new key would appear.

seedTicketViaApi moves into the role's shared helper so the new scenario and the
agent-reply one use one copy. The POST payload is unchanged field for field and
still resolves its base URL and credentials from the same three env values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-01 02:37:32 +02:00
1 parent 64df7e1188
commit 7b5833b37a
11 files changed
+174 -39

No files matched your search

@@ -1,4 +1,4 @@
const { expect } = require("@playwright/test");
const { expect, request } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl, performKeycloakLoginForm, runGuestFlow } = require("./personas");
@@ -24,6 +24,44 @@ async function zammadLogout(page) {
await page.context().clearCookies();
}
/**
* Seed a ticket over the Basic-auth REST API.
*
* @param {string} subject ticket title and article subject.
* @param {string} body article body.
* @returns {Promise<object>} the ticket as Zammad returned it.
*/
async function seedTicketViaApi(subject, body) {
const api = await request.newContext({
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Basic ${Buffer.from(`${adminApiUsername}:${adminApiPassword}`).toString("base64")}`,
"Content-Type": "application/json",
},
});
const resp = await api.post(`${zammadBaseUrl}/api/v1/tickets`, {
data: {
title: subject,
group: "Users",
customer: adminApiUsername,
article: {
subject,
body,
type: "note",
internal: false,
},
},
});
if (resp.status() >= 300) {
throw new Error(`Seed POST /api/v1/tickets failed: ${resp.status()} ${await resp.text()}`);
}
const ticket = await resp.json();
await api.dispose();
return ticket;
}
async function signInAsApiBot(page) {
// page.context().request shares the browser cookie jar; in-page fetch hits Keycloak cross-origin in OIDC variants.
await page.context().clearCookies();
@@ -110,6 +148,7 @@ module.exports = {
biberPassword,
canonicalDomain,
},
seedTicketViaApi,
signInViaZammadOidc,
signInAsApiBot,
zammadLogout,
@@ -19,4 +19,5 @@ require("./test-rest-api-ticket-create").register(shared);
require("./test-ticket-create-as-customer").register(shared);
require("./test-ticket-reply-as-agent").register(shared);
require("./test-websocket-realtime").register(shared);
require("./test-seaweedfs").register(shared);
require("./test-ai-gateway").register(shared);
@@ -0,0 +1,74 @@
// SeaweedFS object-store scenario for Zammad.
//
// Required env (rendered by templates/playwright.env.j2):
// ZAMMAD_BASE_URL, ADMIN_API_USERNAME, ADMIN_API_PASSWORD and the SEAWEEDFS_*
// keys consumed by runSeaweedfsStorageCheck.
const { randomUUID } = require("node:crypto");
const { test, expect } = require("@playwright/test");
const { resolveTimeout, isOnionTarget } = require("./timeouts");
const { gotoOnion, runSeaweedfsStorageCheck } = require("./personas");
exports.register = function (shared) {
test("seaweedfs: a Zammad ticket attachment is stored in the SeaweedFS bucket", async ({ page, browser }) => {
test.skip(isOnionTarget(), "the SeaweedFS S3 API is not a Tor surface on an onion node (headless backend)");
shared.skipUnlessServiceEnabled("seaweedfs");
expect(shared.env.adminApiUsername, "ADMIN_API_USERNAME must be set").toBeTruthy();
expect(shared.env.adminApiPassword, "ADMIN_API_PASSWORD must be set").toBeTruthy();
test.setTimeout(resolveTimeout(300_000));
await runSeaweedfsStorageCheck(page, browser, {
label: "a Zammad ticket attachment upload",
action: async (appPage) => {
const subject = `playwright-seaweedfs-${Date.now()}`;
const ticket = await shared.seedTicketViaApi(
subject,
"Seed article for the SeaweedFS storage-check scenario."
);
await shared.signInAsApiBot(appPage);
await gotoOnion(appPage, `${shared.env.zammadBaseUrl}/#ticket/zoom/${ticket.id}`, { waitUntil: "domcontentloaded" });
await expect(appPage.locator("body")).toContainText(subject, { timeout: resolveTimeout(60_000) });
await appPage
.locator('.article-add [contenteditable="true"]')
.first()
.waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
const fileInput = appPage
.locator('.article-add input[type="file"]')
.or(appPage.locator('input[type="file"]'))
.first();
await expect(
fileInput,
"the Zammad ticket zoom must expose a file input to attach a file to the reply",
).toBeAttached({ timeout: resolveTimeout(60_000) });
const upload = appPage.waitForResponse(
(response) =>
/\/api\/v1\/upload_caches\//.test(response.url()) &&
response.request().method() === "POST",
{ timeout: resolveTimeout(120_000) },
);
const marker = `infinito-storage-check-${Date.now()}-${randomUUID()}`;
await fileInput.setInputFiles({
name: `${marker}.txt`,
mimeType: "text/plain",
buffer: Buffer.from(`Infinito.Nexus SeaweedFS storage check ${marker}\n`, "utf8"),
});
const response = await upload.catch(() => null);
expect(
response,
"Zammad never POSTed the attachment to POST /api/v1/upload_caches/:form_id, so nothing could reach " +
"the bucket; the ticket-zoom reply form did not accept the file rather than the object store failing",
).not.toBeNull();
expect(
response.status(),
`Zammad rejected the attachment upload with HTTP ${response.status()}`,
).toBeLessThan(300);
},
});
});
};
@@ -1,49 +1,16 @@
const { test, expect, request } = require("@playwright/test");
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { gotoOnion } = require("./personas");
async function seedTicketViaApi(baseUrl, adminApiUsername, adminApiPassword, subject) {
const api = await request.newContext({
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Basic ${Buffer.from(`${adminApiUsername}:${adminApiPassword}`).toString("base64")}`,
"Content-Type": "application/json",
},
});
const resp = await api.post(`${baseUrl}/api/v1/tickets`, {
data: {
title: subject,
group: "Users",
customer: adminApiUsername,
article: {
subject,
body: "Seed article for the agent-reply Playwright scenario.",
type: "note",
internal: false,
},
},
});
if (resp.status() >= 300) {
throw new Error(`Seed POST /api/v1/tickets failed: ${resp.status()} ${await resp.text()}`);
}
const ticket = await resp.json();
await api.dispose();
return ticket;
}
exports.register = function (shared) {
test("administrator (agent): replies to an API-seeded ticket via the SPA", async ({ page }) => {
expect(shared.env.adminApiUsername, "ADMIN_API_USERNAME must be set").toBeTruthy();
expect(shared.env.adminApiPassword, "ADMIN_API_PASSWORD must be set").toBeTruthy();
const subject = `playwright-agent-reply-${Date.now()}`;
const ticket = await seedTicketViaApi(
shared.env.zammadBaseUrl,
shared.env.adminApiUsername,
shared.env.adminApiPassword,
subject
const ticket = await shared.seedTicketViaApi(
subject,
"Seed article for the agent-reply Playwright scenario."
);
await shared.signInAsApiBot(page);
@@ -0,0 +1,10 @@
# nocheck: mirrored-unit-test - runs in the Zammad console and writes through Setting,
# whose validator heads the bucket on save; the round trip against the live object
# store is the entire contract
UserInfo.current_user_id = 1
Setting.set("storage_provider", "S3")
stored = Setting.get("storage_provider")
raise "storage_provider is #{stored.inspect}, expected \"S3\"" if stored != "S3"
+5 -1
View File
@@ -4,7 +4,6 @@ litellm:
bond: 0.5
enabled: "{{ LITELLM_USABLE }}"
shared: true
# nocheck: seaweedfs-required - object storage not implemented for this role
sso:
bond: 1
enabled: "{{ 'web-app-keycloak' in group_names }}"
@@ -34,6 +33,10 @@ prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
shared: "{{ 'web-app-prometheus' in group_names }}"
seaweedfs:
bond: 1
enabled: "{{ 'web-svc-seaweedfs' in group_names }}"
shared: "{{ 'web-svc-seaweedfs' in group_names }}"
# nocheck: playwright-service-flag - css consumer, no Playwright surface (provider: web-svc-css)
css:
bond: 1
@@ -86,6 +89,7 @@ zammad:
run_after:
- web-app-keycloak
- web-app-mailu
- web-svc-seaweedfs
lifecycle: beta
cpus: "1.0"
mem_reservation: 1g
+9
View File
@@ -36,6 +36,9 @@
shared: true
redis:
shared: true
seaweedfs:
enabled: true
shared: true
litellm:
enabled: false
tor:
@@ -83,6 +86,9 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
redis:
shared: false # nocheck: variants-static-override backup shared stays literally true
seaweedfs:
enabled: false
shared: false
litellm:
enabled: false
tor:
@@ -133,6 +139,9 @@
shared: false # nocheck: variants-static-override backup shared stays literally true
redis:
shared: true
seaweedfs:
enabled: false
shared: false
tor:
enabled: true
openwebui:
@@ -41,6 +41,18 @@
register: zammad_auto_wizard_setup
changed_when: false
- name: "🗄️ point Zammad's attachment store at the S3 bucket"
ansible.builtin.shell:
cmd: |
set -euo pipefail
container exec -i \
{{ ZAMMAD_RAILS_CONTAINER_ADDRESS }} \
bundle exec rails r -
stdin: "{{ lookup('file', 'ruby/apply/storage_provider.rb') }}"
register: zammad_storage_provider_apply
changed_when: false
when: ZAMMAD_OBJSTORE_ENABLED | bool
- name: "✅ ensure API-bot user in Zammad (Basic-auth REST API; all variants, no-auth too)"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
ansible.builtin.shell:
+6
View File
@@ -11,6 +11,12 @@ REDIS_URL={{ lookup('engine', 'redis', application_id, 'url') }}
MEMCACHE_SERVERS={{ lookup('engine', 'memcached', application_id, 'host') }}:{{ lookup('engine', 'memcached', application_id, 'port') }}
{% set objstore = lookup('objstore', application_id) %}
{% if objstore.enabled | bool %}
{% set objstore_userinfo = (objstore.access_key | urlencode | replace('/', '%2F')) ~ ':' ~ (objstore.secret_key | urlencode | replace('/', '%2F')) %}
S3_URL={{ ((objstore.url | replace('://', '://' ~ objstore_userinfo ~ '@')) ~ '/' ~ objstore.bucket ~ '?region=' ~ objstore.region ~ '&force_path_style=true') | dotenv_quote }}
{% endif %}
ELASTICSEARCH_HOST={{ ZAMMAD_SEARCH_SERVICE }}
ELASTICSEARCH_PORT={{ ZAMMAD_SEARCH_PORT }}
ELASTICSEARCH_SCHEMA=http
@@ -32,6 +32,18 @@ MCP_SERVICE_ENABLED={{ lookup('config', application_id, 'mcp.enabled') | string
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
SEAWEEDFS_APP_ACCESS_KEY={{ lookup('objstore', application_id, 'access_key') | dotenv_quote }}
SEAWEEDFS_APP_BUCKET={{ lookup('objstore', application_id, 'bucket') | dotenv_quote }}
SEAWEEDFS_APP_SECRET_KEY={{ lookup('objstore', application_id, 'secret_key') | dotenv_quote }}
SEAWEEDFS_REGION={{ lookup('config', 'web-svc-seaweedfs', 'services.seaweedfs.region') }}
SEAWEEDFS_S3_URL={{ lookup('canonical_url', 'web-svc-seaweedfs', 'api', consumer=application_id) | dotenv_quote }}
SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedfs.enabled') | string | lower }}
SSO_SERVICE_ENABLED={{ ZAMMAD_OIDC_ENABLED | string | lower }}
ZAMMAD_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
+1
View File
@@ -57,6 +57,7 @@ ZAMMAD_OIDC_GROUP_ROLE_MAP:
ZAMMAD_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') | bool }}"
ZAMMAD_EMAIL_ENABLED: "{{ lookup('config', application_id, 'services.email.enabled') | bool }}"
ZAMMAD_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
ZAMMAD_OBJSTORE_ENABLED: "{{ lookup('objstore', application_id, 'enabled') | bool }}"
ZAMMAD_MAIL: "{{ lookup('email', application_id) if ZAMMAD_EMAIL_ENABLED else {} }}"
ZAMMAD_HELPDESK_USER: "{{ lookup('users', 'helpdesk') if ZAMMAD_EMAIL_ENABLED else {} }}"