mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 {} }}"
|
||||
|
||||
Reference in new issue
Block a user