diff --git a/e2e/playground.spec.ts b/e2e/playground.spec.ts index 3e884ff0..6244bb55 100644 --- a/e2e/playground.spec.ts +++ b/e2e/playground.spec.ts @@ -42,6 +42,33 @@ test.describe('Playground', () => { await expect(page.locator('select[data-field-key="notediscovery.defaultTheme"]')).toHaveValue('light'); }); + test('mongodb arbiter output stays within supported topology combinations', async ({ page }) => { + await page.goto('/playground'); + await page.locator('.playground-chart-btn[data-slug="mongodb"]').click(); + + const architecture = page.locator('select[data-field-key="architecture"]'); + const members = page.locator('input[data-field-key="replicaSet.members"]'); + const arbiter = page.locator('button[data-field-key="arbiter.enabled"]'); + const code = page.locator('#playground-code'); + + await arbiter.click(); + await expect(architecture).toHaveValue('replicaset'); + await expect(members).toHaveValue('2'); + await expect(code).toContainText('architecture=replicaset'); + await expect(code).toContainText('replicaSet.members=2'); + await expect(code).toContainText('arbiter.enabled=true'); + + await members.fill('3'); + await expect(code).not.toContainText('arbiter.enabled'); + + await arbiter.click(); + await architecture.selectOption('standalone'); + await expect(code).not.toContainText('arbiter.enabled'); + + await page.goto('/playground?chart=mongodb&architecture=replicaset&replicaSet.members=3&arbiter.enabled=true'); + await expect(page.locator('#playground-code')).not.toContainText('arbiter.enabled'); + }); + test('ntfy defaults stay aligned with the chart', async ({ page }) => { await page.goto('/playground'); await page.locator('.playground-chart-btn[data-slug="ntfy"]').click(); diff --git a/src/data/playground-configs.ts b/src/data/playground-configs.ts index 359cc2bd..1d6c489e 100644 --- a/src/data/playground-configs.ts +++ b/src/data/playground-configs.ts @@ -4928,6 +4928,13 @@ export const chartConfigs: Record = { default: '3', description: 'Data-bearing members in replica set mode', }, + { + label: 'Arbiter', + key: 'arbiter.enabled', + type: 'toggle', + default: 'false', + description: 'Add one voting arbiter; requires 2, 4, or 6 data-bearing members', + }, { label: 'Storage Size', key: 'persistence.size', diff --git a/src/pages/docs/charts/mongodb.mdx b/src/pages/docs/charts/mongodb.mdx index 8882fe1c..553133fa 100644 --- a/src/pages/docs/charts/mongodb.mdx +++ b/src/pages/docs/charts/mongodb.mdx @@ -168,7 +168,10 @@ persistence: An arbiter participates in elections but holds no data. Use it when you have an even number of data-bearing members to - ensure a majority for elections. With 3 data members, an arbiter is not needed. + ensure a majority for elections. MongoDB recommends three data-bearing members when capacity permits because an + arbiter does not add data redundancy and can affect majority write availability. The chart supports one arbiter with + 2, 4, or 6 data-bearing members. See the [official arbiter + considerations](https://www.mongodb.com/docs/manual/core/replica-set-arbiter/). @@ -241,12 +244,13 @@ config: ### Architecture -| Parameter | Type | Default | Description | -| -------------------- | ------- | ------------ | -------------------------------------------------------- | -| `architecture` | string | `standalone` | `standalone`, `replicaset`, or `sharded`. | -| `replicaSet.name` | string | `rs0` | Replica set name. | -| `replicaSet.members` | integer | `3` | Number of data-bearing replica set members. | -| `arbiter.enabled` | boolean | `false` | Add an arbiter pod (votes in elections, stores no data). | +| Parameter | Type | Default | Description | +| -------------------- | ------- | ------------ | ---------------------------------------------------------------- | +| `architecture` | string | `standalone` | `standalone`, `replicaset`, or `sharded`. | +| `replicaSet.name` | string | `rs0` | Replica set name. | +| `replicaSet.members` | integer | `3` | Number of data-bearing replica set members. | +| `arbiter.enabled` | boolean | `false` | Add one non-data-bearing voting member in replica set mode. | +| `arbiter.resources` | object | `{}` | CPU and memory requests/limits for the arbiter mongod container. | ### Sharded Cluster @@ -340,8 +344,14 @@ MongoDB administrative commands instead of changing chart values. 2. Deploy the chart in `replicaset` mode. 3. Restore data with `mongorestore`. -Changing `replicaSet.members` on an existing replica set triggers a reconfiguration. -Scaling down removes members — ensure no data is exclusively on the removed members. +Enabling or disabling `arbiter.enabled` reconciles the arbiter through the post-upgrade hook. Increasing +`replicaSet.members` adds missing members. Reducing it does not remove data-bearing members from MongoDB; follow MongoDB's +member-removal procedure before scaling down the StatefulSet. + +When arbiter reconciliation would change MongoDB's implicit default write concern, the hook preserves the currently +effective value (`w: 1` or `majority`) by promoting it to a global default. Existing operator-managed global defaults are +left unchanged. MongoDB 5.0 and newer do not allow a global default write concern to be unset after it is established. See +the official [`setDefaultRWConcern` documentation](https://www.mongodb.com/docs/manual/reference/command/setDefaultRWConcern/). diff --git a/src/scripts/playground.ts b/src/scripts/playground.ts index fed59bc4..9aaa1562 100644 --- a/src/scripts/playground.ts +++ b/src/scripts/playground.ts @@ -81,6 +81,7 @@ const coupledRuleKeysBySlug: Record = { }; const giteaPostgresqlPassword = 'change-me-gitea-postgresql'; +const mongodbArbiterMemberCounts = new Set(['2', '4', '6']); function getGroups(slug: string): GroupConfig[] { return configs[slug] ?? configs['_default'] ?? []; @@ -232,7 +233,7 @@ function autoEnableField(key: string) { function setControlValue(key: string, value: string) { currentValues[key] = value; const control = controlsEl?.querySelector( - `[data-field-key="${key}"]`, + `input[data-field-key="${key}"], select[data-field-key="${key}"], button[data-field-key="${key}"]`, ); if (!control) return; @@ -259,6 +260,22 @@ function clearGiteaExternalDatabaseDetectionFields() { } function applyFieldSideEffects(key: string, value: string) { + if (selectedSlug === 'mongodb') { + if (key === 'arbiter.enabled' && value === 'true') { + setControlValue('architecture', 'replicaset'); + if (!mongodbArbiterMemberCounts.has(currentValues['replicaSet.members'])) { + setControlValue('replicaSet.members', '2'); + } + } else if ( + currentValues['arbiter.enabled'] === 'true' && + ((key === 'architecture' && value !== 'replicaset') || + (key === 'replicaSet.members' && !mongodbArbiterMemberCounts.has(value))) + ) { + updateToggleField('arbiter.enabled', false); + } + return; + } + if (selectedSlug !== 'gitea') return; if (key === 'database.mode' && value === 'postgresql') { @@ -607,6 +624,7 @@ function buildFieldControl(field: FieldConfig): HTMLElement { input.addEventListener('input', () => { setFieldValue(field.key, input.value); if (field.enables) autoEnableField(field.enables); + applyFieldSideEffects(field.key, input.value); updateOutput(); }); controlDiv.appendChild(input); @@ -997,6 +1015,14 @@ function setTreeValue(tree: Record, key: string, value: boolean | n function updateOutput() { if (!codeEl || !selectedSlug) return; + if ( + selectedSlug === 'mongodb' && + currentValues['arbiter.enabled'] === 'true' && + (currentValues['architecture'] !== 'replicaset' || + !mongodbArbiterMemberCounts.has(currentValues['replicaSet.members'])) + ) { + updateToggleField('arbiter.enabled', false); + } if (copyBtn) copyBtn.disabled = false; if (shareBtn) shareBtn.disabled = false;