mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(litellm): serve OpenAI, Anthropic and OpenRouter from declared keys
The gateway could reach exactly one remote provider, through a bare
OPENROUTER_API_KEY group_var with an empty-string default and an `{% if %}`
around a single hardcoded model entry. OpenAI and Anthropic had no path at all.
All three are now declared in roles/svc-ai-litellm/meta/secrets.yml, which
mapping.py already calls the application config schema, so a key lands at
applications.svc-ai-litellm.secrets.credentials.<provider>_api_key like every
other credential. Each carries the `type` and `regex` its value must satisfy and
defaults to the central API.<provider>.api_key block, which keeps one
operator-facing place to fill a provider in while the role owns the shape.
type and regex had to become real to be worth declaring. 192 `validation:`
blocks sit in the role schemas today and not one line of Python enforces them,
so a `regex` would have joined a pile of decoration. validate_supplied_value
now runs on every path a value arrives from outside — the --set override, a
value already in the inventory, and the value standing next to a `default:`.
A generated value stays exempt because the algorithm that produced it is named
by the same schema and generate_value takes no schema argument. min_length is
deliberately left unenforced: the generators ignore it, so holding them to it
would fail where nothing is wrong.
The pattern is written for the paste, not for the vendor. OpenAI moved issuance
from sk- to sk-proj- without notice and Anthropic issues sk-ant-api03-; a schema
pinning a prefix would reject a valid key and abort inventory creation. `\S{20,}`
accepts every form those three issue today and one nobody has announced, and
rejects a trailing newline, an embedded space, a truncated copy and a
<placeholder>. A key pasted into the wrong provider's slot is caught at deploy
instead, where the route it publishes answers nothing.
The gateway iterates one LITELLM_REMOTE_BACKENDS list instead of three `{% if %}`
blocks; config.yaml.j2 and env.j2 read the same list and selectattr('key')
decides. LITELLM_CHAT_MODEL falls back to the first configured remote alias
rather than to a hardcoded openrouter/auto.
For CI the keys travel as INFINITO_API_{OPENAI,ANTHROPIC,OPENROUTER}_KEY through
default.env, the passthrough allowlist, the infinito service in compose.yml, the
workflow env block and the development inventory. The compose leg is not
optional: the env lint rejected the change without it, because a key the
inventory reads but compose does not forward resolves to empty inside the
container with no error. The workflow reads `secrets.X || vars.X` so a variable
works as asked while a secret wins, which matters because a variable is not
masked and the development inventory sets MASK_CREDENTIALS_IN_LOGS: false.
An unset key is not an error anywhere: the provider's routes stay unpublished
and the CLI test asserts their absence rather than skipping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
abd6a24b73
commit
786899700f
24 files changed
+438
-33
No files matched your search
@@ -125,6 +125,9 @@ env:
|
||||
INFINITO_IMAGE_TAG: ci-${{ github.sha }}
|
||||
INFINITO_GHCR_MIRROR_PREFIX: mirror
|
||||
INFINITO_PLAYWRIGHT_KEEP: ${{ vars.PLAYWRIGHT_KEEP_ALL }}
|
||||
INFINITO_API_OPENAI_KEY: ${{ secrets.API_OPENAI_KEY || vars.API_OPENAI_KEY }}
|
||||
INFINITO_API_ANTHROPIC_KEY: ${{ secrets.API_ANTHROPIC_KEY || vars.API_ANTHROPIC_KEY }}
|
||||
INFINITO_API_OPENROUTER_KEY: ${{ secrets.API_OPENROUTER_KEY || vars.API_OPENROUTER_KEY }}
|
||||
INFINITO_APP_DISCOVERY_RUNNER: host
|
||||
INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }}
|
||||
INFINITO_LIFECYCLES: ${{ inputs.lifecycles }}
|
||||
|
||||
@@ -71,6 +71,9 @@ services:
|
||||
- letsencrypt:/etc/letsencrypt/
|
||||
|
||||
environment:
|
||||
INFINITO_API_ANTHROPIC_KEY: ${INFINITO_API_ANTHROPIC_KEY}
|
||||
INFINITO_API_OPENAI_KEY: ${INFINITO_API_OPENAI_KEY}
|
||||
INFINITO_API_OPENROUTER_KEY: ${INFINITO_API_OPENROUTER_KEY}
|
||||
INFINITO_BUILDKIT_ENABLED: ${INFINITO_BUILDKIT_ENABLED}
|
||||
INFINITO_CONTAINER_CA_ENABLED: ${INFINITO_CONTAINER_CA_ENABLED}
|
||||
INFINITO_DIR_VAR_LIB: ${INFINITO_DIR_VAR_LIB}
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
# Git repository holding the operator's terminal aliases (an `aliases` file at repo root); developers override this with their own repo.
|
||||
INFINITO_ALIAS_REPOSITORY=https://github.com/kevinveenbirkenbach/alias
|
||||
|
||||
# Anthropic API key the development inventory feeds into API.anthropic.api_key; empty leaves the anthropic routes unpublished.
|
||||
INFINITO_API_ANTHROPIC_KEY=""
|
||||
|
||||
# OpenAI API key the development inventory feeds into API.openai.api_key; empty leaves the openai routes unpublished.
|
||||
INFINITO_API_OPENAI_KEY=""
|
||||
|
||||
# OpenRouter API key the development inventory feeds into API.openrouter.api_key; empty leaves openrouter/auto unpublished.
|
||||
INFINITO_API_OPENROUTER_KEY=""
|
||||
|
||||
# Where scripts/meta/resolve/apps.sh runs the meta-CLI: host | docker.
|
||||
INFINITO_APP_DISCOVERY_RUNNER=docker
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ The unified schema supports:
|
||||
|
||||
- **Nested keys.** Both flat and nested credential keys are accepted, so e.g. `recaptcha.key` and `recaptcha.secret` remain nested.
|
||||
- **`algorithm:` defaults to `plain`** when the field is omitted.
|
||||
- **`type:` (optional)** is one of `string`, `integer`, `boolean`. A supplied value of another type aborts inventory creation.
|
||||
- **`regex:` (optional)** is a pattern the supplied value MUST match in full (`re.fullmatch`). Anchors are implicit; `sk-[A-Za-z0-9]{16,}` needs no `^` or `$`.
|
||||
- **`default:` (optional)** is a Jinja string used as the credential's value when the inventory does not provide one.
|
||||
- `default:` is **NOT rendered at inventory creation time.** The literal Jinja string is written verbatim into the inventory so that referenced variables (`CAPTCHA.RECAPTCHA.KEY`, `lookup(...)`, …) resolve only at deploy/runtime when those variables are actually defined.
|
||||
- `default:` values are **NOT validated.** `validation:` only applies to user-provided values, so the schema default is exempt.
|
||||
@@ -108,6 +110,58 @@ credentials:
|
||||
default: "{{ CAPTCHA.RECAPTCHA.SECRET | default('') }}"
|
||||
```
|
||||
|
||||
### What is checked, and when 🔎
|
||||
|
||||
`type:` and `regex:` are enforced by `validate_supplied_value()` in
|
||||
[inventory.py](../../../../../utils/manager/inventory.py) at inventory-creation
|
||||
time, on every path where a value arrives from outside:
|
||||
|
||||
| Source | Checked |
|
||||
|---|---|
|
||||
| `--set applications.<app>.secrets.credentials.<path>=<value>` | yes |
|
||||
| A value already present in the inventory | yes |
|
||||
| The literal written from `default:` | no |
|
||||
| A value produced from `algorithm:` | no |
|
||||
|
||||
A generated value is exempt because the algorithm that produced it is named by
|
||||
the same schema, and `generate_value()` takes no schema argument.
|
||||
|
||||
An empty string means "not configured" and passes every check, which is what
|
||||
makes an optional provider key expressible: declare the shape, leave the value
|
||||
empty, and the consumer gates on emptiness.
|
||||
|
||||
`validation.min_length` is declared in many role schemas but enforced nowhere.
|
||||
Do not read it as a guarantee.
|
||||
|
||||
### Worked Example: an optional external key
|
||||
|
||||
```yaml
|
||||
# roles/svc-ai-litellm/meta/secrets.yml
|
||||
credentials:
|
||||
openai_api_key:
|
||||
description: "OpenAI API key; empty leaves the openai/* models unpublished"
|
||||
type: "string"
|
||||
regex: "\\S{20,}"
|
||||
default: "{{ API.openai.api_key }}"
|
||||
```
|
||||
|
||||
The `default:` points at the central `API` block in
|
||||
[group_vars/all/18_api.yml](../../../../../group_vars/all/18_api.yml), so an
|
||||
operator fills the provider in one place while the role keeps the shape the
|
||||
value must have.
|
||||
|
||||
### Write a pattern for the paste, not for the vendor 🚫
|
||||
|
||||
`regex:` MUST NOT pin a vendor's key prefix. OpenAI moved issuance from `sk-` to
|
||||
`sk-proj-` without notice, and Anthropic issues `sk-ant-api03-`; a schema that
|
||||
encoded either would reject a valid key and abort inventory creation, which is a
|
||||
hard failure for a value that was correct.
|
||||
|
||||
Write the pattern against the mistakes a paste makes — a trailing newline, an
|
||||
embedded space, a truncated copy, a `<placeholder>` — and leave the provider's
|
||||
format alone. A key pasted into the wrong provider's slot is not caught here;
|
||||
it is caught at deploy time, where the route it publishes answers nothing.
|
||||
|
||||
Flat schema entries keep the same shape:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -12,6 +12,31 @@ Repository variables are set under **Settings → Secrets and variables → Acti
|
||||
| `CI_RUN_ON_MAIN` | [entry-push-latest.yml](../../../../../.github/workflows/entry-push-latest.yml) | Pushes to `main` skip CI | `true` to run CI on `main` pushes too |
|
||||
| `CI_ENABLE_AUTO_UPDATES` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml), [entry-pr-open-dependabot-close.yml](../../../../../.github/workflows/entry-pr-open-dependabot-close.yml) | Update jobs skipped; Dependabot PRs auto-closed | `true` to allow update PRs (workflow-driven and Dependabot) |
|
||||
| `INFINITO_PLAYWRIGHT_KEEP` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Playwright keeps trace, screenshot and video only when a test fails | `true` to keep them for every test (passing runs included) |
|
||||
| `API_OPENAI_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `openai/*` model, and the CLI test asserts their absence | An OpenAI key, to deploy and test the route |
|
||||
| `API_ANTHROPIC_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `anthropic/*` model | An Anthropic key |
|
||||
| `API_OPENROUTER_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | The gateway publishes no `openrouter/auto` model | An OpenRouter key |
|
||||
|
||||
## Provider keys: variable or secret? 🔑
|
||||
|
||||
The three `API_*_KEY` entries are read as `${{ secrets.X || vars.X }}`, so a
|
||||
secret of the same name wins and the variable is the fallback. Prefer the
|
||||
secret: a repository **variable is not masked**, and the development inventory
|
||||
sets `MASK_CREDENTIALS_IN_LOGS: false`, so a key supplied as a variable can be
|
||||
printed by a task that echoes its own arguments.
|
||||
|
||||
An unset key is not an error. The provider's route stays unpublished, the
|
||||
gateway serves whatever local backend the round deploys, and
|
||||
[the litellm CLI test](../../../../../roles/svc-ai-litellm/files/test/probe.py)
|
||||
asserts the absence rather than skipping.
|
||||
|
||||
They travel as `INFINITO_API_OPENAI_KEY`, `INFINITO_API_ANTHROPIC_KEY` and
|
||||
`INFINITO_API_OPENROUTER_KEY` through the workflow `env:` block, the passthrough
|
||||
allowlist in [passthrough.py](../../../../../utils/env/handlers/passthrough.py),
|
||||
the `infinito` service environment in
|
||||
[compose.yml](../../../../../compose.yml), and land in `API.<provider>.api_key`
|
||||
in the development inventory, whose path is the SPOT behind
|
||||
`INFINITO_INVENTORY_VARS_FILE`. Skipping the compose forwarding makes them
|
||||
resolve to empty inside the container without any error.
|
||||
|
||||
## Cancelling in-progress runs 🛑
|
||||
|
||||
|
||||
@@ -10,6 +10,29 @@ Repository secrets MUST be set under **Settings → Secrets and variables → Ac
|
||||
|---|---|---|---|
|
||||
| `BOT_APP_CLIENT_ID` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml) | GitHub App Client ID (OAuth-style `Iv…` identifier shown on the App's General page) used to mint a short-lived installation token for the update PR. | `CI_ENABLE_AUTO_UPDATES == 'true'` |
|
||||
| `BOT_APP_PRIVATE_KEY` | [cron-update.yml](../../../../../.github/workflows/cron-update.yml) | PEM-encoded private key of the same GitHub App. Used to sign the JWT that exchanges for the installation token. | `CI_ENABLE_AUTO_UPDATES == 'true'` |
|
||||
| `API_OPENAI_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes the `openai/*` models on the LiteLLM gateway and lets the CLI test exercise the route. | Never. Unset leaves the route unpublished. |
|
||||
| `API_ANTHROPIC_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes the `anthropic/*` models. | Never. |
|
||||
| `API_OPENROUTER_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes `openrouter/auto`. | Never. |
|
||||
|
||||
## `API_OPENAI_KEY`, `API_ANTHROPIC_KEY`, `API_OPENROUTER_KEY` 🤖
|
||||
|
||||
These are optional and fail soft: unset means the provider's route is never
|
||||
published, and the deployment falls back to whatever local backend the round
|
||||
deploys. The workflow reads each as `${{ secrets.X || vars.X }}`, so the same
|
||||
name works as a repository variable; see
|
||||
[configuration.md](configuration.md#provider-keys-variable-or-secret-) for why
|
||||
the secret is the better half of that pair.
|
||||
|
||||
Set them the same way as the App credentials: **Settings → Secrets and variables
|
||||
→ Actions → Secrets → New repository secret**, one per provider, the value being
|
||||
the provider's key verbatim.
|
||||
|
||||
The shape each key must have is declared per role rather than here, in
|
||||
[roles/svc-ai-litellm/meta/secrets.yml](../../../../../roles/svc-ai-litellm/meta/secrets.yml)
|
||||
with a `type:` and a `regex:`. The pattern rejects a truncated or
|
||||
whitespace-carrying paste and deliberately accepts any vendor prefix, so a
|
||||
provider changing its issuance format cannot turn a valid key into a failed
|
||||
inventory run.
|
||||
|
||||
## `BOT_APP_CLIENT_ID` and `BOT_APP_PRIVATE_KEY` 🤖
|
||||
|
||||
|
||||
@@ -21,10 +21,14 @@ AI_LOCAL_MODELS:
|
||||
- alias: nomic-embed-text:latest
|
||||
name: nomic-embed-text-v1.5
|
||||
|
||||
AI_REMOTE_ALIASES: >-
|
||||
{{ ['openai/gpt-4o-mini'] * ((API.openai.api_key | string | length > 0) | int)
|
||||
+ ['anthropic/claude-sonnet-4-5'] * ((API.anthropic.api_key | string | length > 0) | int)
|
||||
+ ['openrouter/auto'] * ((API.openrouter.api_key | string | length > 0) | int) }}
|
||||
|
||||
LITELLM_CHAT_MODEL: "{{ (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | reject('search', 'embed') | list | first |
|
||||
default('')) if 'svc-ai-ollama' in LITELLM_BACKEND_ROLES else ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias')
|
||||
| reject('search', 'embed') | list | first | default('')) if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else ('openrouter/auto' if ((OPENROUTER_API_KEY | string)
|
||||
| length > 0) else '')) }}"
|
||||
| reject('search', 'embed') | list | first | default('')) if 'svc-ai-lmstudio' in LITELLM_BACKEND_ROLES else (AI_REMOTE_ALIASES | first | default(''))) }}"
|
||||
|
||||
LITELLM_CHAT_MODEL_SERVED: >-
|
||||
{{ (('svc-ai-ollama' in LITELLM_BACKEND_ROLES)
|
||||
@@ -33,12 +37,10 @@ LITELLM_CHAT_MODEL_SERVED: >-
|
||||
or (('svc-ai-lmstudio' in LITELLM_BACKEND_ROLES)
|
||||
and ((lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models')
|
||||
| map(attribute='alias') | reject('search', 'embed') | list) | length > 0))
|
||||
or ((OPENROUTER_API_KEY | string) | length > 0) }}
|
||||
or ((AI_REMOTE_ALIASES | length) > 0) }}
|
||||
|
||||
LITELLM_USABLE: "{{ ('svc-ai-litellm' in group_names) and (LITELLM_CHAT_MODEL_SERVED | bool) }}"
|
||||
|
||||
OPENROUTER_API_KEY: ""
|
||||
|
||||
MCP_DISCOVERY: "{{ lookup('mcp_servers') }}"
|
||||
MCP_DISCOVERED_SERVERS: "{{ MCP_DISCOVERY.selected }}"
|
||||
MCP_RECONCILE_STRICT: false
|
||||
@@ -6,8 +6,14 @@
|
||||
# encrypted) inventory; access ONLY via lookup('api', '<provider>.<key>').
|
||||
# nocheck: unused-var: consumed by the api lookup plugin (plugins/lookup/api.py) via lookup('api', ...), not referenced directly in YAML/Jinja.
|
||||
API:
|
||||
anthropic:
|
||||
api_key: ""
|
||||
cloudflare:
|
||||
api_token: ""
|
||||
openai:
|
||||
api_key: ""
|
||||
openrouter:
|
||||
api_key: ""
|
||||
telegram:
|
||||
bot_token: ""
|
||||
api_id: ""
|
||||
|
||||
@@ -39,6 +39,14 @@ AI_LOCAL_MODELS_CI:
|
||||
- alias: qwen2.5:0.5b
|
||||
name: qwen2.5-0.5b-instruct
|
||||
|
||||
API:
|
||||
openai:
|
||||
api_key: "{{ lookup('env', 'INFINITO_API_OPENAI_KEY') }}"
|
||||
anthropic:
|
||||
api_key: "{{ lookup('env', 'INFINITO_API_ANTHROPIC_KEY') }}"
|
||||
openrouter:
|
||||
api_key: "{{ lookup('env', 'INFINITO_API_OPENROUTER_KEY') }}"
|
||||
|
||||
OIDC_OVERRIDE:
|
||||
CLIENT:
|
||||
SECRET: VERYUNSAFESAMPLEPASSWORD1234
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This role deploys LiteLLM as a shared gateway container in Compose and Swarm deployments, backed by the central PostgreSQL service. The gateway is headless: it binds its HTTP port on the container host and claims no domain of its own, and the browser-facing surface is published separately by the LiteLLM Admin UI. It renders the model list from the interchangeable local backends available on the host, Ollama and LM Studio, and adds an OpenRouter entry when an OpenRouter API key is configured. Once the gateway is up, it provisions one virtual key per consuming application through the gateway admin API.
|
||||
This role deploys LiteLLM as a shared gateway container in Compose and Swarm deployments, backed by the central PostgreSQL service. The gateway is headless: it binds its HTTP port on the container host and claims no domain of its own, and the browser-facing surface is published separately by the LiteLLM Admin UI. It renders the model list from the interchangeable local backends available on the host, Ollama and LM Studio, and adds the OpenAI, Anthropic and OpenRouter entries whose API key is configured. Once the gateway is up, it provisions one virtual key per consuming application through the gateway admin API.
|
||||
|
||||
## Cosmos
|
||||
|
||||
@@ -63,7 +63,8 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (
|
||||
## Features
|
||||
|
||||
- **OpenAI-compatible API:** One HTTP endpoint serves every model listed in the gateway configuration.
|
||||
- **Backend routing:** Model entries are generated for Ollama and LM Studio when those services run on the host, and for OpenRouter when its API key is set.
|
||||
- **Backend routing:** Model entries are generated for Ollama and LM Studio when those services run on the host, under the shared alias each model carries so a consumer names one model whichever local backend answers it.
|
||||
- **Remote providers:** OpenAI, Anthropic and OpenRouter each add their models when a key is configured. The keys are declared in `meta/secrets.yml` with the `type` and `regex` their value must satisfy, and default to the central `API.<provider>.api_key` entry; an unset key leaves that provider's routes unpublished.
|
||||
- **Per-consumer virtual keys:** Each consuming application receives its own virtual key, created through the gateway admin API under an alias naming that application.
|
||||
- **File-based model list:** The model list is mounted as a read-only config file, with database-stored model entries turned off.
|
||||
- **Managed credentials:** The gateway master key and the admin UI password are generated and kept as role credentials, and the admin UI username is the platform administrator name.
|
||||
|
||||
@@ -13,6 +13,8 @@ Env (rendered into test.env from templates/test.env.j2):
|
||||
LMSTUDIO_ALIASES JSON list of aliases only LM Studio provides, rendered
|
||||
whether or not the backend is deployed so the absent
|
||||
case stays assertable
|
||||
REMOTE_ALIASES JSON list of aliases a configured provider key publishes
|
||||
(OpenAI, Anthropic, OpenRouter)
|
||||
OLLAMA_ENABLED true|false
|
||||
LMSTUDIO_ENABLED true|false
|
||||
RETRIES completion attempts (default 10)
|
||||
@@ -28,8 +30,6 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OPENROUTER_MODEL = "openrouter/auto"
|
||||
|
||||
|
||||
def evaluate(
|
||||
*,
|
||||
@@ -40,6 +40,7 @@ def evaluate(
|
||||
ollama_enabled: bool,
|
||||
lmstudio_enabled: bool,
|
||||
lmstudio_aliases: list[str],
|
||||
remote_aliases: list[str],
|
||||
) -> list[str]:
|
||||
"""Every routing violation the published model list shows, as messages.
|
||||
|
||||
@@ -51,6 +52,7 @@ def evaluate(
|
||||
ollama_enabled: svc-ai-ollama is deployed on the gateway's host.
|
||||
lmstudio_enabled: svc-ai-lmstudio is deployed on the gateway's host.
|
||||
lmstudio_aliases: aliases only LM Studio provides, whatever is deployed.
|
||||
remote_aliases: aliases a configured remote provider key publishes.
|
||||
|
||||
Returns:
|
||||
One message per violation; empty when the routing is consistent.
|
||||
@@ -76,7 +78,7 @@ def evaluate(
|
||||
f"{sorted(exclusive & served)}; a route stands without its backend"
|
||||
)
|
||||
|
||||
unbacked = sorted(served - exclusive - {OPENROUTER_MODEL})
|
||||
unbacked = sorted(served - exclusive - set(remote_aliases))
|
||||
if not ollama_enabled and unbacked:
|
||||
failures.append(
|
||||
f"svc-ai-ollama is not deployed yet the gateway publishes {unbacked}"
|
||||
@@ -164,6 +166,7 @@ def main() -> int:
|
||||
ollama_enabled=os.environ["OLLAMA_ENABLED"] == "true",
|
||||
lmstudio_enabled=os.environ["LMSTUDIO_ENABLED"] == "true",
|
||||
lmstudio_aliases=json.loads(os.environ["LMSTUDIO_ALIASES"]),
|
||||
remote_aliases=json.loads(os.environ["REMOTE_ALIASES"]),
|
||||
)
|
||||
|
||||
if chat_model_served and not failures:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
# LITELLM_CHAT_MODEL_SERVED true|false
|
||||
# LITELLM_EXPECTED_MODELS JSON list the config template published
|
||||
# LITELLM_LMSTUDIO_ALIASES JSON list of aliases only LM Studio provides
|
||||
# LITELLM_REMOTE_ALIASES JSON list of aliases a configured provider key adds
|
||||
# LITELLM_OLLAMA_ENABLED true|false
|
||||
# LITELLM_LMSTUDIO_ENABLED true|false
|
||||
# READY_RETRIES gateway readiness attempts (default 30)
|
||||
@@ -54,6 +55,7 @@ probe() {
|
||||
-e "CHAT_MODEL_SERVED=${LITELLM_CHAT_MODEL_SERVED}" \
|
||||
-e "EXPECTED_MODELS=${LITELLM_EXPECTED_MODELS}" \
|
||||
-e "LMSTUDIO_ALIASES=${LITELLM_LMSTUDIO_ALIASES}" \
|
||||
-e "REMOTE_ALIASES=${LITELLM_REMOTE_ALIASES}" \
|
||||
-e "OLLAMA_ENABLED=${LITELLM_OLLAMA_ENABLED}" \
|
||||
-e "LMSTUDIO_ENABLED=${LITELLM_LMSTUDIO_ENABLED}" \
|
||||
"${LITELLM_CONTAINER}" "$(interpreter)" - <"${here}/probe.py"
|
||||
|
||||
@@ -10,3 +10,19 @@ credentials:
|
||||
algorithm: "alphanumeric"
|
||||
validation:
|
||||
min_length: 32
|
||||
openai_api_key:
|
||||
description: "OpenAI API key; empty leaves the openai/* models unpublished. The pattern rejects a truncated or whitespace-carrying paste, not a vendor prefix:
|
||||
OpenAI moved from sk- to sk-proj- without notice, and a schema that pinned the prefix would reject a valid key"
|
||||
type: "string"
|
||||
regex: "\\S{20,}"
|
||||
default: "{{ API.openai.api_key }}"
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key; empty leaves the anthropic/* models unpublished"
|
||||
type: "string"
|
||||
regex: "\\S{20,}"
|
||||
default: "{{ API.anthropic.api_key }}"
|
||||
openrouter_api_key:
|
||||
description: "OpenRouter API key; empty leaves openrouter/auto unpublished"
|
||||
type: "string"
|
||||
regex: "\\S{20,}"
|
||||
default: "{{ API.openrouter.api_key }}"
|
||||
@@ -1,6 +1,7 @@
|
||||
{% set _ollama_models = lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') if 'svc-ai-ollama' in group_names else [] %}
|
||||
{% set _lmstudio_models = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') if 'svc-ai-lmstudio' in group_names else [] %}
|
||||
{% set has_backend = _ollama_models or _lmstudio_models or LITELLM_OPENROUTER_API_KEY %}
|
||||
{% set _remote_models = LITELLM_REMOTE_BACKENDS | selectattr('key') | list %}
|
||||
{% set has_backend = _ollama_models or _lmstudio_models or _remote_models %}
|
||||
model_list:{{ ' []' if not has_backend else '' }}
|
||||
{% for _model in _ollama_models %}
|
||||
- model_name: "{{ _model.alias }}"
|
||||
@@ -21,12 +22,14 @@ model_list:{{ ' []' if not has_backend else '' }}
|
||||
max_tokens: {{ LITELLM_MAX_OUTPUT_TOKENS }}
|
||||
timeout: {{ LITELLM_UPSTREAM_TIMEOUT }}
|
||||
{% endfor %}
|
||||
{% if LITELLM_OPENROUTER_API_KEY %}
|
||||
- model_name: openrouter/auto
|
||||
{% for _backend in _remote_models %}
|
||||
- model_name: "{{ _backend.alias }}"
|
||||
litellm_params:
|
||||
model: openrouter/openrouter/auto
|
||||
api_key: os.environ/OPENROUTER_API_KEY
|
||||
{% endif %}
|
||||
model: "{{ _backend.model }}"
|
||||
api_key: os.environ/{{ _backend.env }}
|
||||
max_tokens: {{ LITELLM_MAX_OUTPUT_TOKENS }}
|
||||
timeout: {{ LITELLM_UPSTREAM_TIMEOUT }}
|
||||
{% endfor %}
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
|
||||
@@ -5,9 +5,9 @@ DATABASE_URL={{ lookup('database', application_id, 'url_full') | dotenv_quote }}
|
||||
STORE_MODEL_IN_DB=False
|
||||
UI_USERNAME={{ lookup('users', 'administrator').username }}
|
||||
UI_PASSWORD={{ lookup('config', application_id, 'secrets.credentials.ui_password') | dotenv_quote }}
|
||||
{% if LITELLM_OPENROUTER_API_KEY %}
|
||||
OPENROUTER_API_KEY={{ LITELLM_OPENROUTER_API_KEY | dotenv_quote }}
|
||||
{% endif %}
|
||||
{% for _backend in LITELLM_REMOTE_BACKENDS if _backend.key %}
|
||||
{{ _backend.env }}={{ _backend.key | dotenv_quote }}
|
||||
{% endfor %}
|
||||
{% if LITELLM_UI_SSO | bool %}
|
||||
PROXY_BASE_URL={{ LITELLM_UI_BASE_URL }}
|
||||
PROXY_ADMIN_ID={{ LITELLM_UI_ADMIN }}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{%- set _ollama_models = (lookup('config', 'svc-ai-ollama', 'services.ollama.preload_models') | map(attribute='alias') | list) if 'svc-ai-ollama' in group_names else [] -%}
|
||||
{%- set _lmstudio_declared = lookup('config', 'svc-ai-lmstudio', 'services.lmstudio.preload_models') | map(attribute='alias') | list -%}
|
||||
{%- set _lmstudio_only = _lmstudio_declared | reject('in', _ollama_models) | list -%}
|
||||
{%- set _expected = _ollama_models
|
||||
+ (_lmstudio_only if 'svc-ai-lmstudio' in group_names else [])
|
||||
+ (['openrouter/auto'] if LITELLM_OPENROUTER_API_KEY else []) -%}
|
||||
{%- set _remote = LITELLM_REMOTE_BACKENDS | selectattr('key') | map(attribute='alias') | list -%}
|
||||
{%- set _expected = _ollama_models + (_lmstudio_only if 'svc-ai-lmstudio' in group_names else []) + _remote -%}
|
||||
LITELLM_CONTAINER={{ CLI_LOCAL_CID }}
|
||||
LITELLM_PORT={{ LITELLM_PORT }}
|
||||
LITELLM_MASTER_KEY_B64={{ LITELLM_MASTER_KEY | b64encode }}
|
||||
@@ -12,4 +11,5 @@ LITELLM_CHAT_MODEL_SERVED={{ LITELLM_CHAT_MODEL_SERVED | bool | string | lower }
|
||||
LITELLM_OLLAMA_ENABLED={{ ('svc-ai-ollama' in group_names) | string | lower }}
|
||||
LITELLM_LMSTUDIO_ENABLED={{ ('svc-ai-lmstudio' in group_names) | string | lower }}
|
||||
LITELLM_LMSTUDIO_ALIASES='{{ _lmstudio_only | to_json }}'
|
||||
LITELLM_REMOTE_ALIASES='{{ _remote | to_json }}'
|
||||
LITELLM_EXPECTED_MODELS='{{ _expected | to_json }}'
|
||||
@@ -9,7 +9,22 @@ LITELLM_MAX_OUTPUT_TOKENS: "{{ lookup('config', application_id, 'services.litell
|
||||
LITELLM_UPSTREAM_TIMEOUT: "{{ lookup('config', application_id, 'services.litellm.upstream_timeout') }}"
|
||||
LITELLM_CONFIG_PATH_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'config.yaml' ] | path_join }}"
|
||||
LITELLM_CONFIG_PATH_DOCKER: "{{ lookup('volume', application_id, 'litellm_config').target }}"
|
||||
LITELLM_OPENROUTER_API_KEY: "{{ OPENROUTER_API_KEY }}"
|
||||
LITELLM_OPENAI_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.openai_api_key') }}"
|
||||
LITELLM_ANTHROPIC_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.anthropic_api_key') }}"
|
||||
LITELLM_OPENROUTER_API_KEY: "{{ lookup('config', application_id, 'secrets.credentials.openrouter_api_key') }}"
|
||||
LITELLM_REMOTE_BACKENDS:
|
||||
- alias: openai/gpt-4o-mini
|
||||
model: openai/gpt-4o-mini
|
||||
key: "{{ LITELLM_OPENAI_API_KEY }}"
|
||||
env: OPENAI_API_KEY
|
||||
- alias: anthropic/claude-sonnet-4-5
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
key: "{{ LITELLM_ANTHROPIC_API_KEY }}"
|
||||
env: ANTHROPIC_API_KEY
|
||||
- alias: openrouter/auto
|
||||
model: openrouter/openrouter/auto
|
||||
key: "{{ LITELLM_OPENROUTER_API_KEY }}"
|
||||
env: OPENROUTER_API_KEY
|
||||
LITELLM_UI_ROLE: "web-app-litellm"
|
||||
LITELLM_UI_SSO: "{{ (LITELLM_UI_ROLE in group_names) and (lookup('sso', LITELLM_UI_ROLE, 'is_oidc_native') | bool) }}"
|
||||
LITELLM_UI_BASE_URL: "{{ lookup('tls', LITELLM_UI_ROLE, 'url.base') }}"
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
model {{ LITELLM_CHAT_MODEL }}), so prompts would leave the deployment,
|
||||
use a model the gateway does not serve, or carry a stale virtual key.
|
||||
An empty model means the gateway serves none: neither svc-ai-ollama nor
|
||||
svc-ai-lmstudio is in this deployment and OPENROUTER_API_KEY is empty.
|
||||
svc-ai-lmstudio is in this deployment and no remote provider key is set.
|
||||
|
||||
- name: "🔎 Mattermost | Wait for the Agents plugin to provision its bot account"
|
||||
database_query:
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestLitellmChatModel(unittest.TestCase):
|
||||
self.env.from_string(self.source[name])
|
||||
.render(
|
||||
LITELLM_BACKEND_ROLES=list(roles),
|
||||
OPENROUTER_API_KEY=api_key,
|
||||
AI_REMOTE_ALIASES=(["openrouter/auto"] if api_key else []),
|
||||
lookup=_stub_lookup(
|
||||
[{"alias": alias, "name": alias} for alias in preload_models],
|
||||
[
|
||||
|
||||
@@ -26,6 +26,7 @@ def verdict(**overrides) -> list[str]:
|
||||
"ollama_enabled": False,
|
||||
"lmstudio_enabled": False,
|
||||
"lmstudio_aliases": [LMSTUDIO_ONLY],
|
||||
"remote_aliases": [],
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return probe.evaluate(**kwargs)
|
||||
@@ -155,12 +156,54 @@ class TestBothBackendsDeployment(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestRemoteProviders(unittest.TestCase):
|
||||
"""A configured provider key publishes its alias and serves the gateway."""
|
||||
|
||||
def test_a_keyed_provider_is_the_only_backend_needed(self) -> None:
|
||||
self.assertEqual(
|
||||
verdict(
|
||||
served={"openai/gpt-4o-mini"},
|
||||
expected=["openai/gpt-4o-mini"],
|
||||
chat_model="openai/gpt-4o-mini",
|
||||
chat_model_served=True,
|
||||
remote_aliases=["openai/gpt-4o-mini"],
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_a_remote_alias_is_not_mistaken_for_an_ollama_model(self) -> None:
|
||||
self.assertEqual(
|
||||
verdict(
|
||||
served={"anthropic/claude-sonnet-4-5", ALIAS},
|
||||
expected=["anthropic/claude-sonnet-4-5", ALIAS],
|
||||
chat_model=ALIAS,
|
||||
chat_model_served=True,
|
||||
lmstudio_enabled=True,
|
||||
lmstudio_aliases=[ALIAS],
|
||||
remote_aliases=["anthropic/claude-sonnet-4-5"],
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_a_declared_provider_the_gateway_dropped_fails(self) -> None:
|
||||
failures = verdict(
|
||||
served=set(),
|
||||
expected=["openrouter/auto"],
|
||||
chat_model="openrouter/auto",
|
||||
chat_model_served=True,
|
||||
remote_aliases=["openrouter/auto"],
|
||||
)
|
||||
self.assertTrue(
|
||||
any("does not serve" in failure for failure in failures), failures
|
||||
)
|
||||
|
||||
|
||||
class TestNoBackendDeployment(unittest.TestCase):
|
||||
def test_an_empty_gateway_passes(self) -> None:
|
||||
self.assertEqual(verdict(), [])
|
||||
|
||||
def test_any_published_model_fails(self) -> None:
|
||||
failures = verdict(served={probe.OPENROUTER_MODEL})
|
||||
failures = verdict(served={"openrouter/auto"})
|
||||
self.assertTrue(
|
||||
any("no backend is deployed" in failure for failure in failures), failures
|
||||
)
|
||||
|
||||
@@ -39,13 +39,32 @@ def _stub_lookup(ollama_models, lmstudio_models):
|
||||
return lookup
|
||||
|
||||
|
||||
def render(*, ollama=(), lmstudio=(), api_key=""):
|
||||
REMOTE_BACKENDS = (
|
||||
("openai/gpt-4o-mini", "openai/gpt-4o-mini", "OPENAI_API_KEY"),
|
||||
("anthropic/claude-sonnet-4-5", "anthropic/claude-sonnet-4-5", "ANTHROPIC_API_KEY"),
|
||||
("openrouter/auto", "openrouter/openrouter/auto", "OPENROUTER_API_KEY"),
|
||||
)
|
||||
|
||||
|
||||
def _remote(keys):
|
||||
"""The LITELLM_REMOTE_BACKENDS list vars/main.yml builds, with these keys.
|
||||
|
||||
Args:
|
||||
keys: env-name -> key value; a provider absent from it stays unkeyed.
|
||||
"""
|
||||
return [
|
||||
{"alias": alias, "model": model, "env": env, "key": keys.get(env, "")}
|
||||
for alias, model, env in REMOTE_BACKENDS
|
||||
]
|
||||
|
||||
|
||||
def render(*, ollama=(), lmstudio=(), keys=None):
|
||||
"""The rendered config as a parsed mapping.
|
||||
|
||||
Args:
|
||||
ollama: preload entries svc-ai-ollama declares; empty means not deployed.
|
||||
lmstudio: preload entries svc-ai-lmstudio declares; empty means not deployed.
|
||||
api_key: the OpenRouter key, empty when none is configured.
|
||||
keys: env-name -> provider key for the remote backends.
|
||||
"""
|
||||
group_names = ["svc-ai-litellm"]
|
||||
if ollama:
|
||||
@@ -60,7 +79,7 @@ def render(*, ollama=(), lmstudio=(), api_key=""):
|
||||
LMSTUDIO_BASE_LOCAL_URL=LMSTUDIO_URL,
|
||||
LITELLM_MAX_OUTPUT_TOKENS=512,
|
||||
LITELLM_UPSTREAM_TIMEOUT=60,
|
||||
LITELLM_OPENROUTER_API_KEY=api_key,
|
||||
LITELLM_REMOTE_BACKENDS=_remote(keys or {}),
|
||||
)
|
||||
return yaml.safe_load(
|
||||
rendered
|
||||
@@ -119,8 +138,28 @@ class TestNoBackend(unittest.TestCase):
|
||||
def test_the_model_list_is_empty(self) -> None:
|
||||
self.assertEqual(render()["model_list"], [])
|
||||
|
||||
def test_the_openrouter_key_alone_publishes_its_route(self) -> None:
|
||||
self.assertEqual(set(routes(render(api_key="sk-test"))), {"openrouter/auto"})
|
||||
def test_one_provider_key_alone_publishes_its_route(self) -> None:
|
||||
published = routes(render(keys={"OPENROUTER_API_KEY": "sk-or-test"}))
|
||||
self.assertEqual(set(published), {"openrouter/auto"})
|
||||
self.assertEqual(
|
||||
published["openrouter/auto"]["api_key"], "os.environ/OPENROUTER_API_KEY"
|
||||
)
|
||||
|
||||
def test_each_keyed_provider_gets_its_own_route(self) -> None:
|
||||
published = routes(
|
||||
render(
|
||||
keys={
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
"ANTHROPIC_API_KEY": "sk-ant-test",
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
set(published), {"openai/gpt-4o-mini", "anthropic/claude-sonnet-4-5"}
|
||||
)
|
||||
|
||||
def test_an_unkeyed_provider_publishes_nothing(self) -> None:
|
||||
self.assertEqual(render(keys={"OPENAI_API_KEY": ""})["model_list"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from unittest import TestCase, main, mock
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from utils.cache.files import PROJECT_ROOT
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.handler.vault import VaultScalar
|
||||
from utils.manager.inventory import InventoryManager
|
||||
from utils.manager.inventory import InventoryManager, validate_supplied_value
|
||||
from utils.manager.value_generator import ValueGenerator
|
||||
from utils.roles.mapping import (
|
||||
ROLE_FILE_META_SECRETS,
|
||||
@@ -760,5 +763,84 @@ class TestInventoryManagerVariant(TestCase):
|
||||
self.assertEqual(cfg, {})
|
||||
|
||||
|
||||
class TestSuppliedValueValidation(TestCase):
|
||||
"""A schema that declares a shape must reject a value contradicting it.
|
||||
|
||||
Without this the declaration is decoration: the 192 `validation:` blocks
|
||||
already in the role schemas are enforced nowhere, and a `regex` would have
|
||||
joined them.
|
||||
"""
|
||||
|
||||
SCHEMA: ClassVar[dict[str, str]] = {
|
||||
"type": "string",
|
||||
"regex": "sk-[A-Za-z0-9]{4,}",
|
||||
}
|
||||
|
||||
def test_a_matching_value_passes(self):
|
||||
validate_supplied_value("credentials.api_key", "sk-abcd1234", self.SCHEMA)
|
||||
|
||||
def test_an_empty_value_is_not_configured_and_passes(self):
|
||||
validate_supplied_value("credentials.api_key", "", self.SCHEMA)
|
||||
validate_supplied_value("credentials.api_key", None, self.SCHEMA)
|
||||
|
||||
def test_a_value_failing_the_pattern_aborts(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
validate_supplied_value("credentials.api_key", "nope", self.SCHEMA)
|
||||
|
||||
def test_a_partial_match_is_not_enough(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
validate_supplied_value("credentials.api_key", "sk-ab!!", self.SCHEMA)
|
||||
|
||||
def test_a_value_of_the_wrong_type_aborts(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
validate_supplied_value("credentials.port", 8080, {"type": "string"})
|
||||
|
||||
def test_an_unknown_declared_type_aborts(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
validate_supplied_value("credentials.x", "v", {"type": "uuid"})
|
||||
|
||||
def test_a_schema_declaring_neither_accepts_anything(self):
|
||||
validate_supplied_value("credentials.x", "anything", {"description": "free"})
|
||||
|
||||
|
||||
class TestProviderKeyPattern(TestCase):
|
||||
"""The shipped provider-key pattern must outlive a vendor's format change.
|
||||
|
||||
OpenAI moved from `sk-` to `sk-proj-` without notice. A schema that pinned
|
||||
the prefix would reject a valid key and abort inventory creation, so the
|
||||
pattern checks what a paste can get wrong instead.
|
||||
"""
|
||||
|
||||
ACCEPTED: ClassVar[tuple[str, ...]] = (
|
||||
"sk-proj-AbCdEfGh1234567890xyz",
|
||||
"sk-ant-api03-AbCdEfGh1234567890",
|
||||
"sk-or-v1-AbCdEfGh1234567890abc",
|
||||
"sk-AbCdEfGh1234567890legacyKey",
|
||||
"some-future-format-2099-abcdef",
|
||||
)
|
||||
REJECTED: ClassVar[tuple[str, ...]] = (
|
||||
"sk-proj-AbCdEfGh1234567890xyz\n",
|
||||
"sk-short",
|
||||
"<your-key-here>",
|
||||
"sk-proj-Ab Cd 1234567890 xyz",
|
||||
)
|
||||
|
||||
def _schema(self) -> dict:
|
||||
path = PROJECT_ROOT / "roles/svc-ai-litellm" / ROLE_FILE_META_SECRETS
|
||||
return load_yaml_any(str(path))["credentials"]["openai_api_key"]
|
||||
|
||||
def test_every_known_provider_form_is_accepted(self):
|
||||
schema = self._schema()
|
||||
for value in self.ACCEPTED:
|
||||
with self.subTest(value=value):
|
||||
validate_supplied_value("credentials.openai_api_key", value, schema)
|
||||
|
||||
def test_a_broken_paste_is_rejected(self):
|
||||
schema = self._schema()
|
||||
for value in self.REJECTED:
|
||||
with self.subTest(value=value), self.assertRaises(SystemExit):
|
||||
validate_supplied_value("credentials.openai_api_key", value, schema)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Vendored
+3
@@ -71,6 +71,9 @@ STATIC_KEYS = (
|
||||
"INFINITO_ALIAS_REPOSITORY",
|
||||
"INFINITO_APT_UBUNTU_MIRRORS",
|
||||
"INFINITO_FORK_REPOSITORY_NAME",
|
||||
"INFINITO_API_ANTHROPIC_KEY",
|
||||
"INFINITO_API_OPENAI_KEY",
|
||||
"INFINITO_API_OPENROUTER_KEY",
|
||||
"INFINITO_PLAYWRIGHT_KEEP",
|
||||
"INFINITO_PUBLISH_PORTS",
|
||||
"INFINITO_SKILLS_REPOSITORY",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -19,7 +20,20 @@ from utils.roles.mapping import ROLE_FILE_META_SECRETS, ROLE_FILE_VARS_MAIN
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_CREDENTIAL_LEAF_MARKERS = ("description", "algorithm", "validation", "default")
|
||||
_CREDENTIAL_LEAF_MARKERS = (
|
||||
"description",
|
||||
"algorithm",
|
||||
"validation",
|
||||
"default",
|
||||
"type",
|
||||
"regex",
|
||||
)
|
||||
|
||||
_SCHEMA_TYPES: dict[str, type | tuple[type, ...]] = {
|
||||
"string": str,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
}
|
||||
|
||||
|
||||
def _is_credential_leaf(node: Any) -> bool:
|
||||
@@ -28,6 +42,51 @@ def _is_credential_leaf(node: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def validate_supplied_value(full_key: str, value: Any, meta: dict[str, Any]) -> None:
|
||||
"""Hold an operator-supplied credential to the schema it declares.
|
||||
|
||||
Only supplied values are checked: a generated one comes from the algorithm
|
||||
the same schema names, and the generators take no schema argument.
|
||||
|
||||
Args:
|
||||
full_key: dotted schema path, used in the failure message.
|
||||
value: the value the operator supplied, empty meaning "not configured".
|
||||
meta: the schema leaf, read for `type` and `regex`.
|
||||
|
||||
Raises:
|
||||
SystemExit: the value contradicts the declared type or pattern.
|
||||
"""
|
||||
if value in ("", None):
|
||||
return
|
||||
|
||||
declared = meta.get("type")
|
||||
if declared:
|
||||
expected = _SCHEMA_TYPES.get(str(declared))
|
||||
if expected is None:
|
||||
print(
|
||||
f"ERROR: '{full_key}' declares unknown type '{declared}'; "
|
||||
f"known types are {', '.join(sorted(_SCHEMA_TYPES))}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if not isinstance(value, expected):
|
||||
print(
|
||||
f"ERROR: '{full_key}' is declared {declared} but the supplied "
|
||||
f"value is {type(value).__name__}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
pattern = meta.get("regex")
|
||||
if pattern and not re.fullmatch(str(pattern), str(value)):
|
||||
print(
|
||||
f"ERROR: the value supplied for '{full_key}' does not match the "
|
||||
f"pattern its schema declares: {pattern}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _meta_role_config(role_path: Path) -> dict[str, Any]:
|
||||
"""Assemble the view of a role's config from its meta files.
|
||||
|
||||
@@ -251,6 +310,9 @@ class InventoryManager:
|
||||
* `default:` (Jinja literal) is written verbatim and
|
||||
short-circuits algorithm-based generation. `validation:` is
|
||||
ignored for default-bearing entries.
|
||||
* `type:` and `regex:` hold an operator-supplied value to the
|
||||
shape the schema declares; a generated one is exempt because
|
||||
the algorithm that produced it is named by the same schema.
|
||||
* Existing inventory values are preserved (no double-encryption,
|
||||
no overwrite of operator-supplied secrets).
|
||||
"""
|
||||
@@ -295,6 +357,7 @@ class InventoryManager:
|
||||
|
||||
if "default" in meta:
|
||||
if isinstance(existing_value, str) and existing_value != "":
|
||||
validate_supplied_value(full_key, existing_value, meta)
|
||||
return
|
||||
dest[key] = meta["default"]
|
||||
return
|
||||
@@ -306,7 +369,9 @@ class InventoryManager:
|
||||
if algorithm == "plain":
|
||||
if set_key in self.overrides:
|
||||
plain = self.overrides[set_key]
|
||||
validate_supplied_value(full_key, plain, meta)
|
||||
elif isinstance(existing_value, str) and existing_value != "":
|
||||
validate_supplied_value(full_key, existing_value, meta)
|
||||
return
|
||||
elif self.allow_empty_plain:
|
||||
plain = ""
|
||||
|
||||
Reference in new issue
Block a user