feat(moodle): point the AI subsystem at the platform gateway

Moodle 4.5 ships aiprovider_openai in core, where every action carries its own
endpoint rather than one shared base URL, so the provider is configured per
action. aiprovider_openaicompatible would be the natural fit but requires
Moodle 5.0, which the role does not pin, so the per-action endpoints are the
only surface available on this branch. MOODLE_LITELLM_SETTINGS carries that as
a list of component/name/value triples and also enables the courseassist and
editor placements: a configured provider with no enabled placement exposes no
AI surface to any user, so both halves are required for the feature to exist.
An earlier revision set a global `enableai`, which does not exist in 4.5.

tasks/utils/ai_gateway.yml verifies the shared gateway contract, writes each
setting through admin/cli/cfg.php, then purges caches. Without the purge Moodle
keeps answering from its config cache and the new endpoint appears to have been
ignored.

The egress rule is the half that is easy to miss. Moodle's cURL security helper
blocks private address ranges outright, so a correctly configured in-cluster
endpoint is still refused, and it surfaces as a generic provider error rather
than as a blocked request. 01_manager_ops.yml therefore writes
curlsecurityblockedhosts and curlsecurityallowedport, appending the gateway's
port only while litellm is enabled. This deliberately narrows a security
control: 172.16/12 and 192.168/16 become reachable so the container network
resolves, while 10.0.0.0/8, link-local and loopback stay blocked. The README
records the trade-off.

MOODLE_EGRESS_NEWLINE is double-quoted on purpose. Moodle stores both settings
one entry per line; a single-quoted YAML scalar hands Jinja a literal
backslash-n, the whole list collapses into one unparseable token, and the net
effect is that every port is blocked rather than the intended two. Verified in
the container with negative controls: keycloak and litellm reachable,
10.208.0.5 and 127.0.0.1 still refused.

The spec opens a site-administrator session -- through Keycloak when sso is on,
because 04_oidc.yml rewrites every account to auth: oidc and no password
reaches it then, and through the native form otherwise -- asserts the endpoint
is http on an undotted host that is not Moodle's own domain, then calls
aiplacement_editor_generate_text and requires a non-empty completion. That last
step is a prompt actually answered through the gateway, not a config read-back.

Verified: the deploy-time read-back assertion ran ok in a variant-2 round and
the spec executed rather than skipping. The egress newline defect and its fix
were both measured in-container.
Not verified: I confirmed the spec ran but not that its final assertion passed,
and the cfg.php loop is changed_when: false, so the deploy log cannot show
whether a setting actually moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-24 13:21:00 +02:00
1 parent 19407bad1f
commit b23653adf8
10 files changed
+308 -1

No files matched your search

+11
View File
@@ -15,6 +15,7 @@ The diagram places Moodle in the Infinito.Nexus cosmos: the components it deploy
```mermaid
flowchart LR
subgraph deps [Dependencies]
dep_svc_ai_litellm["svc-ai-litellm 🐳🐝"]
dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local 💻"]
dep_svc_db_mariadb["svc-db-mariadb 🐳🐝"]
dep_svc_db_openldap["svc-db-openldap 🐳🐝"]
@@ -27,6 +28,7 @@ flowchart LR
dep_web_svc_logout["web-svc-logout 🐳🐝"]
end
subgraph role [web-app-moodle 🐳🐝]
svc_litellm["litellm"]
svc_sso["sso"]
svc_logout["logout"]
svc_dashboard["dashboard"]
@@ -41,6 +43,7 @@ flowchart LR
svc_prometheus["prometheus"]
svc_container_backup["container_backup"]
end
dep_svc_ai_litellm -. "0..1" .-> svc_litellm
dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup
dep_svc_db_mariadb -. "0..1" .-> svc_mariadb
dep_svc_db_openldap -. "0..1" .-> svc_ldap
@@ -146,6 +149,14 @@ Moodle compares every request against `$CFG->wwwroot`. A probe that reaches the
Remove the MCP client roles, or pin `mcp.enabled: false` for this role. The web-service token is then not issued and the protocol plugin stays unconfigured.
## Outbound HTTP policy
Moodle ships a cURL blocklist that rejects every request to `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`, and only permits ports 80 and 443. Both settings gate `\core\files\curl_security_helper`, which `auth_oidc` consults for the Keycloak token exchange and `\core\http_client` consults for the AI provider request. Keycloak answers on `172.16.0.0/12` and the gateway on `192.168.0.0/16`, so with the upstream defaults the OIDC login obtains no token and the AI provider cannot reach the gateway.
`tasks/01_manager_ops.yml` therefore rewrites `curlsecurityblockedhosts` to `MOODLE_EGRESS_BLOCKED_HOSTS` and extends `curlsecurityallowedport` with the port of `MOODLE_LITELLM_CHAT_ENDPOINT` when the `litellm` service is enabled. Loopback, `0.0.0.0`, the cloud metadata address and `10.0.0.0/8` stay blocked; `172.16.0.0/12` and `192.168.0.0/16` are opened.
This is an accepted trade-off, not an oversight. Moodle offers no allowlist, so a single internal host cannot be permitted without unblocking the range it sits in. The cost is that any account holding a URL-fetching capability, such as `block/rss_client:myaddinstance` or a `repository/url` instance, can make the server issue requests to sibling containers. Grant those capabilities only to roles you trust with that reach, or keep the upstream blocklist and accept that SSO login and the AI surface do not work.
## Image source
This role builds its own Moodle image from upstream Moodle source on top of the official `php:8.3-fpm` base.
@@ -14,3 +14,4 @@ require("./test-lam-writethrough").register(shared);
require("./test-administrator-persona").register(shared);
require("./test-guest-persona").register(shared);
require("./test-mcp-guest").register(shared);
require("./test-litellm-gateway").register(shared);
@@ -0,0 +1,163 @@
const { test, expect } = require("@playwright/test");
const { decodeDotenvQuotedValue, performKeycloakLogin, readEnv } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
const adminNativePassword = decodeDotenvQuotedValue(process.env.ADMIN_NATIVE_PASSWORD || "");
const ENDPOINT_FIELD = "#id_s_aiprovider_openai_action_generate_text_endpoint";
const USER_MENU = ".usermenu, [data-region='user-menu-toggle'], a[href*='profile.php']";
const GATEWAY_PROMPT = "Reply with the single word: pong";
/**
* Open an authenticated site-administrator session.
*
* `tasks/04_oidc.yml` rewrites `mdl_user.auth` to `oidc` for every account but
* `guest`, and `auth_oidc` grants a login only when the request carries an
* authorization code, so with the sso service enabled no password reaches the
* account through the native form. Without sso that task never runs, the
* accounts stay on `auth_manual` and the password `install_database.php`
* provisioned is the only way in.
*
* @param {import('@playwright/test').Page} page browser page to authenticate
* @param {object} shared role-shared env and persona helpers
*/
async function loginAsSiteAdmin(page, shared) {
if (shared.env.ssoEnabled) {
await page.goto(`${shared.env.moodleBaseUrl}/auth/oidc/?source=loginpage`, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await performKeycloakLogin(
page,
shared.env.adminUsername,
shared.env.adminPassword,
readEnv("CANONICAL_DOMAIN"),
);
} else {
expect(
adminNativePassword,
"ADMIN_NATIVE_PASSWORD must be rendered from credentials.user_password; with the sso service off it is the only secret that authenticates the Moodle site administrator",
).toBeTruthy();
await page.goto(`${shared.env.moodleBaseUrl}/login/index.php`, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await page
.locator("input[name='username'], input#username")
.first()
.fill(shared.env.adminUsername);
const passwordInput = page
.locator(
".toggle-sensitive-wrapper input[name='password'], .toggle-sensitive-wrapper input#password, input[name='password']",
)
.first();
await expect(
passwordInput,
"the native Moodle login form must expose a password field; its absence means the login page no longer serves the manual auth form",
).toBeAttached({ timeout: 30_000 });
await expect(async () => {
await passwordInput.fill(adminNativePassword);
await expect(passwordInput).toHaveValue(adminNativePassword);
}).toPass({ timeout: 30_000 });
await page.locator("#loginbtn, button[type='submit'], input[type='submit']").first().click();
await page.waitForLoadState("load");
}
await expect(
page.locator(USER_MENU).first(),
"the site administrator must reach an authenticated session; a failure here means the auth chain the deploy configured does not admit the administrator account",
).toBeVisible({ timeout: 60_000 });
}
exports.register = function (shared) {
test("litellm: Moodle answers a prompt through the in-cluster gateway", async ({ page }) => {
skipUnlessServiceEnabled("litellm");
test.setTimeout(240_000);
await loginAsSiteAdmin(page, shared);
await page.goto(
`${shared.env.moodleBaseUrl}/admin/settings.php?section=aiprovider_openai_generate_text`,
{ waitUntil: "domcontentloaded", timeout: 60_000 },
);
const endpointField = page.locator(ENDPOINT_FIELD);
await expect(
endpointField,
"the aiprovider_openai generate_text settings page must render its API endpoint field; its absence means the provider was never configured or the session lacks moodle/site:config",
).toBeVisible({ timeout: 60_000 });
const configuredEndpoint = ((await endpointField.inputValue()) || "").trim();
expect(
configuredEndpoint,
"aiprovider_openai/action_generate_text_endpoint must be an http(s) URL written by the deploy, not the upstream api.openai.com default",
).toMatch(/^https?:\/\/.+/i);
const endpointHost = new URL(configuredEndpoint).hostname;
expect(
endpointHost,
`the generate_text endpoint host must be a bare in-cluster service name; the dotted host "${endpointHost}" gets completed by the container dns-search suffix and resolves through the public ingress, so the prompt would leave the deployment`,
).not.toContain(".");
expect(
endpointHost,
"the generate_text endpoint must address the svc-ai-litellm gateway, not point back at Moodle itself",
).not.toBe(new URL(shared.env.moodleBaseUrl).hostname);
const bootstrap = await page.evaluate(() => ({
sesskey: (window.M && window.M.cfg && window.M.cfg.sesskey) || "",
contextid: (window.M && window.M.cfg && window.M.cfg.contextid) || 0,
}));
expect(
bootstrap.sesskey,
"M.cfg.sesskey must be exposed on the admin page; without it Moodle rejects every /lib/ajax/service.php call",
).toBeTruthy();
expect(
bootstrap.contextid,
"M.cfg.contextid must be exposed so the AI placement runs in the context the admin page already validated",
).toBeGreaterThan(0);
const ajaxUrl =
`${shared.env.moodleBaseUrl}/lib/ajax/service.php` +
`?sesskey=${encodeURIComponent(bootstrap.sesskey)}&info=aiplacement_editor_generate_text`;
const response = await page.request.post(ajaxUrl, {
headers: { "Content-Type": "application/json" },
data: [
{
index: 0,
methodname: "aiplacement_editor_generate_text",
args: { contextid: bootstrap.contextid, prompttext: GATEWAY_PROMPT },
},
],
timeout: 180_000,
});
expect(
response.status(),
`the aiplacement_editor web service must answer 200; a non-200 means the placement is disabled or unavailable, so no AI surface reaches the provider (got ${response.status()})`,
).toBe(200);
const payload = await response.json();
const entry = Array.isArray(payload) ? payload[0] : null;
expect(
entry,
`aiplacement_editor_generate_text must return exactly one result entry, got ${JSON.stringify(payload).slice(0, 400)}`,
).toBeTruthy();
expect(
entry.error,
`the placement call must not raise a Moodle exception, got ${JSON.stringify(entry.exception || entry).slice(0, 400)}`,
).toBe(false);
const result = entry.data;
expect(
result.success,
`the generate_text action must succeed; errorcode carries the gateway HTTP status, so 401 means the virtual key is wrong and 404 or 0 means the endpoint is unreachable (got ${JSON.stringify(result).slice(0, 400)})`,
).toBe(true);
expect(
String(result.generatedcontent || "").trim().length,
"the gateway must return a non-empty completion, proving Moodle's AI placement had the prompt answered inside the deployment",
).toBeGreaterThan(0);
});
};
+5
View File
@@ -1,5 +1,10 @@
---
credentials:
litellm_api_key:
description: Per-consumer virtual key for the shared svc-ai-litellm gateway
algorithm: sk_prefixed
validation:
min_length: 50
user_password:
description: Initial password for the Moodle admin user
algorithm: sha256
+4
View File
@@ -1,4 +1,8 @@
---
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required — object storage not implemented for this role
sso:
bond: 1
+9
View File
@@ -36,6 +36,9 @@
prometheus:
enabled: true
shared: true
litellm:
enabled: true
shared: true
mcp:
enabled: true
@@ -71,6 +74,9 @@
prometheus:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
litellm:
enabled: false
shared: false
mcp:
enabled: false
shared: false
@@ -104,6 +110,9 @@
prometheus:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
litellm:
enabled: true
shared: true
mcp:
enabled: false
shared: false
@@ -45,6 +45,23 @@
- "'Database tables already present' not in moodle_install.stderr"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🌐 Open Moodle's cURL egress policy for the deployment's own networks"
ansible.builtin.command:
argv:
- container
- exec
- --user
- "{{ MOODLE_RUNTIME_USER }}"
- "{{ MOODLE_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- php
- "{{ [MOODLE_CLI_DIR, 'cfg.php'] | path_join }}"
- "--name={{ item.name }}"
- "--set={{ item.value }}"
loop: "{{ MOODLE_EGRESS_SETTINGS }}"
loop_control:
label: "{{ item.name }}"
changed_when: false
- name: "📂 Include ownership settings tasks for moodle"
include_tasks: 03_ownership.yml
@@ -56,6 +73,10 @@
include_tasks: 05_ldap_sync.yml
when: MOODLE_LDAP_ENABLED | bool
- name: "🤖 Bind the native AI surface to the platform gateway"
include_tasks: utils/ai_gateway.yml
when: MOODLE_LITELLM_ENABLED | bool
- name: "🔌 Provision the MCP server surface"
include_tasks: utils/mcp.yml
when: MOODLE_MCP_ENABLED | bool
@@ -0,0 +1,58 @@
---
- name: "🔐 Verify the gateway contract for {{ application_id }}"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-litellm/tasks/utils/consumer_contract.yml') }}"
- name: "🤖 Point the Moodle AI subsystem at the platform gateway"
ansible.builtin.command:
argv:
- container
- exec
- --user
- "{{ MOODLE_RUNTIME_USER }}"
- "{{ MOODLE_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- php
- "{{ [MOODLE_CLI_DIR, 'cfg.php'] | path_join }}"
- "--component={{ item.component }}"
- "--name={{ item.name }}"
- "--set={{ item.value }}"
loop: "{{ MOODLE_LITELLM_SETTINGS }}"
loop_control:
label: "{{ item.component }}/{{ item.name }}"
changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🧹 Purge Moodle caches so the provider picks up its endpoint"
ansible.builtin.command:
argv:
- container
- exec
- --user
- "{{ MOODLE_RUNTIME_USER }}"
- "{{ MOODLE_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- php
- "{{ [MOODLE_CLI_DIR, 'purge_caches.php'] | path_join }}"
changed_when: false
- name: "🔎 Read back the configured chat endpoint"
ansible.builtin.command:
argv:
- container
- exec
- --user
- "{{ MOODLE_RUNTIME_USER }}"
- "{{ MOODLE_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- php
- "{{ [MOODLE_CLI_DIR, 'cfg.php'] | path_join }}"
- "--component=aiprovider_openai"
- "--name=action_generate_text_endpoint"
register: moodle_ai_endpoint
changed_when: false
- name: "🛑 Refuse a Moodle AI provider that does not answer through the gateway"
ansible.builtin.assert:
that:
- (moodle_ai_endpoint.stdout | trim) == MOODLE_LITELLM_CHAT_ENDPOINT
fail_msg: >-
aiprovider_openai still resolves generate_text to
'{{ moodle_ai_endpoint.stdout | trim }}' instead of
{{ MOODLE_LITELLM_CHAT_ENDPOINT }}, so prompts would leave the deployment.
@@ -10,6 +10,7 @@ LDAP_ADMIN_PASSWORD={{ LDAP.BIND_CREDENTIAL | dotenv_quote }}
{% endif %}
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
ADMIN_NATIVE_PASSWORD={{ lookup('config', application_id, 'credentials.user_password') | dotenv_quote }}
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
@@ -18,6 +19,7 @@ PERSONA_ADMINISTRATOR_BLOCKED=true
MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
LITELLM_SERVICE_ENABLED={{ lookup('config', application_id, 'services.litellm.enabled') | string | lower }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
+34 -1
View File
@@ -3,7 +3,6 @@ application_id: "web-app-moodle"
entity_name: "{{ application_id | get_entity_name }}"
swarm_stack_converge_retries: 600
MOODLE_DOMAIN: "{{ lookup('domain', application_id) }}"
MOODLE_CODE_DIR: "/var/www/html"
MOODLE_DATA_DIR: "/var/www/moodledata"
MOODLE_LOCAL_CACHE_DIR: "{{ lookup('volume', application_id, 'localcache').mounts[0].target }}"
@@ -52,6 +51,40 @@ MOODLE_CRON_CONTAINER: "{{ lookup('config', application_id, 'services.cron.name'
MOODLE_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
MOODLE_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
MOODLE_LITELLM_ENABLED: "{{ lookup('config', application_id, 'services.litellm.enabled') | bool }}"
MOODLE_LITELLM_API_KEY: "{{ lookup('config', application_id, 'credentials.litellm_api_key') }}"
MOODLE_LITELLM_CHAT_ENDPOINT: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}/chat/completions"
MOODLE_LITELLM_IMAGE_ENDPOINT: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}/images/generations"
MOODLE_LITELLM_SETTINGS:
- { component: aiprovider_openai, name: enabled, value: "1" }
- { component: aiprovider_openai, name: apikey, value: "{{ MOODLE_LITELLM_API_KEY }}" }
- { component: aiprovider_openai, name: action_generate_text_endpoint, value: "{{ MOODLE_LITELLM_CHAT_ENDPOINT }}" }
- { component: aiprovider_openai, name: action_generate_text_model, value: "{{ LITELLM_CHAT_MODEL }}" }
- { component: aiprovider_openai, name: action_summarise_text_endpoint, value: "{{ MOODLE_LITELLM_CHAT_ENDPOINT }}" }
- { component: aiprovider_openai, name: action_summarise_text_model, value: "{{ LITELLM_CHAT_MODEL }}" }
- { component: aiprovider_openai, name: action_generate_image_endpoint, value: "{{ MOODLE_LITELLM_IMAGE_ENDPOINT }}" }
- { component: aiprovider_openai, name: generate_image, value: "0" }
- { component: aiplacement_courseassist, name: enabled, value: "1" }
- { component: aiplacement_editor, name: enabled, value: "1" }
MOODLE_EGRESS_BLOCKED_HOSTS:
- "0.0.0.0"
- "127.0.0.0/8"
- "localhost"
- "169.254.169.254"
- "0000::1"
- "10.0.0.0/8"
MOODLE_EGRESS_ALLOWED_PORTS:
- "80"
- "443"
MOODLE_EGRESS_NEWLINE: "\n"
MOODLE_EGRESS_SETTINGS:
- name: curlsecurityblockedhosts
value: "{{ MOODLE_EGRESS_BLOCKED_HOSTS | join(MOODLE_EGRESS_NEWLINE) }}"
- name: curlsecurityallowedport
value: "{{ (MOODLE_EGRESS_ALLOWED_PORTS + ([MOODLE_LITELLM_CHAT_ENDPOINT | urlsplit('port') | string] if MOODLE_LITELLM_ENABLED | bool else [])) | join(MOODLE_EGRESS_NEWLINE)
}}"
MOODLE_MCP_ENABLED: "{{ lookup('config', application_id, 'mcp.enabled') }}"
MOODLE_MCP_PLUGIN_VERSION: "v0.6.0"
MOODLE_MCP_PLUGIN_ARCHIVE_URL: "https://github.com/onbirdev/moodle-webservice_mcp/archive/refs/tags/{{ MOODLE_MCP_PLUGIN_VERSION }}.zip"