feat(xwiki): store attachments in the SeaweedFS bucket

XWiki 18.x keeps attachment content in a blob store whose default is
filesystem; s3 arrives as an extension. tasks/04_extensions.yml installs
org.xwiki.commons:xwiki-commons-store-blob-s3 -- commons, not platform -- at
the bare release of services.xwiki.version, because the artefact ships one
build per platform release and its pom declares the parent at the same
version.

The extension is installed on the root namespace. Its pom pins
xwiki.extension.namespaces to {root}, DefaultNamespaceValidator accepts that
token only for a null namespace, and createInstallPlanRequest attaches a
namespace to the request only when StringUtils.isNotBlank holds -- so the
empty string is what reaches InstallPlanJob as a farm-wide install, and the
installer keeps distinguishing an absent key from an empty one.

xwiki.properties is rendered twice: once during bootstrap with the S3 block
suppressed, once after the extension exists. The bootstrap render must leave
store.blobStoreType unset, because DefaultBlobStoreManager resolves the
factory by that hint and nothing answers to 's3' before the extension is
installed.

That double render is why the properties volume carries an explicit name.
compose_volumes derives a content hash for config and secret objects, but
_read_file_for_hash falls back to the source path whenever the controller
cannot read the rendered file, which is the case on a swarm DinD node. A
constant name with changing content then fails the second stack deploy with
'only updates to Labels are allowed'. 9bf35d672a introduced the rotation for
the OIDC phase; the blob-store switch is a second phase, so the token now
covers both and the name changes exactly when the content does.

The volume also becomes a secret rather than a config: the template now
renders store.s3.secretKey, and a credential in a non-secret volume is what
tests/lint/ansible/services/test_config_volume_secrets.py exists to refuse.

Two pre-existing defects fall out: the properties config could never converge
on swarm with SSO off, and addons/ai-llm.spec.js asserted
XWIKI_SUPERADMIN_USERNAME/_PASSWORD that playwright.env.j2 never emitted.

An inventory generated before this change carries no objstore_secret_key for
the role, which renders an empty secret and 403s at the first attachment write
without failing the deploy. Regenerate the inventory before deploying this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-01 05:06:10 +02:00
1 parent ab399443c8
commit 26d588fa41
11 files changed
+151 -5

No files matched your search

@@ -3,7 +3,7 @@ import groovy.json.JsonSlurper
import java.util.Base64
def ext = services.extension
def ns = "wiki:xwiki"
def defaultNs = "wiki:xwiki"
def b64 = '__WANTED_B64__'
def json = new String(Base64.decoder.decode(b64), 'UTF-8')
@@ -17,6 +17,7 @@ if (!wanted || wanted.isEmpty()) {
wanted.each { e ->
def id = (e.id ?: "").toString()
def ver = (e.version ?: "").toString().trim()
def ns = (e.namespace == null) ? defaultNs : e.namespace.toString()
if (!id) { println "ERROR::<missing-id>::Empty extension id in wanted list"; return }
def already = ext.getInstalledExtension(id, ns)
@@ -10,6 +10,9 @@ const {
runBiberFlow,
runGuestFlow,
} = require("./personas");
require("./test-seaweedfs");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
@@ -0,0 +1,90 @@
// SeaweedFS object-store scenario for XWiki.
//
// XWiki 18.7 keeps attachment content in a blob store. templates/xwiki.properties.j2 switches
// that store to S3 (store.blobStoreType=s3 plus store.s3.* from the `objstore` lookup, which
// resolves to SeaweedFS when seaweedfs is the enabled engine) once the "S3 Blob Store"
// extension (org.xwiki.commons:xwiki-commons-store-blob-s3) is installed on the root namespace
// by tasks/04_extensions.yml. With that store active, attaching a file to a wiki page writes
// the attachment content into the consumer bucket as a new object.
//
// The action attaches through XWiki's own REST attachment endpoint as the built-in superadmin:
// templates/playwright.env.j2 declares both personas blocked (the administrator page carries no
// password property and the deployment authenticates every privileged call as superadmin), so
// superadmin is the only credential that can drive a write into the attachment store.
//
// Required env (rendered by templates/playwright.env.j2):
// APP_BASE_URL, XWIKI_SUPERADMIN_USERNAME, XWIKI_SUPERADMIN_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 { skipUnlessServiceEnabled } = require("./service-gating");
const { normalizeBaseUrl, requireDotenvValue, runSeaweedfsStorageCheck } = require("./personas");
test.use({ ignoreHTTPSErrors: true });
test("seaweedfs: an attachment added to an XWiki page is stored in the SeaweedFS bucket", async ({ page, browser }) => {
test.skip(isOnionTarget(), "SeaweedFS S3 API is not a Tor surface on an onion node (headless backend)");
skipUnlessServiceEnabled("seaweedfs");
test.setTimeout(resolveTimeout(300_000));
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const superadminUsername = requireDotenvValue(
process.env.XWIKI_SUPERADMIN_USERNAME,
"XWIKI_SUPERADMIN_USERNAME",
);
const superadminPassword = requireDotenvValue(
process.env.XWIKI_SUPERADMIN_PASSWORD,
"XWIKI_SUPERADMIN_PASSWORD",
);
const authorization = `Basic ${Buffer.from(`${superadminUsername}:${superadminPassword}`).toString("base64")}`;
await runSeaweedfsStorageCheck(page, browser, {
label: "an XWiki page attachment upload",
action: async (appPage) => {
const base = appBaseUrl.replace(/\/$/, "");
const marker = `infinito-storage-check-${Date.now()}-${randomUUID()}.txt`;
const pageUrl = `${base}/rest/wikis/xwiki/spaces/Main/pages/WebHome`;
const session = await appPage.request.get(`${base}/rest/wikis/xwiki/spaces`, {
headers: { Authorization: authorization },
timeout: resolveTimeout(60_000),
});
expect(
session.status(),
`XWiki refused the superadmin REST session with HTTP ${session.status()}; without it no ` +
"form token exists and the attachment write cannot be attempted",
).toBe(200);
const formToken = session.headers()["xwiki-form-token"];
const upload = await appPage.request.put(`${pageUrl}/attachments/${marker}`, {
headers: {
Authorization: authorization,
"Content-Type": "application/octet-stream",
...(formToken ? { "XWiki-Form-Token": formToken } : {}),
},
data: Buffer.from(`infinito storage check ${marker}`),
timeout: resolveTimeout(120_000),
});
expect(
upload.status(),
`XWiki rejected the attachment upload with HTTP ${upload.status()}; the wiki write failed ` +
"rather than the object store",
).toBeLessThan(300);
const listing = await appPage.request.get(`${pageUrl}/attachments`, {
headers: { Authorization: authorization, Accept: "application/json" },
timeout: resolveTimeout(60_000),
});
expect(
listing.status(),
`XWiki did not return the attachment listing for Main.WebHome (HTTP ${listing.status()})`,
).toBeLessThan(300);
expect(
await listing.text(),
`XWiki reported the upload of '${marker}' as accepted but does not list it on Main.WebHome`,
).toContain(marker);
},
});
});
+4 -1
View File
@@ -4,7 +4,10 @@ litellm:
bond: 0.5
enabled: "{{ LITELLM_USABLE }}"
shared: true
# nocheck: seaweedfs-required — object storage not implemented for this role
seaweedfs:
bond: 1
enabled: "{{ 'web-svc-seaweedfs' in group_names }}"
shared: "{{ 'web-svc-seaweedfs' in group_names }}"
sso:
bond: 1
enabled: "{{ 'web-app-keycloak' in group_names }}"
+9
View File
@@ -21,6 +21,9 @@
matomo:
enabled: true
shared: true
seaweedfs:
enabled: true
shared: true
sso:
enabled: true
shared: true
@@ -54,6 +57,9 @@
matomo:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
seaweedfs:
enabled: false
shared: false
sso:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
@@ -75,6 +81,9 @@
container_backup:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
seaweedfs:
enabled: false
shared: false
sso:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
+2 -2
View File
@@ -7,8 +7,8 @@ data:
target: /usr/local/xwiki
properties:
type: config
name: "xwiki_properties_{{ 'oidc' if xwiki_oidc_enabled_switch | default(false) | bool else 'std' }}"
type: secret
name: "xwiki_properties_{{ 'oidc' if xwiki_oidc_enabled_switch | default(false) | bool else 'std' }}{{ '_s3' if xwiki_blobstore_s3_enabled_switch | default(false) | bool else '' }}"
source: "{{ [lookup('container', 'web-app-xwiki', 'directories.config'), 'xwiki.properties'] | path_join }}"
mode: "0444"
mounts:
+1
View File
@@ -27,6 +27,7 @@
vars:
xwiki_oidc_enabled_switch: false
xwiki_ldap_enabled_switch: false
xwiki_blobstore_s3_enabled_switch: false
- name: "👔 XWiki manager-side operations (file deploys + container exec)"
include_tasks: 02_manager_ops.yml
@@ -71,3 +71,4 @@
vars:
xwiki_oidc_enabled_switch: "{{ XWIKI_OIDC_ENABLED | bool }}"
xwiki_ldap_enabled_switch: "{{ XWIKI_LDAP_ENABLED | bool }}"
xwiki_blobstore_s3_enabled_switch: "{{ XWIKI_OBJSTORE_ENABLED | bool }}"
@@ -37,6 +37,22 @@ PERSONA_ADMINISTRATOR_BLOCKED=true
# nocheck: persona-block-rationale — see PERSONA_ADMINISTRATOR_BLOCKED above; biber has no XWiki user page at all (tasks/03_administrator.yml provisions the administrator only) and the OIDC group mapping stays commented out (templates/xwiki.properties.j2:23-25), so an auto-provisioned biber would land without rights.
PERSONA_BIBER_BLOCKED=true
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={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
XWIKI_SUPERADMIN_PASSWORD={{ XWIKI_SUPERADMIN_PASSWORD | dotenv_quote }}
XWIKI_SUPERADMIN_USERNAME={{ XWIKI_SUPERADMIN_USERNAME | dotenv_quote }}
{{ lookup('addon_env_flags', application_id) }}
@@ -70,6 +70,16 @@ logging.logger.org.apache.http.wire=DEBUG # very verbose, raw request/response
logging.logger.org.pac4j=DEBUG
{% endif %}
{% if xwiki_blobstore_s3_enabled_switch | bool %}
store.blobStoreType=s3
store.s3.bucketName={{ XWIKI_OBJSTORE_BUCKET }}
store.s3.region={{ XWIKI_OBJSTORE_REGION }}
store.s3.accessKey={{ XWIKI_OBJSTORE_ACCESS_KEY }}
store.s3.secretKey={{ XWIKI_OBJSTORE_SECRET_KEY }}
store.s3.endpoint={{ XWIKI_OBJSTORE_URL }}
store.s3.pathStyleAccess=true
{% endif %}
# Persist data in the Docker volume
environment.permanentDirectory={{ XWIKI_DOCK_DATA_DIR }}
+13 -1
View File
@@ -54,4 +54,16 @@ XWIKI_OIDC_SCOPES: "openid,email,profile,{{ RBAC.GROUP.CLAIM }}"
XWIKI_OIDC_GROUPS_CLAIM: "{{ RBAC.GROUP.CLAIM }}"
XWIKI_OIDC_ADMIN_PROVIDER_GROUP: "{{ lookup('rbac_group_path', application_id=application_id, role='administrator') }}"
XWIKI_PLUGINS: "{{ lookup('config', application_id, 'addons') | xwiki_enabled_install_items }}"
XWIKI_OBJSTORE_ENABLED: "{{ lookup('objstore', application_id, 'enabled') }}"
XWIKI_OBJSTORE_BUCKET: "{{ lookup('objstore', application_id, 'bucket') }}"
XWIKI_OBJSTORE_ACCESS_KEY: "{{ lookup('objstore', application_id, 'access_key') }}"
XWIKI_OBJSTORE_SECRET_KEY: "{{ lookup('objstore', application_id, 'secret_key') }}"
XWIKI_OBJSTORE_URL: "{{ lookup('objstore', application_id, 'url') }}"
XWIKI_OBJSTORE_REGION: "{{ lookup('objstore', application_id, 'region') }}"
XWIKI_BLOBSTORE_EXTENSION_VERSION: "{{ XWIKI_VERSION | regex_replace('-.*$', '') }}"
XWIKI_BLOBSTORE_EXTENSIONS: "{{ [{'id': 'org.xwiki.commons:xwiki-commons-store-blob-s3', 'version': XWIKI_BLOBSTORE_EXTENSION_VERSION, 'namespace': ''}] if (XWIKI_OBJSTORE_ENABLED
| bool) else [] }}"
XWIKI_PLUGINS: "{{ (lookup('config', application_id, 'addons') | xwiki_enabled_install_items) + XWIKI_BLOBSTORE_EXTENSIONS }}"