diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index 3e3075cb2..8d11bf503 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -16,6 +16,9 @@ name: Build Images - patches/** - scripts/** - hack/ci-* + - hack/gen-option-catalog.sh + - operators/keystone/api/v1alpha1/catalogs/** + - operators/glance/api/v1alpha1/catalogs/** - .github/actions/** - tests/container-images/** - .github/workflows/build-images.yaml @@ -28,6 +31,9 @@ name: Build Images - patches/** - scripts/** - hack/ci-* + - hack/gen-option-catalog.sh + - operators/keystone/api/v1alpha1/catalogs/** + - operators/glance/api/v1alpha1/catalogs/** - .github/actions/** - tests/container-images/** - .github/workflows/build-images.yaml @@ -566,6 +572,15 @@ jobs: verify-script: ${{ github.event_name == 'pull_request' && format('tests/container-images/verify_{0}.sh', matrix.service) || '' }} verify-image-ref: ${{ steps.tags.outputs.composite }} + - name: Verify option catalog + if: github.event_name == 'pull_request' && (matrix.service == 'keystone' || matrix.service == 'glance') + env: + MATRIX_SERVICE: ${{ matrix.service }} + MATRIX_RELEASE: ${{ matrix.release }} + IMAGE_REF: ${{ steps.tags.outputs.composite }} + SOURCE_DIR: src/${{ matrix.service }} + run: hack/gen-option-catalog.sh --check "$MATRIX_SERVICE" "$MATRIX_RELEASE" "$IMAGE_REF" + # Assembles per-platform service digests into multi-arch manifests with attestation. merge-service-images: if: github.event_name != 'pull_request' @@ -719,3 +734,11 @@ jobs: run: | docker pull "$IMAGE_REF" bash "tests/container-images/verify_${SERVICE_NAME}.sh" "$IMAGE_REF" + + - name: Verify option catalog + if: matrix.service == 'keystone' || matrix.service == 'glance' + env: + MATRIX_SERVICE: ${{ matrix.service }} + MATRIX_RELEASE: ${{ matrix.release }} + IMAGE_REF: ${{ steps.tags.outputs.composite }} + run: hack/gen-option-catalog.sh --check "$MATRIX_SERVICE" "$MATRIX_RELEASE" "$IMAGE_REF" diff --git a/.github/workflows/verify-container-images.yaml b/.github/workflows/verify-container-images.yaml index 20964fa8d..10eeb6f49 100644 --- a/.github/workflows/verify-container-images.yaml +++ b/.github/workflows/verify-container-images.yaml @@ -16,6 +16,9 @@ name: Verify Container Images - patches/** - scripts/** - hack/tempest/** + - hack/gen-option-catalog.sh + - operators/keystone/api/v1alpha1/catalogs/** + - operators/glance/api/v1alpha1/catalogs/** - tests/container-images/** - tests/scripts/** - tests/tempest/test_retry_helpers.py diff --git a/Makefile b/Makefile index 07958af60..68c4328f4 100644 --- a/Makefile +++ b/Makefile @@ -389,6 +389,36 @@ verify-invalid-cr-fixtures: @python3 tests/e2e/glance/invalid-cr/test_generate.py @python3 tests/e2e/glance/invalid-glancebackend-cr/test_generate.py +.PHONY: gen-option-catalogs +# gen-option-catalogs regenerates the per-release option catalogs the +# extraConfig validation checks against. For every releases// directory +# it runs hack/gen-option-catalog.sh for keystone and glance, extracting each +# catalog from the matching shipped service image via that image's own +# oslo-config-generator. Requires docker and network access: it pulls the +# service image when absent and fetches the upstream generator config. It is +# intentionally wired into no aggregate target and no CI job. +gen-option-catalogs: + @for release in $(notdir $(patsubst %/,%,$(wildcard releases/*/))); do \ + for service in keystone glance; do \ + echo "Generating $$service $$release option catalog..."; \ + hack/gen-option-catalog.sh $$service $$release; \ + done; \ + done + +.PHONY: verify-option-catalogs +# verify-option-catalogs asserts the committed option catalogs stay byte-stable +# against a fresh extraction. It runs the same loop as gen-option-catalogs with +# --check, so a drifted or missing catalog fails the target with a diff. Like +# gen-option-catalogs it requires docker and network access and is wired into no +# aggregate target and no CI job. +verify-option-catalogs: + @for release in $(notdir $(patsubst %/,%,$(wildcard releases/*/))); do \ + for service in keystone glance; do \ + echo "Checking $$service $$release option catalog..."; \ + hack/gen-option-catalog.sh --check $$service $$release; \ + done; \ + done + .PHONY: check-feature-ids # check-feature-ids fails if any internal feature / requirement ID (CC-NNNN or # REQ-NNN) appears anywhere in the tracked source tree — code, tests, CI, diff --git a/docs/guides/advanced-configuration.md b/docs/guides/advanced-configuration.md index 268988c8b..315ee2722 100644 --- a/docs/guides/advanced-configuration.md +++ b/docs/guides/advanced-configuration.md @@ -347,9 +347,44 @@ it from the typed `spec.logging.debug` field — so this example now draws an `ExtraConfigOwnedKeyOverride` Warning event and flips `ExtraConfigHealthy` to `False` while still taking effect. -The operator does not validate the content of these sections. A typo becomes a silent -no-op at best and a crash loop at worst — test changes in a lab before rolling out. -A change to `extraConfig` triggers a ConfigMap rehash and a rolling Deployment update. +Beyond the ownership registry, the validating webhook checks every option name in +`spec.extraConfig` against a catalog of the options the service actually accepts. +Each operator embeds one catalog per OpenStack release, generated from the +`oslo-config-generator` run of the exact service image forge ships for that +release. The catalog answers a single question: does this option name exist? +Values are never inspected. Keystone derives the release from `spec.image.tag`; +Glance derives it from `spec.openStackRelease`. An option that sits in a known +section but is absent from the catalog is rejected at apply time with `no such +option in the option catalog`, naming the section and key. An +unknown section is rejected with `no such section in the +option catalog (sections registered by a loaded plugin must be declared via +spec.plugins)`. + +Three classes of section or key are exempt from the catalog check. A section +declared by a `spec.plugins` entry's `configSection` is trusted, because the +plugin owns it and its options are not in the base catalog. Every key in the +operator-ownership registry above is exempt too, since the operator already +governs those. For Glance, the reserved store sections `os_glance_staging_store` +and `os_glance_tasks_store` are additionally allowed. + +The check fails open when it cannot reason about a release. A digest-pinned +Keystone image (or any tag that does not name a release) carries no release to +look up, so the option check is skipped and the admission response returns a +warning. A release newer than the operator build, one with no embedded catalog, +is skipped the same way. A deprecated option that the service still accepts is +admitted with a warning naming its replacement, for example `[DEFAULT] logfile` +superseded by `[DEFAULT] log_file`. + +This check lives only in the webhook. There is no CEL or CRD-schema backstop, so +a cluster with the validating webhook disabled accepts a misspelled option name +and surfaces it only at render time. Updates re-run the check only when +`spec.extraConfig`, the plugin section list, or the release field changes. + +The operator still does not validate the values in these sections, but a +misspelled option name is now rejected at apply time. A wrong value on a real +option becomes a silent no-op at best and a crash loop at worst, so test changes +in a lab before rolling out. A change to `extraConfig` triggers a ConfigMap +rehash and a rolling Deployment update. --- diff --git a/docs/reference/glance/glance-crd.md b/docs/reference/glance/glance-crd.md index 8ddc060fb..fcde5340f 100644 --- a/docs/reference/glance/glance-crd.md +++ b/docs/reference/glance/glance-crd.md @@ -38,7 +38,7 @@ stores are **not** part of this spec — they attach out-of-band through | `policyOverrides` | `*PolicySpec` | no | Custom oslo.policy rules. A CEL rule requires at least one of `rules` or `configMapRef`; when set, the operator renders a `policy.yaml` and wires `oslo_policy.policy_file` | | `middleware` | `[]MiddlewareSpec` | no | WSGI middleware filters injected into the `api-paste.ini` pipeline | | `plugins` | `[]PluginSpec` | no | Service plugins/drivers, modeled as a list-map keyed by `configSection` so duplicate sections are rejected structurally | -| `extraConfig` | `map[string]map[string]string` | no | Free-form INI sections for configuration not covered by explicit fields — the escape hatch for import/staging tuning. The render-time merge follows `plugins < operator defaults < extraConfig` (each stage merged key-wise), so user values win over both. Overrides of operator-owned keys are honored but reported (report-only) via the `ExtraConfigHealthy` condition and an `ExtraConfigOwnedKeyOverride` Warning event — except `[keystone_authtoken] password`, which the validating webhook rejects at admission so the env-injected service password never leaks into the namespace-readable ConfigMap | +| `extraConfig` | `map[string]map[string]string` | no | Free-form INI sections for configuration not covered by explicit fields — the escape hatch for import/staging tuning. The render-time merge follows `plugins < operator defaults < extraConfig` (each stage merged key-wise), so user values win over both. Overrides of operator-owned keys are honored but reported (report-only) via the `ExtraConfigHealthy` condition and an `ExtraConfigOwnedKeyOverride` Warning event — except `[keystone_authtoken] password`, which the validating webhook rejects at admission so the env-injected service password never leaks into the namespace-readable ConfigMap. Option names are validated at admission against a per-release option catalog embedded in the operator (release derived from `spec.openStackRelease`; a release with no embedded catalog skips the check with an admission warning). Sections declared by `spec.plugins`, operator-owned keys, and the reserved store sections `os_glance_staging_store` and `os_glance_tasks_store` are exempt. An unknown section or option is rejected with the section and key named, so an arbitrary backend-named section is refused; backend options belong to the [`GlanceBackend`](./glance-backend-crd.md) CR. A deprecated-but-accepted option is admitted with a warning naming its replacement. Values are never checked, and the rule is webhook-only with no CEL backstop | ### ServiceUserSpec diff --git a/docs/reference/keystone/keystone-crd.md b/docs/reference/keystone/keystone-crd.md index 1a46cddd0..51c242960 100644 --- a/docs/reference/keystone/keystone-crd.md +++ b/docs/reference/keystone/keystone-crd.md @@ -167,7 +167,7 @@ status: | `gateway` | [`*GatewaySpec`](#gatewayspec) | No | `nil` | Gateway API HTTPRoute configuration. When set, an HTTPRoute is created targeting the `{name}` Service on port 5000 and attached to the referenced pre-existing Gateway; `status.endpoint` is updated to `https://{hostname}/v3`. When removed, the HTTPRoute is deleted and `status.endpoint` reverts to the cluster-local Service URL. | | `uwsgi` | [`*UWSGISpec`](#uwsgispec) | No | `nil` | uWSGI application server parameters. When set, the operator uses these values for the Deployment container command. When `nil`, hardcoded defaults (processes=2, threads=1, httpKeepAlive=true) are used in the reconciler. | | `logging` | [`*LoggingSpec`](#loggingspec) | No | See below | oslo.log configuration for the Keystone API container. When `nil`, the defaulting webhook materializes a baseline (`format=text`, `level=INFO`, `debug=false`, no per-logger overrides) so downstream reconciler code never sees a nil pointer. When set, zero-valued sub-fields are partially filled with the same baseline. | -| `extraConfig` | `map[string]map[string]string` | No | `nil` | Free-form INI sections for additional configuration. The render-time merge follows `plugins < operator defaults < extraConfig` (each stage merged key-wise), so `extraConfig` wins over both. Overrides of operator-owned keys are honored but reported via the `ExtraConfigHealthy` condition and an `ExtraConfigOwnedKeyOverride` Warning event. | +| `extraConfig` | `map[string]map[string]string` | No | `nil` | Free-form INI sections for additional configuration. The render-time merge follows `plugins < operator defaults < extraConfig` (each stage merged key-wise), so `extraConfig` wins over both. Overrides of operator-owned keys are honored but reported via the `ExtraConfigHealthy` condition and an `ExtraConfigOwnedKeyOverride` Warning event. Option names are validated at admission against a per-release option catalog embedded in the operator (release derived from `spec.image.tag`; a digest-pinned image or a release with no embedded catalog skips the check with an admission warning). Sections declared by `spec.plugins` and operator-owned keys are exempt; an unknown section or option is rejected with the section and key named, and a deprecated-but-accepted option is admitted with a warning naming its replacement. Values are never checked, and the rule is webhook-only with no CEL backstop. | ### DeploymentSpec diff --git a/hack/gen-option-catalog.sh b/hack/gen-option-catalog.sh new file mode 100755 index 000000000..c967cbae3 --- /dev/null +++ b/hack/gen-option-catalog.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# hack/gen-option-catalog.sh — Extract a per-release option catalog from a +# shipped service image. +# +# Runs the service image's own oslo-config-generator (--format json) against +# the upstream generator config for the pinned release, then post-processes the +# machine-readable output into the compact catalog format consumed by +# internal/common/config.ParseOptionCatalog. The machine-readable output spells +# option names with dashes; the catalog holds the underscore form oslo.config +# reads from a file, so names are normalized on the way out. +# +# Usage: +# hack/gen-option-catalog.sh [--check] [image-ref] +# +# Without --check the catalog is written to +# operators//api/v1alpha1/catalogs/.json. With --check the +# freshly generated catalog is diffed against the committed file and any +# difference (or a missing committed file) is a non-zero exit — the committed +# files are byte-stable caches verified this way. +# +# Host dependencies: docker, curl, yq. All Python runs inside the service image +# so no host Python is required (macOS-safe). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +usage() { + cat >&2 <<'EOF' +Usage: hack/gen-option-catalog.sh [--check] [image-ref] + + --check diff the generated catalog against the committed file instead + of writing it; non-zero exit on any difference. + service keystone or glance. + release release directory name (e.g. 2025.2). + image-ref service image to extract from + (default: ghcr.io/c5c3/:). +EOF +} + +# --------------------------------------------------------------------------- +# 1. Parse arguments +# --------------------------------------------------------------------------- +CHECK=false +if [[ "${1:-}" == "--check" ]]; then + CHECK=true + shift +fi +SERVICE="${1:-}" +RELEASE="${2:-}" +IMAGE_REF="${3:-}" +if [[ -z "${SERVICE}" || -z "${RELEASE}" ]]; then + usage + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2. Resolve the upstream source ref (before the service->path mapping so an +# unlisted service reports the missing ref, not an unsupported service). +# --------------------------------------------------------------------------- +REFS_FILE="${REPO_ROOT}/releases/${RELEASE}/source-refs.yaml" +SERVICE_REF="" +if [[ -f "${REFS_FILE}" ]]; then + SERVICE_REF="$(yq ".\"${SERVICE}\"" "${REFS_FILE}")" +fi +if [[ -z "${SERVICE_REF}" || "${SERVICE_REF}" == "null" ]]; then + echo "no source ref for ${SERVICE} in releases/${RELEASE}/source-refs.yaml" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. Map the service to its oslo-config-generator config path. +# --------------------------------------------------------------------------- +case "${SERVICE}" in + keystone) GEN_CONFIG_PATH="config-generator/keystone.conf" ;; + glance) GEN_CONFIG_PATH="etc/oslo-config-generator/glance-api.conf" ;; + *) + echo "unsupported service ${SERVICE}: no oslo-config-generator config mapping" >&2 + exit 1 + ;; +esac + +WORK_DIR="$(mktemp -d "/tmp/gen-option-catalog.XXXXXX")" +trap 'rm -rf "${WORK_DIR}"' EXIT +# mktemp -d creates the directory 0700 for the invoking user, but the generator +# reads the bind-mounted /work as the image's non-root user, so the directory +# and the config it holds must be world-readable. +chmod 755 "${WORK_DIR}" + +# --------------------------------------------------------------------------- +# 4. Obtain the upstream generator config. SOURCE_DIR points at a local source +# checkout; otherwise fetch the file from the pinned ref on GitHub. +# --------------------------------------------------------------------------- +if [[ -n "${SOURCE_DIR:-}" ]]; then + SRC_CONFIG="${SOURCE_DIR}/${GEN_CONFIG_PATH}" + if [[ ! -f "${SRC_CONFIG}" ]]; then + echo "generator config not found: ${SRC_CONFIG}" >&2 + exit 1 + fi + cp "${SRC_CONFIG}" "${WORK_DIR}/upstream.conf" +else + if ! curl -fsSL \ + "https://raw.githubusercontent.com/openstack/${SERVICE}/${SERVICE_REF}/${GEN_CONFIG_PATH}" \ + -o "${WORK_DIR}/upstream.conf"; then + echo "fetching generator config for ${SERVICE} at ${SERVICE_REF} failed" >&2 + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# 5. Build the generator config: a [DEFAULT] header plus only the namespace +# lines from the upstream config (dropping output_file/wrap_width so the +# generator writes JSON to stdout). Commented namespaces are excluded. +# --------------------------------------------------------------------------- +NAMESPACE_LINES="$(grep -E '^[[:space:]]*namespace[[:space:]]*=' "${WORK_DIR}/upstream.conf" || true)" +{ + echo "[DEFAULT]" + printf '%s\n' "${NAMESPACE_LINES}" +} > "${WORK_DIR}/gen.conf" +chmod 644 "${WORK_DIR}/gen.conf" + +# --------------------------------------------------------------------------- +# 6. Resolve the image ref and ensure it is present locally. +# --------------------------------------------------------------------------- +IMAGE_REF="${IMAGE_REF:-ghcr.io/c5c3/${SERVICE}:${RELEASE}}" +if ! docker image inspect "${IMAGE_REF}" >/dev/null 2>&1; then + docker pull "${IMAGE_REF}" +fi + +# --------------------------------------------------------------------------- +# 7. Run the generator inside the image. A namespace listed but not installed +# (e.g. glance's os_brick) degrades to a stevedore warning on stderr, which +# we let pass through; only a non-zero generator exit is fatal. +# --------------------------------------------------------------------------- +docker run --rm -v "${WORK_DIR}:/work" "${IMAGE_REF}" \ + /var/lib/openstack/bin/oslo-config-generator \ + --config-file /work/gen.conf --format json > "${WORK_DIR}/raw.json" + +# --------------------------------------------------------------------------- +# 8./9. Transform the raw JSON into the compact catalog using the image's own +# Python (stdlib json only). The plausibility floor is enforced here so a +# truncated run exits non-zero before anything is written. +# --------------------------------------------------------------------------- +TRANSFORM=$(cat <<'PYEOF' +import json +import sys + + +def normalize(name): + # oslo emits raw option names with dashes; a config file spells them with + # underscores, which is the form the catalog must hold. + return name.replace("-", "_") + + +service = sys.argv[1] +release = sys.argv[2] +options = json.load(sys.stdin)["options"] + +opts_by_section = {} # section -> set of current option names +removal = {} # section -> set of names deprecated for removal (no rename) +renamed = {} # section -> {old_name: "[current_section] current_name"} + +for section, group in options.items(): + for opt in group["opts"]: + current = normalize(opt["name"]) + opts_by_section.setdefault(section, set()).add(current) + deprecated_opts = opt.get("deprecated_opts") or [] + for old in deprecated_opts: + old_section = old.get("group") or section or "DEFAULT" + old_name = normalize(old["name"]) + renamed.setdefault(old_section, {})[old_name] = "[%s] %s" % (section, current) + # A live option that is being removed with no replacement records a + # bare deprecation marker under its own name, but only survives below + # if that name is not itself a live opt in the section (opts wins). + if opt.get("deprecated_for_removal") and not deprecated_opts: + removal.setdefault(section, set()).add(current) + +sections = {} +for section in set(opts_by_section) | set(removal) | set(renamed): + live = opts_by_section.get(section, set()) + deprecated = {} + for name in removal.get(section, set()): + deprecated[name] = "" + # A rename carries a replacement and outranks a bare removal marker. + for name, replacement in renamed.get(section, {}).items(): + deprecated[name] = replacement + # A name that is a live opt never doubles as a deprecated key. + deprecated = {name: value for name, value in deprecated.items() if name not in live} + sections[section] = {"opts": sorted(live), "deprecated": deprecated} + +total = sum(len(section["opts"]) for section in sections.values()) +if len(sections) < 10 or total < 100 or "DEFAULT" not in sections: + sys.stderr.write( + "plausibility floor not met for %s %s: sections=%d options=%d default=%s\n" + % (service, release, len(sections), total, "DEFAULT" in sections) + ) + sys.exit(1) + +catalog = {"service": service, "release": release, "sections": sections} +sys.stdout.write(json.dumps(catalog, indent=2, sort_keys=True) + "\n") +PYEOF +) + +docker run -i --rm "${IMAGE_REF}" /var/lib/openstack/bin/python3 -c "${TRANSFORM}" \ + "${SERVICE}" "${RELEASE}" < "${WORK_DIR}/raw.json" > "${WORK_DIR}/catalog.json" + +# --------------------------------------------------------------------------- +# 10. Write the catalog, or (in --check mode) diff it against the committed one. +# --------------------------------------------------------------------------- +CATALOG_DIR="${REPO_ROOT}/operators/${SERVICE}/api/v1alpha1/catalogs" +CATALOG_FILE="${CATALOG_DIR}/${RELEASE}.json" + +if [[ "${CHECK}" == true ]]; then + if [[ ! -f "${CATALOG_FILE}" ]]; then + echo "committed catalog missing: ${CATALOG_FILE}" >&2 + diff -u /dev/null "${WORK_DIR}/catalog.json" || true + exit 1 + fi + if ! diff -u "${CATALOG_FILE}" "${WORK_DIR}/catalog.json"; then + exit 1 + fi + echo "catalog up to date: ${CATALOG_FILE}" +else + mkdir -p "${CATALOG_DIR}" + mv "${WORK_DIR}/catalog.json" "${CATALOG_FILE}" + echo "wrote ${CATALOG_FILE}" +fi diff --git a/internal/common/config/catalog.go b/internal/common/config/catalog.go new file mode 100644 index 000000000..5a79c5232 --- /dev/null +++ b/internal/common/config/catalog.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "sort" + + "github.com/c5c3/forge/internal/common/release" +) + +// CatalogSection is one INI section of a per-release option catalog. Opts lists +// the option names oslo.config accepts in that section, spelled exactly as they +// appear when read from a file (the underscore form). Deprecated maps a still +// accepted but deprecated option name to its replacement, so a rendered override +// of a deprecated option is honored while callers can surface the migration. +type CatalogSection struct { + Opts []string `json:"opts"` + Deprecated map[string]string `json:"deprecated"` +} + +// OptionCatalog is the set of option names a single OpenStack service accepts in +// a single release. Service is the service name ("keystone", "glance"), Release +// is the release identifier the catalog was generated for, and Sections maps an +// INI section name to the options it accepts. It is the release-pinned reference +// that FindUnknownOptions validates a merged configuration against. +type OptionCatalog struct { + Service string `json:"service"` + Release string `json:"release"` + Sections map[string]CatalogSection `json:"sections"` +} + +// ParseOptionCatalog decodes the JSON option-catalog document in data into an +// OptionCatalog. It wraps an unmarshal failure as "parsing option catalog: %w" +// and rejects a document that carries no sections (a nil or empty Sections map) +// with "option catalog has no sections", since a catalog with nothing to +// validate against is never a usable reference. +func ParseOptionCatalog(data []byte) (*OptionCatalog, error) { + var catalog OptionCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("parsing option catalog: %w", err) + } + if len(catalog.Sections) == 0 { + return nil, errors.New("option catalog has no sections") + } + return &catalog, nil +} + +// MustParseEmbeddedCatalogs parses every file under the "catalogs" directory of +// fsys into a map keyed by each file's YYYY.N stem. It panics on any read or parse +// failure: the catalogs are build artifacts vendored into an operator, so a broken +// or missing catalog is a build defect that must fail loudly at startup rather +// than a runtime condition to tolerate. A unit test parses the same files, so the +// panic path can never first surface in production. +func MustParseEmbeddedCatalogs(fsys fs.FS) map[string]*OptionCatalog { + entries, err := fs.ReadDir(fsys, "catalogs") + if err != nil { + panic(fmt.Sprintf("reading embedded catalogs directory: %v", err)) + } + catalogs := make(map[string]*OptionCatalog, len(entries)) + for _, entry := range entries { + name := entry.Name() + data, err := fs.ReadFile(fsys, "catalogs/"+name) + if err != nil { + panic(fmt.Sprintf("reading embedded catalog %q: %v", name, err)) + } + catalog, err := ParseOptionCatalog(data) + if err != nil { + panic(fmt.Sprintf("parsing embedded catalog %q: %v", name, err)) + } + // Strip the ".json" extension to recover the YYYY.N stem. + stem := name[:len(name)-len(".json")] + catalogs[stem] = catalog + } + return catalogs +} + +// LookupCatalog returns the option catalog in catalogs for the OpenStack release +// named by tag. The tag is parsed as a release version (YYYY.N with an optional +// -pN patch suffix); the patch suffix is stripped before lookup, so a patch +// release shares its base release's catalog. It returns (nil, false) when the tag +// does not name a release (an empty tag, a digest pin, or "latest") or when +// catalogs holds no entry for the resolved YYYY.N base. +func LookupCatalog(catalogs map[string]*OptionCatalog, tag string) (*OptionCatalog, bool) { + rel, err := release.ParseRelease(tag) + if err != nil { + return nil, false + } + base := fmt.Sprintf("%d.%d", rel.Year, rel.Minor) + catalog, ok := catalogs[base] + if !ok { + return nil, false + } + return catalog, true +} + +// CatalogExemptions carves the parts of a merged configuration that a catalog +// cannot judge out of the unknown-option scan. Sections names INI sections +// skipped whole because their option set is not enumerable from the release +// catalog: oslo plugin sections and per-service dynamically named sections. Keys +// maps a section name to the option names skipped individually within it, used +// to carry the ownership registry so an operator-owned key never doubles as an +// unknown option. +type CatalogExemptions struct { + Sections map[string]struct{} + Keys map[string]map[string]struct{} +} + +// KeyExemptionsFromRegistry builds the (section, key) exemption set consumed as +// CatalogExemptions.Keys from an OwnedKey registry. Every registry entry is +// included regardless of its Rejected value, because an operator-owned key is +// out of the catalog's scope whether the webhook rejects the override or only +// reports it. A nil registry yields an empty, non-nil map. +func KeyExemptionsFromRegistry(registry []OwnedKey) map[string]map[string]struct{} { + pairs := make(map[string]map[string]struct{}) + for _, entry := range registry { + if pairs[entry.Section] == nil { + pairs[entry.Section] = make(map[string]struct{}) + } + pairs[entry.Section][entry.Key] = struct{}{} + } + return pairs +} + +// UnknownOption is a configuration option the catalog does not accept. Section +// and Key locate the override; SectionUnknown is true when the whole INI section +// is absent from the catalog and false when the section exists but does not list +// the key. +type UnknownOption struct { + Section string + Key string + SectionUnknown bool +} + +// DeprecatedOption is a configuration option the catalog still accepts but marks +// as deprecated. Section and Key locate the override; Replacement is the option +// name the catalog records as its successor. +type DeprecatedOption struct { + Section string + Key string + Replacement string +} + +// FindUnknownOptions validates extraConfig against the catalog and returns the +// options it does not accept and the deprecated-but-accepted options it carries. +// It is a pure function with no error path: a nil or empty extraConfig returns +// (nil, nil). +// +// A section named in ex.Sections is skipped whole; a (section, key) pair whose +// key is in ex.Keys[section] is skipped individually. For a remaining key, a +// section absent from the catalog yields an UnknownOption with SectionUnknown +// true, a key the section lists in neither Opts nor Deprecated yields an +// UnknownOption with SectionUnknown false, a key present only in Deprecated +// yields a DeprecatedOption carrying its replacement, and a key in Opts is +// accepted silently. Keys are compared verbatim, so a dash-spelled key is +// unknown by design because the catalog holds the underscore form. Both slices +// come back sorted by section then key. +func (c *OptionCatalog) FindUnknownOptions(extraConfig map[string]map[string]string, ex CatalogExemptions) ([]UnknownOption, []DeprecatedOption) { + if len(extraConfig) == 0 { + return nil, nil + } + + var unknown []UnknownOption + var deprecated []DeprecatedOption + for section, keys := range extraConfig { + if _, skip := ex.Sections[section]; skip { + continue + } + exemptKeys := ex.Keys[section] + catalogSection, known := c.Sections[section] + // Materialize the section's accepted-option set once on entry so the + // per-key lookup below never rescans the Opts slice. + var opts map[string]struct{} + if known { + opts = make(map[string]struct{}, len(catalogSection.Opts)) + for _, opt := range catalogSection.Opts { + opts[opt] = struct{}{} + } + } + for key := range keys { + if _, exempt := exemptKeys[key]; exempt { + continue + } + if !known { + unknown = append(unknown, UnknownOption{Section: section, Key: key, SectionUnknown: true}) + continue + } + if _, ok := opts[key]; ok { + continue + } + if replacement, ok := catalogSection.Deprecated[key]; ok { + deprecated = append(deprecated, DeprecatedOption{Section: section, Key: key, Replacement: replacement}) + continue + } + unknown = append(unknown, UnknownOption{Section: section, Key: key}) + } + } + + sort.Slice(unknown, func(i, j int) bool { + if unknown[i].Section != unknown[j].Section { + return unknown[i].Section < unknown[j].Section + } + return unknown[i].Key < unknown[j].Key + }) + sort.Slice(deprecated, func(i, j int) bool { + if deprecated[i].Section != deprecated[j].Section { + return deprecated[i].Section < deprecated[j].Section + } + return deprecated[i].Key < deprecated[j].Key + }) + return unknown, deprecated +} diff --git a/internal/common/config/catalog_test.go b/internal/common/config/catalog_test.go new file mode 100644 index 000000000..861d90511 --- /dev/null +++ b/internal/common/config/catalog_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestParseOptionCatalog_Valid(t *testing.T) { + g := NewGomegaWithT(t) + + data := []byte(`{ + "service": "keystone", + "release": "2026.1", + "sections": { + "DEFAULT": { + "opts": ["debug", "log_dir"], + "deprecated": {"verbose": "debug"} + }, + "token": { + "opts": ["provider", "expiration"] + } + } + }`) + + catalog, err := ParseOptionCatalog(data) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(catalog.Service).To(Equal("keystone")) + g.Expect(catalog.Release).To(Equal("2026.1")) + g.Expect(catalog.Sections).To(HaveKey("DEFAULT")) + g.Expect(catalog.Sections["DEFAULT"].Opts).To(ConsistOf("debug", "log_dir")) + g.Expect(catalog.Sections["DEFAULT"].Deprecated).To(Equal(map[string]string{"verbose": "debug"})) + g.Expect(catalog.Sections["token"].Opts).To(ConsistOf("provider", "expiration")) +} + +func TestParseOptionCatalog_MalformedJSON(t *testing.T) { + g := NewGomegaWithT(t) + + _, err := ParseOptionCatalog([]byte(`{"service": "keystone",`)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("parsing option catalog:")) +} + +func TestParseOptionCatalog_NoSections(t *testing.T) { + g := NewGomegaWithT(t) + + // The sections member is absent entirely. + _, err := ParseOptionCatalog([]byte(`{"service": "keystone", "release": "2026.1"}`)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("option catalog has no sections")) + + // The sections member is present but empty. + _, err = ParseOptionCatalog([]byte(`{"service": "keystone", "sections": {}}`)) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("option catalog has no sections")) +} + +// testCatalog is the reference catalog reused across FindUnknownOptions cases: +// a known "DEFAULT" section with an opt and a deprecated option, plus a known +// "token" section. It deliberately has no "memcache" section so the plugin/ +// dynamic-section exemption paths can be exercised. +func testCatalog() *OptionCatalog { + return &OptionCatalog{ + Service: "keystone", + Release: "2026.1", + Sections: map[string]CatalogSection{ + "DEFAULT": { + Opts: []string{"debug", "log_dir"}, + Deprecated: map[string]string{"verbose": "debug"}, + }, + "token": { + Opts: []string{"provider", "expiration"}, + }, + }, + } +} + +func TestFindUnknownOptions_EmptyInputs(t *testing.T) { + catalog := testCatalog() + + tests := []struct { + name string + extraConfig map[string]map[string]string + }{ + {name: "nil extraConfig", extraConfig: nil}, + {name: "empty non-nil map", extraConfig: map[string]map[string]string{}}, + { + name: "section with empty key map", + extraConfig: map[string]map[string]string{"DEFAULT": {}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewGomegaWithT(t) + unknown, deprecated := catalog.FindUnknownOptions(tt.extraConfig, CatalogExemptions{}) + g.Expect(unknown).To(BeNil()) + g.Expect(deprecated).To(BeNil()) + }) + } +} + +func TestFindUnknownOptions_UnknownKeyInKnownSection(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // "log_level" is not listed by the known "DEFAULT" section. + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "DEFAULT": {"log_level": "INFO"}, + }, CatalogExemptions{}) + + g.Expect(deprecated).To(BeNil()) + g.Expect(unknown).To(Equal([]UnknownOption{ + {Section: "DEFAULT", Key: "log_level", SectionUnknown: false}, + })) +} + +func TestFindUnknownOptions_UnknownSection(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // The catalog has no "cache" section, so every key is section-unknown. + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "cache": {"enabled": "true", "backend": "dogpile"}, + }, CatalogExemptions{}) + + g.Expect(deprecated).To(BeNil()) + g.Expect(unknown).To(Equal([]UnknownOption{ + {Section: "cache", Key: "backend", SectionUnknown: true}, + {Section: "cache", Key: "enabled", SectionUnknown: true}, + })) +} + +func TestFindUnknownOptions_SortedBySectionThenKey(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // Sections and keys are chosen so map-iteration order would not already be + // sorted: "zeta" before "alpha", "yankee" before "alpha" within each. + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "zeta": {"yankee": "1", "alpha": "2"}, + "alpha": {"yankee": "3", "alpha": "4"}, + }, CatalogExemptions{}) + + g.Expect(deprecated).To(BeNil()) + g.Expect(unknown).To(Equal([]UnknownOption{ + {Section: "alpha", Key: "alpha", SectionUnknown: true}, + {Section: "alpha", Key: "yankee", SectionUnknown: true}, + {Section: "zeta", Key: "alpha", SectionUnknown: true}, + {Section: "zeta", Key: "yankee", SectionUnknown: true}, + })) +} + +func TestFindUnknownOptions_KeyExemptionPrecedence(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // The [memcache] shape: the ownership registry owns "servers" in a section + // the catalog does not know. The owned key passes while its sibling is still + // reported as section-unknown. + exemptions := CatalogExemptions{ + Keys: KeyExemptionsFromRegistry([]OwnedKey{ + {Section: "memcache", Key: "servers", OwnedBy: "operator-computed"}, + }), + } + + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "memcache": {"servers": "127.0.0.1:11211", "dead_retry": "60"}, + }, exemptions) + + g.Expect(deprecated).To(BeNil()) + g.Expect(unknown).To(Equal([]UnknownOption{ + {Section: "memcache", Key: "dead_retry", SectionUnknown: true}, + })) +} + +func TestFindUnknownOptions_SectionExemptionSkipsWhole(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // A plugin section listed in ex.Sections is skipped whole even though its + // keys are absent from the catalog. + exemptions := CatalogExemptions{ + Sections: map[string]struct{}{"oslo_messaging_rabbit": {}}, + } + + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "oslo_messaging_rabbit": {"rabbit_qos_prefetch_count": "0", "heartbeat_timeout_threshold": "60"}, + }, exemptions) + + g.Expect(unknown).To(BeNil()) + g.Expect(deprecated).To(BeNil()) +} + +func TestFindUnknownOptions_DeprecatedAccepted(t *testing.T) { + g := NewGomegaWithT(t) + catalog := testCatalog() + + // "verbose" lives only in DEFAULT's Deprecated map: accepted, not unknown, + // and returned with its replacement. + unknown, deprecated := catalog.FindUnknownOptions(map[string]map[string]string{ + "DEFAULT": {"verbose": "true"}, + }, CatalogExemptions{}) + + g.Expect(unknown).To(BeNil()) + g.Expect(deprecated).To(Equal([]DeprecatedOption{ + {Section: "DEFAULT", Key: "verbose", Replacement: "debug"}, + })) +} + +func TestKeyExemptionsFromRegistry_NilIsEmptyNonNil(t *testing.T) { + g := NewGomegaWithT(t) + + pairs := KeyExemptionsFromRegistry(nil) + g.Expect(pairs).NotTo(BeNil()) + g.Expect(pairs).To(BeEmpty()) +} + +func TestKeyExemptionsFromRegistry_IncludesRejectedAndReported(t *testing.T) { + g := NewGomegaWithT(t) + + // Both a rejected-class and a reported-class entry must land in the pair set. + pairs := KeyExemptionsFromRegistry([]OwnedKey{ + {Section: "database", Key: "connection", Rejected: true, OwnedBy: "operator-computed"}, + {Section: "token", Key: "provider", Rejected: false, OwnedBy: "operator-computed"}, + }) + + g.Expect(pairs).To(Equal(map[string]map[string]struct{}{ + "database": {"connection": {}}, + "token": {"provider": {}}, + })) +} diff --git a/operators/glance/api/v1alpha1/catalogs/2025.2.json b/operators/glance/api/v1alpha1/catalogs/2025.2.json new file mode 100644 index 000000000..562ec0180 --- /dev/null +++ b/operators/glance/api/v1alpha1/catalogs/2025.2.json @@ -0,0 +1,648 @@ +{ + "release": "2025.2", + "sections": { + "DEFAULT": { + "deprecated": { + "container_formats": "[image_format] container_formats", + "disk_formats": "[image_format] disk_formats", + "log_config": "[DEFAULT] log_config_append", + "logdir": "[DEFAULT] log_dir", + "logfile": "[DEFAULT] log_file", + "rpc_thread_pool_size": "[DEFAULT] executor_thread_pool_size", + "task_time_to_live": "[task] task_time_to_live" + }, + "opts": [ + "allow_anonymous_access", + "api_limit_max", + "backlog", + "bind_host", + "bind_port", + "client_socket_timeout", + "control_exchange", + "debug", + "default_log_levels", + "default_publisher_id", + "delayed_delete", + "digest_algorithm", + "disabled_notifications", + "do_secure_hash", + "enabled_backends", + "enabled_import_methods", + "executor_thread_pool_size", + "fatal_deprecations", + "hashing_algorithm", + "http_keepalive", + "http_retries", + "image_cache_dir", + "image_cache_driver", + "image_cache_max_size", + "image_cache_sqlite_db", + "image_cache_stall_time", + "image_location_quota", + "image_member_quota", + "image_property_quota", + "image_size_cap", + "image_tag_quota", + "instance_format", + "instance_uuid_format", + "limit_param_default", + "log_color", + "log_config_append", + "log_date_format", + "log_dir", + "log_file", + "log_rotate_interval", + "log_rotate_interval_type", + "log_rotation_type", + "logging_context_format_string", + "logging_debug_format_suffix", + "logging_default_format_string", + "logging_exception_prefix", + "logging_user_identity_format", + "max_header_line", + "max_logfile_count", + "max_logfile_size_mb", + "max_request_id_length", + "metadata_encryption_key", + "node_staging_uri", + "property_protection_file", + "property_protection_rule_format", + "public_endpoint", + "publish_errors", + "pydev_worker_debug_host", + "pydev_worker_debug_port", + "rate_limit_burst", + "rate_limit_except_level", + "rate_limit_interval", + "rpc_ping_enabled", + "rpc_response_timeout", + "scrub_pool_size", + "scrub_time", + "show_image_direct_url", + "show_multiple_locations", + "syslog_log_facility", + "tcp_keepidle", + "transport_url", + "use_journal", + "use_json", + "use_keystone_limits", + "use_stderr", + "use_syslog", + "user_storage_quota", + "watch_log_file", + "worker_self_reference_url", + "workers" + ] + }, + "barbican": { + "deprecated": {}, + "opts": [ + "auth_endpoint", + "barbican_api_version", + "barbican_endpoint", + "barbican_endpoint_type", + "barbican_region_name", + "number_of_retries", + "retry_delay", + "send_service_user_token", + "verify_ssl", + "verify_ssl_path" + ] + }, + "barbican_service_user": { + "deprecated": { + "auth_plugin": "[barbican_service_user] auth_type" + }, + "opts": [ + "auth_section", + "auth_type", + "cafile", + "certfile", + "collect_timing", + "insecure", + "keyfile", + "split_loggers", + "timeout" + ] + }, + "cors": { + "deprecated": {}, + "opts": [ + "allow_credentials", + "allow_headers", + "allow_methods", + "allowed_origin", + "expose_headers", + "max_age" + ] + }, + "database": { + "deprecated": {}, + "opts": [ + "asyncio_connection", + "asyncio_slave_connection", + "backend", + "connection", + "connection_debug", + "connection_parameters", + "connection_recycle_time", + "connection_trace", + "db_inc_retry_interval", + "db_max_retries", + "db_max_retry_interval", + "db_retry_interval", + "max_overflow", + "max_pool_size", + "max_retries", + "mysql_sql_mode", + "mysql_wsrep_sync_wait", + "pool_timeout", + "retry_interval", + "slave_connection", + "sqlite_synchronous", + "use_db_reconnect" + ] + }, + "file": { + "deprecated": {}, + "opts": [ + "filesystem_store_chunk_size", + "filesystem_store_datadir", + "filesystem_store_datadirs", + "filesystem_store_file_perm", + "filesystem_store_metadata_file", + "filesystem_thin_provisioning" + ] + }, + "glance.store.http.store": { + "deprecated": {}, + "opts": [ + "http_proxy_information", + "https_ca_certificates_file", + "https_insecure" + ] + }, + "glance.store.rbd.store": { + "deprecated": {}, + "opts": [ + "rados_connect_timeout", + "rbd_store_ceph_conf", + "rbd_store_chunk_size", + "rbd_store_pool", + "rbd_store_user", + "rbd_thin_provisioning" + ] + }, + "glance.store.s3.store": { + "deprecated": {}, + "opts": [ + "s3_store_access_key", + "s3_store_bucket", + "s3_store_bucket_url_format", + "s3_store_cacert", + "s3_store_create_bucket_on_put", + "s3_store_host", + "s3_store_large_object_chunk_size", + "s3_store_large_object_size", + "s3_store_region_name", + "s3_store_secret_key", + "s3_store_thread_pools" + ] + }, + "glance.store.swift.store": { + "deprecated": {}, + "opts": [ + "default_swift_reference", + "swift_buffer_on_upload", + "swift_store_admin_tenants", + "swift_store_auth_address", + "swift_store_auth_insecure", + "swift_store_auth_version", + "swift_store_cacert", + "swift_store_config_file", + "swift_store_container", + "swift_store_create_container_on_put", + "swift_store_endpoint", + "swift_store_endpoint_type", + "swift_store_expire_soon_interval", + "swift_store_key", + "swift_store_large_object_chunk_size", + "swift_store_large_object_size", + "swift_store_multi_tenant", + "swift_store_multiple_containers_seed", + "swift_store_region", + "swift_store_retry_get_count", + "swift_store_service_type", + "swift_store_ssl_compression", + "swift_store_use_trusts", + "swift_store_user", + "swift_upload_buffer_dir" + ] + }, + "glance.store.vmware_datastore.store": { + "deprecated": { + "vmware_api_insecure": "[glance.store.vmware_datastore.store] vmware_insecure" + }, + "opts": [ + "vmware_api_retry_count", + "vmware_ca_file", + "vmware_datastores", + "vmware_insecure", + "vmware_server_host", + "vmware_server_password", + "vmware_server_username", + "vmware_store_image_dir", + "vmware_task_poll_interval" + ] + }, + "glance_store": { + "deprecated": { + "vmware_api_insecure": "[glance_store] vmware_insecure" + }, + "opts": [ + "default_backend", + "default_store", + "default_swift_reference", + "filesystem_store_chunk_size", + "filesystem_store_datadir", + "filesystem_store_datadirs", + "filesystem_store_file_perm", + "filesystem_store_metadata_file", + "filesystem_thin_provisioning", + "http_proxy_information", + "https_ca_certificates_file", + "https_insecure", + "rados_connect_timeout", + "rbd_store_ceph_conf", + "rbd_store_chunk_size", + "rbd_store_pool", + "rbd_store_user", + "rbd_thin_provisioning", + "s3_store_access_key", + "s3_store_bucket", + "s3_store_bucket_url_format", + "s3_store_cacert", + "s3_store_create_bucket_on_put", + "s3_store_host", + "s3_store_large_object_chunk_size", + "s3_store_large_object_size", + "s3_store_region_name", + "s3_store_secret_key", + "s3_store_thread_pools", + "stores", + "swift_buffer_on_upload", + "swift_store_admin_tenants", + "swift_store_auth_address", + "swift_store_auth_insecure", + "swift_store_auth_version", + "swift_store_cacert", + "swift_store_config_file", + "swift_store_container", + "swift_store_create_container_on_put", + "swift_store_endpoint", + "swift_store_endpoint_type", + "swift_store_expire_soon_interval", + "swift_store_key", + "swift_store_large_object_chunk_size", + "swift_store_large_object_size", + "swift_store_multi_tenant", + "swift_store_multiple_containers_seed", + "swift_store_region", + "swift_store_retry_get_count", + "swift_store_service_type", + "swift_store_ssl_compression", + "swift_store_use_trusts", + "swift_store_user", + "swift_upload_buffer_dir", + "vmware_api_retry_count", + "vmware_ca_file", + "vmware_datastores", + "vmware_insecure", + "vmware_server_host", + "vmware_server_password", + "vmware_server_username", + "vmware_store_image_dir", + "vmware_task_poll_interval" + ] + }, + "healthcheck": { + "deprecated": {}, + "opts": [ + "allowed_source_ranges", + "backends", + "detailed", + "disable_by_file_path", + "disable_by_file_paths", + "enable_by_file_paths", + "ignore_proxied_requests", + "path" + ] + }, + "image_format": { + "deprecated": {}, + "opts": [ + "container_formats", + "disk_formats", + "gpt_safety_checks_nonfatal", + "require_image_format_match", + "vmdk_allowed_types" + ] + }, + "key_manager": { + "deprecated": { + "api_class": "[key_manager] backend" + }, + "opts": [ + "auth_type", + "auth_url", + "backend", + "domain_id", + "domain_name", + "password", + "project_domain_id", + "project_domain_name", + "project_id", + "project_name", + "reauthenticate", + "token", + "trust_id", + "user_domain_id", + "user_domain_name", + "user_id", + "username" + ] + }, + "keystone_authtoken": { + "deprecated": { + "auth_plugin": "[keystone_authtoken] auth_type", + "memcache_servers": "[keystone_authtoken] memcached_servers" + }, + "opts": [ + "auth_section", + "auth_type", + "auth_uri", + "auth_version", + "cache", + "cafile", + "certfile", + "delay_auth_decision", + "enforce_token_bind", + "http_connect_timeout", + "http_request_max_retries", + "include_service_catalog", + "insecure", + "interface", + "keyfile", + "memcache_password", + "memcache_pool_conn_get_timeout", + "memcache_pool_dead_retry", + "memcache_pool_maxsize", + "memcache_pool_socket_timeout", + "memcache_pool_unused_timeout", + "memcache_sasl_enabled", + "memcache_secret_key", + "memcache_security_strategy", + "memcache_tls_allowed_ciphers", + "memcache_tls_cafile", + "memcache_tls_certfile", + "memcache_tls_enabled", + "memcache_tls_keyfile", + "memcache_use_advanced_pool", + "memcache_username", + "memcached_servers", + "region_name", + "service_token_roles", + "service_token_roles_required", + "service_type", + "token_cache_time", + "www_authenticate_uri" + ] + }, + "oslo_concurrency": { + "deprecated": {}, + "opts": [ + "disable_process_locking", + "lock_path" + ] + }, + "oslo_limit": { + "deprecated": { + "user_name": "[oslo_limit] username" + }, + "opts": [ + "auth_url", + "cafile", + "certfile", + "collect_timing", + "connect_retries", + "connect_retry_delay", + "default_domain_id", + "default_domain_name", + "domain_id", + "domain_name", + "endpoint_id", + "endpoint_interface", + "endpoint_override", + "endpoint_region_name", + "endpoint_service_name", + "endpoint_service_type", + "insecure", + "keyfile", + "max_version", + "min_version", + "password", + "project_domain_id", + "project_domain_name", + "project_id", + "project_name", + "region_name", + "retriable_status_codes", + "service_name", + "service_type", + "split_loggers", + "status_code_retries", + "status_code_retry_delay", + "system_scope", + "tenant_id", + "tenant_name", + "timeout", + "trust_id", + "user_domain_id", + "user_domain_name", + "user_id", + "username", + "valid_interfaces", + "version" + ] + }, + "oslo_messaging_kafka": { + "deprecated": {}, + "opts": [ + "compression_codec", + "consumer_group", + "enable_auto_commit", + "kafka_consumer_timeout", + "kafka_max_fetch_bytes", + "max_poll_records", + "producer_batch_size", + "producer_batch_timeout", + "sasl_mechanism", + "security_protocol", + "ssl_cafile", + "ssl_client_cert_file", + "ssl_client_key_file", + "ssl_client_key_password" + ] + }, + "oslo_messaging_notifications": { + "deprecated": {}, + "opts": [ + "driver", + "retry", + "topics", + "transport_url" + ] + }, + "oslo_messaging_rabbit": { + "deprecated": { + "kombu_reconnect_timeout": "[oslo_messaging_rabbit] kombu_missing_consumer_retry_timeout" + }, + "opts": [ + "amqp_auto_delete", + "amqp_durable_queues", + "conn_pool_min_size", + "conn_pool_ttl", + "direct_mandatory_flag", + "enable_cancel_on_failover", + "heartbeat_in_pthread", + "heartbeat_rate", + "heartbeat_timeout_threshold", + "hostname", + "kombu_compression", + "kombu_failover_strategy", + "kombu_missing_consumer_retry_timeout", + "kombu_reconnect_delay", + "kombu_reconnect_splay", + "processname", + "rabbit_ha_queues", + "rabbit_interval_max", + "rabbit_login_method", + "rabbit_qos_prefetch_count", + "rabbit_quorum_delivery_limit", + "rabbit_quorum_max_memory_bytes", + "rabbit_quorum_max_memory_length", + "rabbit_quorum_queue", + "rabbit_retry_backoff", + "rabbit_retry_interval", + "rabbit_stream_fanout", + "rabbit_transient_queues_ttl", + "rabbit_transient_quorum_queue", + "rpc_conn_pool_size", + "ssl", + "ssl_ca_file", + "ssl_cert_file", + "ssl_enforce_fips_mode", + "ssl_key_file", + "ssl_version", + "use_queue_manager" + ] + }, + "oslo_middleware": { + "deprecated": {}, + "opts": [ + "enable_proxy_headers_parsing" + ] + }, + "oslo_policy": { + "deprecated": {}, + "opts": [ + "enforce_new_defaults", + "enforce_scope", + "policy_default_rule", + "policy_dirs", + "policy_file", + "remote_content_type", + "remote_ssl_ca_crt_file", + "remote_ssl_client_crt_file", + "remote_ssl_client_key_file", + "remote_ssl_verify_server_crt", + "remote_timeout" + ] + }, + "oslo_reports": { + "deprecated": {}, + "opts": [ + "file_event_handler", + "file_event_handler_interval", + "log_dir" + ] + }, + "paste_deploy": { + "deprecated": {}, + "opts": [ + "config_file", + "flavor" + ] + }, + "profiler": { + "deprecated": { + "profiler_enabled": "[profiler] enabled" + }, + "opts": [ + "connection_string", + "enabled", + "es_doc_type", + "es_scroll_size", + "es_scroll_time", + "filter_error_trace", + "hmac_keys", + "sentinel_service_name", + "socket_timeout", + "trace_requests", + "trace_sqlalchemy" + ] + }, + "task": { + "deprecated": { + "eventlet_executor_pool_size": "[taskflow_executor] max_workers" + }, + "opts": [ + "task_executor", + "task_time_to_live", + "work_dir" + ] + }, + "taskflow_executor": { + "deprecated": {}, + "opts": [ + "conversion_format", + "engine_mode", + "max_workers" + ] + }, + "vault": { + "deprecated": {}, + "opts": [ + "approle_role_id", + "approle_secret_id", + "kv_mountpoint", + "kv_path", + "kv_version", + "namespace", + "root_token_id", + "ssl_ca_crt_file", + "timeout", + "use_ssl", + "vault_url" + ] + }, + "wsgi": { + "deprecated": {}, + "opts": [ + "python_interpreter", + "task_pool_threads" + ] + } + }, + "service": "glance" +} diff --git a/operators/glance/api/v1alpha1/catalogs/2026.1.json b/operators/glance/api/v1alpha1/catalogs/2026.1.json new file mode 100644 index 000000000..32aa9c6af --- /dev/null +++ b/operators/glance/api/v1alpha1/catalogs/2026.1.json @@ -0,0 +1,674 @@ +{ + "release": "2026.1", + "sections": { + "DEFAULT": { + "deprecated": { + "container_formats": "[image_format] container_formats", + "disk_formats": "[image_format] disk_formats", + "log_config": "[DEFAULT] log_config_append", + "logdir": "[DEFAULT] log_dir", + "logfile": "[DEFAULT] log_file", + "rpc_thread_pool_size": "[DEFAULT] executor_thread_pool_size", + "task_time_to_live": "[task] task_time_to_live" + }, + "opts": [ + "allow_anonymous_access", + "api_limit_max", + "backlog", + "bind_host", + "bind_port", + "client_socket_timeout", + "control_exchange", + "debug", + "default_log_levels", + "default_publisher_id", + "delayed_delete", + "digest_algorithm", + "disabled_notifications", + "do_secure_hash", + "enabled_backends", + "enabled_import_methods", + "executor_thread_pool_size", + "fatal_deprecations", + "hashing_algorithm", + "http_keepalive", + "http_retries", + "image_cache_dir", + "image_cache_driver", + "image_cache_max_size", + "image_cache_sqlite_db", + "image_cache_stall_time", + "image_location_quota", + "image_member_quota", + "image_property_quota", + "image_size_cap", + "image_tag_quota", + "instance_format", + "instance_uuid_format", + "limit_param_default", + "log_color", + "log_config_append", + "log_date_format", + "log_dir", + "log_file", + "log_rotate_interval", + "log_rotate_interval_type", + "log_rotation_type", + "logging_context_format_string", + "logging_debug_format_suffix", + "logging_default_format_string", + "logging_exception_prefix", + "logging_user_identity_format", + "max_header_line", + "max_logfile_count", + "max_logfile_size_mb", + "max_request_id_length", + "metadata_encryption_key", + "node_staging_uri", + "property_protection_file", + "property_protection_rule_format", + "public_endpoint", + "publish_errors", + "pydev_worker_debug_host", + "pydev_worker_debug_port", + "rate_limit_burst", + "rate_limit_except_level", + "rate_limit_interval", + "rpc_ping_enabled", + "rpc_response_timeout", + "scrub_pool_size", + "scrub_time", + "show_image_direct_url", + "show_multiple_locations", + "syslog_log_facility", + "tcp_keepidle", + "transport_url", + "use_journal", + "use_json", + "use_keystone_limits", + "use_stderr", + "use_syslog", + "user_storage_quota", + "worker_self_reference_url", + "workers" + ] + }, + "barbican": { + "deprecated": {}, + "opts": [ + "auth_endpoint", + "barbican_api_version", + "barbican_endpoint", + "barbican_endpoint_type", + "barbican_region_name", + "cafile", + "certfile", + "collect_timing", + "insecure", + "keyfile", + "number_of_retries", + "retry_delay", + "send_service_user_token", + "split_loggers", + "timeout", + "verify_ssl", + "verify_ssl_path" + ] + }, + "barbican_service_user": { + "deprecated": { + "auth_plugin": "[barbican_service_user] auth_type" + }, + "opts": [ + "auth_section", + "auth_type", + "cafile", + "certfile", + "collect_timing", + "insecure", + "keyfile", + "split_loggers", + "timeout" + ] + }, + "cors": { + "deprecated": {}, + "opts": [ + "allow_credentials", + "allow_headers", + "allow_methods", + "allowed_origin", + "expose_headers", + "max_age" + ] + }, + "database": { + "deprecated": {}, + "opts": [ + "asyncio_connection", + "asyncio_slave_connection", + "backend", + "connection", + "connection_debug", + "connection_parameters", + "connection_recycle_time", + "connection_trace", + "db_inc_retry_interval", + "db_max_retries", + "db_max_retry_interval", + "db_retry_interval", + "max_overflow", + "max_pool_size", + "max_retries", + "mysql_sql_mode", + "mysql_wsrep_sync_wait", + "pool_timeout", + "retry_interval", + "slave_connection", + "sqlite_synchronous", + "synchronous_reader", + "use_db_reconnect" + ] + }, + "file": { + "deprecated": {}, + "opts": [ + "filesystem_store_chunk_size", + "filesystem_store_datadir", + "filesystem_store_datadirs", + "filesystem_store_file_perm", + "filesystem_store_metadata_file", + "filesystem_store_thread_pool_size", + "filesystem_store_threadpool_threshold", + "filesystem_store_timeout", + "filesystem_thin_provisioning" + ] + }, + "glance.store.http.store": { + "deprecated": {}, + "opts": [ + "http_proxy_information", + "https_ca_certificates_file", + "https_insecure" + ] + }, + "glance.store.rbd.store": { + "deprecated": {}, + "opts": [ + "rados_connect_timeout", + "rbd_store_ceph_conf", + "rbd_store_chunk_size", + "rbd_store_pool", + "rbd_store_user", + "rbd_thin_provisioning" + ] + }, + "glance.store.s3.store": { + "deprecated": {}, + "opts": [ + "s3_store_access_key", + "s3_store_bucket", + "s3_store_bucket_url_format", + "s3_store_cacert", + "s3_store_create_bucket_on_put", + "s3_store_enable_data_integrity_protection", + "s3_store_host", + "s3_store_large_object_chunk_size", + "s3_store_large_object_size", + "s3_store_region_name", + "s3_store_request_checksum_calculation", + "s3_store_response_checksum_validation", + "s3_store_secret_key", + "s3_store_thread_pools" + ] + }, + "glance.store.swift.store": { + "deprecated": {}, + "opts": [ + "default_swift_reference", + "swift_buffer_on_upload", + "swift_store_admin_tenants", + "swift_store_application_credential_id", + "swift_store_application_credential_secret", + "swift_store_auth_address", + "swift_store_auth_insecure", + "swift_store_auth_version", + "swift_store_cacert", + "swift_store_config_file", + "swift_store_container", + "swift_store_create_container_on_put", + "swift_store_endpoint", + "swift_store_endpoint_type", + "swift_store_expire_soon_interval", + "swift_store_key", + "swift_store_large_object_chunk_size", + "swift_store_large_object_size", + "swift_store_multi_tenant", + "swift_store_multiple_containers_seed", + "swift_store_project_id", + "swift_store_project_name", + "swift_store_region", + "swift_store_retry_get_count", + "swift_store_service_type", + "swift_store_ssl_compression", + "swift_store_use_trusts", + "swift_store_user", + "swift_upload_buffer_dir" + ] + }, + "glance.store.vmware_datastore.store": { + "deprecated": { + "vmware_api_insecure": "[glance.store.vmware_datastore.store] vmware_insecure" + }, + "opts": [ + "vmware_api_retry_count", + "vmware_ca_file", + "vmware_datastores", + "vmware_insecure", + "vmware_server_host", + "vmware_server_password", + "vmware_server_username", + "vmware_store_image_dir", + "vmware_task_poll_interval" + ] + }, + "glance_store": { + "deprecated": { + "vmware_api_insecure": "[glance_store] vmware_insecure" + }, + "opts": [ + "default_backend", + "default_store", + "default_swift_reference", + "filesystem_store_chunk_size", + "filesystem_store_datadir", + "filesystem_store_datadirs", + "filesystem_store_file_perm", + "filesystem_store_metadata_file", + "filesystem_store_thread_pool_size", + "filesystem_store_threadpool_threshold", + "filesystem_store_timeout", + "filesystem_thin_provisioning", + "http_proxy_information", + "https_ca_certificates_file", + "https_insecure", + "rados_connect_timeout", + "rbd_store_ceph_conf", + "rbd_store_chunk_size", + "rbd_store_pool", + "rbd_store_user", + "rbd_thin_provisioning", + "s3_store_access_key", + "s3_store_bucket", + "s3_store_bucket_url_format", + "s3_store_cacert", + "s3_store_create_bucket_on_put", + "s3_store_enable_data_integrity_protection", + "s3_store_host", + "s3_store_large_object_chunk_size", + "s3_store_large_object_size", + "s3_store_region_name", + "s3_store_request_checksum_calculation", + "s3_store_response_checksum_validation", + "s3_store_secret_key", + "s3_store_thread_pools", + "stores", + "swift_buffer_on_upload", + "swift_store_admin_tenants", + "swift_store_application_credential_id", + "swift_store_application_credential_secret", + "swift_store_auth_address", + "swift_store_auth_insecure", + "swift_store_auth_version", + "swift_store_cacert", + "swift_store_config_file", + "swift_store_container", + "swift_store_create_container_on_put", + "swift_store_endpoint", + "swift_store_endpoint_type", + "swift_store_expire_soon_interval", + "swift_store_key", + "swift_store_large_object_chunk_size", + "swift_store_large_object_size", + "swift_store_multi_tenant", + "swift_store_multiple_containers_seed", + "swift_store_project_id", + "swift_store_project_name", + "swift_store_region", + "swift_store_retry_get_count", + "swift_store_service_type", + "swift_store_ssl_compression", + "swift_store_use_trusts", + "swift_store_user", + "swift_upload_buffer_dir", + "vmware_api_retry_count", + "vmware_ca_file", + "vmware_datastores", + "vmware_insecure", + "vmware_server_host", + "vmware_server_password", + "vmware_server_username", + "vmware_store_image_dir", + "vmware_task_poll_interval" + ] + }, + "healthcheck": { + "deprecated": {}, + "opts": [ + "allowed_source_ranges", + "backends", + "detailed", + "disable_by_file_path", + "disable_by_file_paths", + "enable_by_file_paths", + "ignore_proxied_requests" + ] + }, + "image_format": { + "deprecated": {}, + "opts": [ + "container_formats", + "disk_formats", + "gpt_safety_checks_nonfatal", + "require_image_format_match", + "vmdk_allowed_types" + ] + }, + "key_manager": { + "deprecated": { + "api_class": "[key_manager] backend" + }, + "opts": [ + "auth_type", + "auth_url", + "backend", + "domain_id", + "domain_name", + "password", + "project_domain_id", + "project_domain_name", + "project_id", + "project_name", + "reauthenticate", + "token", + "trust_id", + "user_domain_id", + "user_domain_name", + "user_id", + "username" + ] + }, + "keystone_authtoken": { + "deprecated": { + "auth_plugin": "[keystone_authtoken] auth_type", + "memcache_servers": "[keystone_authtoken] memcached_servers" + }, + "opts": [ + "auth_section", + "auth_type", + "auth_uri", + "auth_version", + "cache", + "cafile", + "certfile", + "delay_auth_decision", + "enforce_token_bind", + "http_connect_timeout", + "http_request_max_retries", + "include_service_catalog", + "insecure", + "interface", + "keyfile", + "memcache_password", + "memcache_pool_conn_get_timeout", + "memcache_pool_dead_retry", + "memcache_pool_maxsize", + "memcache_pool_socket_timeout", + "memcache_pool_unused_timeout", + "memcache_sasl_enabled", + "memcache_secret_key", + "memcache_security_strategy", + "memcache_tls_allowed_ciphers", + "memcache_tls_cafile", + "memcache_tls_certfile", + "memcache_tls_enabled", + "memcache_tls_keyfile", + "memcache_use_advanced_pool", + "memcache_username", + "memcached_servers", + "region_name", + "service_token_roles", + "service_token_roles_required", + "service_type", + "token_cache_time", + "www_authenticate_uri" + ] + }, + "oslo_concurrency": { + "deprecated": {}, + "opts": [ + "disable_process_locking", + "lock_path" + ] + }, + "oslo_limit": { + "deprecated": { + "user_name": "[oslo_limit] username" + }, + "opts": [ + "auth_url", + "cafile", + "certfile", + "collect_timing", + "connect_retries", + "connect_retry_delay", + "default_domain_id", + "default_domain_name", + "domain_id", + "domain_name", + "endpoint_id", + "endpoint_interface", + "endpoint_override", + "endpoint_region_name", + "endpoint_service_name", + "endpoint_service_type", + "insecure", + "keyfile", + "max_version", + "min_version", + "password", + "project_domain_id", + "project_domain_name", + "project_id", + "project_name", + "region_name", + "retriable_status_codes", + "service_name", + "service_type", + "split_loggers", + "status_code_retries", + "status_code_retry_delay", + "system_scope", + "tenant_id", + "tenant_name", + "timeout", + "trust_id", + "user_domain_id", + "user_domain_name", + "user_id", + "username", + "valid_interfaces", + "version" + ] + }, + "oslo_messaging_kafka": { + "deprecated": {}, + "opts": [ + "compression_codec", + "consumer_group", + "enable_auto_commit", + "kafka_consumer_timeout", + "kafka_max_fetch_bytes", + "max_poll_records", + "producer_batch_size", + "producer_batch_timeout", + "sasl_mechanism", + "security_protocol", + "ssl_cafile", + "ssl_client_cert_file", + "ssl_client_key_file", + "ssl_client_key_password" + ] + }, + "oslo_messaging_notifications": { + "deprecated": {}, + "opts": [ + "driver", + "retry", + "topics", + "transport_url" + ] + }, + "oslo_messaging_rabbit": { + "deprecated": { + "kombu_reconnect_timeout": "[oslo_messaging_rabbit] kombu_missing_consumer_retry_timeout" + }, + "opts": [ + "amqp_auto_delete", + "amqp_durable_queues", + "conn_pool_min_size", + "conn_pool_ttl", + "direct_mandatory_flag", + "enable_cancel_on_failover", + "heartbeat_in_pthread", + "heartbeat_rate", + "heartbeat_timeout_threshold", + "hostname", + "kombu_compression", + "kombu_failover_strategy", + "kombu_missing_consumer_retry_timeout", + "kombu_reconnect_delay", + "kombu_reconnect_splay", + "processname", + "rabbit_ha_queues", + "rabbit_interval_max", + "rabbit_login_method", + "rabbit_qos_prefetch_count", + "rabbit_quorum_delivery_limit", + "rabbit_quorum_max_memory_bytes", + "rabbit_quorum_max_memory_length", + "rabbit_quorum_queue", + "rabbit_retry_backoff", + "rabbit_retry_interval", + "rabbit_stream_fanout", + "rabbit_transient_queues_ttl", + "rabbit_transient_quorum_queue", + "rpc_conn_pool_size", + "ssl", + "ssl_ca_file", + "ssl_cert_file", + "ssl_enforce_fips_mode", + "ssl_key_file", + "ssl_version", + "use_queue_manager" + ] + }, + "oslo_middleware": { + "deprecated": {}, + "opts": [ + "enable_proxy_headers_parsing" + ] + }, + "oslo_policy": { + "deprecated": {}, + "opts": [ + "enforce_new_defaults", + "enforce_scope", + "policy_default_rule", + "policy_dirs", + "policy_file", + "remote_content_type", + "remote_ssl_ca_crt_file", + "remote_ssl_client_crt_file", + "remote_ssl_client_key_file", + "remote_ssl_verify_server_crt", + "remote_timeout" + ] + }, + "oslo_reports": { + "deprecated": {}, + "opts": [ + "file_event_handler", + "file_event_handler_interval", + "log_dir" + ] + }, + "paste_deploy": { + "deprecated": {}, + "opts": [ + "config_file", + "flavor" + ] + }, + "profiler": { + "deprecated": { + "profiler_enabled": "[profiler] enabled" + }, + "opts": [ + "connection_string", + "enabled", + "es_doc_type", + "es_scroll_size", + "es_scroll_time", + "filter_error_trace", + "hmac_keys", + "sentinel_service_name", + "socket_timeout", + "trace_requests", + "trace_sqlalchemy" + ] + }, + "task": { + "deprecated": { + "eventlet_executor_pool_size": "[taskflow_executor] max_workers" + }, + "opts": [ + "task_executor", + "task_time_to_live", + "work_dir" + ] + }, + "taskflow_executor": { + "deprecated": {}, + "opts": [ + "conversion_format", + "engine_mode", + "max_workers" + ] + }, + "vault": { + "deprecated": {}, + "opts": [ + "approle_role_id", + "approle_secret_id", + "kv_mountpoint", + "kv_path", + "kv_version", + "namespace", + "root_token_id", + "ssl_ca_crt_file", + "timeout", + "use_ssl", + "vault_url" + ] + }, + "wsgi": { + "deprecated": {}, + "opts": [ + "python_interpreter", + "task_pool_threads" + ] + } + }, + "service": "glance" +} diff --git a/operators/glance/api/v1alpha1/glance_webhook.go b/operators/glance/api/v1alpha1/glance_webhook.go index 84dde7d58..10dd8d51d 100644 --- a/operators/glance/api/v1alpha1/glance_webhook.go +++ b/operators/glance/api/v1alpha1/glance_webhook.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "net/url" + "reflect" + "sort" appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -19,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "github.com/c5c3/forge/internal/common/config" "github.com/c5c3/forge/internal/common/naming" "github.com/c5c3/forge/internal/common/release" commonv1 "github.com/c5c3/forge/internal/common/types" @@ -134,12 +137,24 @@ func (w *GlanceWebhook) Default(_ context.Context, obj *Glance) error { // ValidateCreate implements admission.Validator[*Glance]. func (w *GlanceWebhook) ValidateCreate(ctx context.Context, obj *Glance) (admission.Warnings, error) { - return warnInertLaunchModeKnobs(obj), w.validate(ctx, obj) + catalogWarnings, catalogErrs := validateExtraConfigOptions(field.NewPath("spec"), obj) + return append(warnInertLaunchModeKnobs(obj), catalogWarnings...), w.validate(ctx, obj, catalogErrs) } // ValidateUpdate implements admission.Validator[*Glance]. -func (w *GlanceWebhook) ValidateUpdate(ctx context.Context, _, newObj *Glance) (admission.Warnings, error) { - return warnInertLaunchModeKnobs(newObj), w.validate(ctx, newObj) +// +// The extraConfig option-catalog check is re-run only when one of its inputs +// changed (extraConfig, the plugin config-section set, or spec.openStackRelease). +// This keeps an unrelated update — for example scaling replicas — from +// retroactively rejecting a CR whose extraConfig was accepted at create time but +// has since been invalidated by a regenerated catalog. +func (w *GlanceWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj *Glance) (admission.Warnings, error) { + var catalogWarnings admission.Warnings + var catalogErrs field.ErrorList + if extraConfigCatalogInputsChanged(oldObj, newObj) { + catalogWarnings, catalogErrs = validateExtraConfigOptions(field.NewPath("spec"), newObj) + } + return append(warnInertLaunchModeKnobs(newObj), catalogWarnings...), w.validate(ctx, newObj, catalogErrs) } // ValidateDelete implements admission.Validator[*Glance]. The method is required @@ -153,7 +168,9 @@ func (w *GlanceWebhook) ValidateDelete(_ context.Context, _ *Glance) (admission. // validate runs all validation rules against the Glance spec, accumulating // every violation so users see the full list in one admission response. // ctx is required for cluster-scoped lookups (PriorityClass validation). -func (w *GlanceWebhook) validate(ctx context.Context, g *Glance) error { +// extra carries errors accumulated by the caller (the extraConfig option-catalog +// check) so they aggregate into the single Invalid error alongside the rest. +func (w *GlanceWebhook) validate(ctx context.Context, g *Glance, extra field.ErrorList) error { var allErrs field.ErrorList specPath := field.NewPath("spec") @@ -494,6 +511,8 @@ func (w *GlanceWebhook) validate(ctx context.Context, g *Glance) error { )...) } + allErrs = append(allErrs, extra...) + if len(allErrs) > 0 { return apierrors.NewInvalid( schema.GroupKind{Group: GroupVersion.Group, Kind: "Glance"}, @@ -554,3 +573,127 @@ func warnInertLaunchModeKnobs(g *Glance) admission.Warnings { } return warnings } + +// validateExtraConfigOptions validates the option names in spec.extraConfig +// against the option catalog embedded for the release named by +// spec.openStackRelease. +// +// It fails open: when spec.openStackRelease cannot be resolved to an embedded +// catalog it returns exactly one warning and no errors, so a value that does not +// name a release or a build that ships no catalog for the release never blocks +// admission. Sections a loaded plugin declares (spec.plugins[].configSection) +// and the reserved glance_store sections in CatalogExemptSections are skipped +// whole, and the operator-owned keys in OwnedConfigKeys are exempt individually, +// so neither a plugin's dynamically named options, a file-store option under a +// reserved store, nor an operator-owned override is mistaken for an unknown +// option. Every remaining unknown option or unknown section becomes a field +// error; every deprecated-but-accepted option becomes a warning naming its +// replacement. +func validateExtraConfigOptions(specPath *field.Path, g *Glance) (admission.Warnings, field.ErrorList) { + if len(g.Spec.ExtraConfig) == 0 { + return nil, nil + } + + catalog, ok := OptionCatalogForRelease(g.Spec.OpenStackRelease) + if !ok { + // Fail open with exactly one warning. Distinguish a value that does not + // name a release at all from a parseable release the operator ships no + // catalog for. + rel, err := release.ParseRelease(g.Spec.OpenStackRelease) + if err != nil { + return admission.Warnings{ + "spec.extraConfig was not validated against an option catalog: " + + "spec.openStackRelease does not name an OpenStack release", + }, nil + } + return admission.Warnings{ + fmt.Sprintf("spec.extraConfig was not validated against an option catalog: "+ + "no catalog for release %q is embedded in this operator build", + fmt.Sprintf("%d.%d", rel.Year, rel.Minor)), + }, nil + } + + // Exempt the sections a loaded plugin owns and the reserved glance_store + // sections the catalog cannot enumerate (their option set is not derivable + // from the release catalog), plus the operator-owned keys, so none is flagged + // as unknown. + exemptSections := make(map[string]struct{}, len(g.Spec.Plugins)+len(CatalogExemptSections)) + for _, p := range g.Spec.Plugins { + exemptSections[p.ConfigSection] = struct{}{} + } + for _, section := range CatalogExemptSections { + exemptSections[section] = struct{}{} + } + ex := config.CatalogExemptions{ + Sections: exemptSections, + Keys: config.KeyExemptionsFromRegistry(OwnedConfigKeys), + } + + base := catalog.Release + unknown, deprecated := catalog.FindUnknownOptions(g.Spec.ExtraConfig, ex) + + extraConfigPath := specPath.Child("extraConfig") + var errs field.ErrorList + for _, u := range unknown { + fldPath := extraConfigPath.Key(u.Section).Key(u.Key) + if u.SectionUnknown { + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such section in the glance %s option catalog "+ + "(sections registered by a loaded plugin must be declared via spec.plugins)", base))) + continue + } + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such option in the glance %s option catalog", base))) + } + + var warnings admission.Warnings + for _, d := range deprecated { + if d.Replacement == "" { + warnings = append(warnings, fmt.Sprintf( + "spec.extraConfig [%s] %s: deprecated option in glance %s with no replacement", + d.Section, d.Key, base, + )) + continue + } + warnings = append(warnings, fmt.Sprintf( + "spec.extraConfig [%s] %s: deprecated option in glance %s, replaced by %s", + d.Section, d.Key, base, d.Replacement, + )) + } + return warnings, errs +} + +// extraConfigCatalogInputsChanged reports whether anything the extraConfig +// option-catalog check depends on differs between oldObj and newObj: the +// extraConfig map itself, spec.openStackRelease that selects the release +// catalog, or the set of plugin config sections (compared order-insensitively, +// since it only feeds an exemption set). ValidateUpdate gates the catalog +// re-validation on this so a CR whose extraConfig went stale-invalid is not +// rejected by an otherwise-unrelated update. +func extraConfigCatalogInputsChanged(oldObj, newObj *Glance) bool { + if !reflect.DeepEqual(oldObj.Spec.ExtraConfig, newObj.Spec.ExtraConfig) { + return true + } + if oldObj.Spec.OpenStackRelease != newObj.Spec.OpenStackRelease { + return true + } + if len(oldObj.Spec.Plugins) != len(newObj.Spec.Plugins) { + return true + } + oldSections := make([]string, len(oldObj.Spec.Plugins)) + for i, p := range oldObj.Spec.Plugins { + oldSections[i] = p.ConfigSection + } + newSections := make([]string, len(newObj.Spec.Plugins)) + for i, p := range newObj.Spec.Plugins { + newSections[i] = p.ConfigSection + } + sort.Strings(oldSections) + sort.Strings(newSections) + for i := range oldSections { + if oldSections[i] != newSections[i] { + return true + } + } + return false +} diff --git a/operators/glance/api/v1alpha1/glance_webhook_test.go b/operators/glance/api/v1alpha1/glance_webhook_test.go index 118b41f71..b39be211b 100644 --- a/operators/glance/api/v1alpha1/glance_webhook_test.go +++ b/operators/glance/api/v1alpha1/glance_webhook_test.go @@ -426,3 +426,213 @@ func TestGlanceWarnings_InertLaunchModeKnobs(t *testing.T) { }) } } + +// --- extraConfig option-catalog validation --- + +// TestGlanceValidate_ExtraConfigUnknownOptionRejected pins that an unknown option +// name under a known catalog section is rejected. "default_backend_typo" is a +// typo for the [glance_store] default_backend option, so the catalog does not +// list it. +func TestGlanceValidate_ExtraConfigUnknownOptionRejected(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"default_backend_typo": "s3"}, + } + + _, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("no such option in the glance 2025.2 option catalog")) +} + +// TestGlanceValidate_ExtraConfigUnknownSectionRejected pins that an option under +// a section the catalog does not contain is rejected as an unknown section. +// "glance_stor" is a typo for "glance_store". +func TestGlanceValidate_ExtraConfigUnknownSectionRejected(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.ExtraConfig = map[string]map[string]string{ + "glance_stor": {"default_backend": "s3"}, + } + + _, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("no such section in the glance 2025.2 option catalog")) +} + +// TestGlanceValidate_ExtraConfigReservedStoreSectionsExempt pins that the +// reserved glance_store staging and tasks sections are skipped whole: an +// arbitrary file-store option under either — one the release catalog does not +// list — is accepted, because CatalogExemptSections exempts the section itself +// (not just the two operator-owned datadir keys). +func TestGlanceValidate_ExtraConfigReservedStoreSectionsExempt(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.ExtraConfig = map[string]map[string]string{ + "os_glance_staging_store": {"filesystem_store_chunk_size": "65536"}, + "os_glance_tasks_store": {"filesystem_store_chunk_size": "65536"}, + } + + _, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).NotTo(gomega.HaveOccurred()) +} + +// TestGlanceValidate_ExtraConfigOwnershipRegistryKeyExempt pins that an +// operator-owned (section, key) pair is exempt even when the catalog section +// exists but does not list that key. [keystone_authtoken] username is a +// non-Rejected registry entry whose key the 2025.2 catalog does not carry, so +// without the registry exemption it would be flagged as an unknown option. +func TestGlanceValidate_ExtraConfigOwnershipRegistryKeyExempt(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.ExtraConfig = map[string]map[string]string{ + "keystone_authtoken": {"username": "glance-svc"}, + } + + _, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).NotTo(gomega.HaveOccurred()) +} + +// TestGlanceValidate_ExtraConfigPluginSectionExempt pins that a section declared +// by a loaded plugin is skipped whole, so options under it are never flagged even +// though the catalog cannot enumerate them. +func TestGlanceValidate_ExtraConfigPluginSectionExempt(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.Plugins = []commonv1.PluginSpec{ + {Name: "custom-store", ConfigSection: "custom_store"}, + } + obj.Spec.ExtraConfig = map[string]map[string]string{ + "custom_store": {"endpoint": "https://store.example.com", "bucket": "images"}, + } + + _, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).NotTo(gomega.HaveOccurred()) +} + +// TestGlanceValidate_ExtraConfigEmptyReleaseFailsOpen pins the fail-open behavior +// when spec.openStackRelease does not name a release: the catalog cannot be +// resolved, so the check emits a single warning and no catalog error even though +// "default_backend_typo" is unknown. Other validation may reject the empty +// release, so this asserts only on the warning and the absence of catalog errors. +func TestGlanceValidate_ExtraConfigEmptyReleaseFailsOpen(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.OpenStackRelease = "" + obj.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"default_backend_typo": "s3"}, + } + + warnings, err := w.ValidateCreate(context.Background(), obj) + g.Expect(warnings).To(gomega.ContainElement( + "spec.extraConfig was not validated against an option catalog: spec.openStackRelease does not name an OpenStack release", + )) + if err != nil { + g.Expect(err.Error()).NotTo(gomega.ContainSubstring("option catalog")) + } +} + +// TestGlanceValidate_ExtraConfigUnknownReleaseFailsOpen pins the fail-open +// behavior for a parseable release the operator ships no catalog for. +func TestGlanceValidate_ExtraConfigUnknownReleaseFailsOpen(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.OpenStackRelease = "2027.1" + obj.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"default_backend_typo": "s3"}, + } + + warnings, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(warnings).To(gomega.ContainElement( + `spec.extraConfig was not validated against an option catalog: no catalog for release "2027.1" is embedded in this operator build`, + )) +} + +// TestGlanceValidate_ExtraConfigDeprecatedOptionWarns pins that a +// deprecated-but-still-accepted option is honored (no error) but surfaces a +// warning naming its replacement. [DEFAULT] logfile is deprecated in favor of +// [DEFAULT] log_file. +func TestGlanceValidate_ExtraConfigDeprecatedOptionWarns(t *testing.T) { + g := gomega.NewWithT(t) + w := &GlanceWebhook{} + obj := validGlance() + obj.Spec.ExtraConfig = map[string]map[string]string{ + "DEFAULT": {"logfile": "/var/log/glance/glance.log"}, + } + + warnings, err := w.ValidateCreate(context.Background(), obj) + g.Expect(err).NotTo(gomega.HaveOccurred()) + g.Expect(warnings).To(gomega.ContainElement(gomega.ContainSubstring( + "deprecated option in glance 2025.2, replaced by [DEFAULT] log_file", + ))) +} + +// TestGlanceValidateUpdate_ExtraConfigCatalogGate exercises all four arms of the +// UPDATE re-validation gate. The old object carries a since-invalidated +// extraConfig; the check re-runs only when extraConfig, the plugin +// config-section set, or spec.openStackRelease changes. +func TestGlanceValidateUpdate_ExtraConfigCatalogGate(t *testing.T) { + w := &GlanceWebhook{} + oldObj := func() *Glance { + o := validGlance() + o.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"default_backend_typo": "s3"}, + } + return o + } + + t.Run("unrelated edit is accepted", func(t *testing.T) { + g := gomega.NewWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.Deployment.Replicas = 2 + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + t.Run("extraConfig edit is re-validated and rejected", func(t *testing.T) { + g := gomega.NewWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"another_typo": "x"}, + } + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("no such option in the glance 2025.2 option catalog")) + }) + + t.Run("openStackRelease edit is re-validated and rejected", func(t *testing.T) { + g := gomega.NewWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.OpenStackRelease = "2026.1" + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("no such option in the glance 2026.1 option catalog")) + }) + + t.Run("plugin-list edit is re-validated and rejected", func(t *testing.T) { + g := gomega.NewWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.Plugins = []commonv1.PluginSpec{ + {Name: "custom-store", ConfigSection: "custom_store"}, + } + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(gomega.HaveOccurred()) + g.Expect(err.Error()).To(gomega.ContainSubstring("no such option in the glance 2025.2 option catalog")) + }) +} diff --git a/operators/glance/api/v1alpha1/integration_test.go b/operators/glance/api/v1alpha1/integration_test.go index c51773c71..7831d2a03 100644 --- a/operators/glance/api/v1alpha1/integration_test.go +++ b/operators/glance/api/v1alpha1/integration_test.go @@ -342,3 +342,27 @@ func TestIntegration_WebhookEnforcesSingleDefault(t *testing.T) { other.Spec.IsDefault = true g.Expect(c.Create(ctx, other)).To(Succeed(), "a default for a different Glance should be accepted") } + +// TestIntegration_WebhookRejectsUnknownExtraConfigOption pins the extraConfig +// option-catalog check through the real API server: a CR whose extraConfig names +// an option the release's catalog does not accept is rejected by the validating +// webhook. "default_backend_typo" is a typo for the [glance_store] +// default_backend option. +func TestIntegration_WebhookRejectsUnknownExtraConfigOption(t *testing.T) { + testutil.SkipIfEnvTestUnavailable(t) + g := NewGomegaWithT(t) + + c, ctx, _ := setupEnvTest(t) + ns := newNamespace(t, ctx, c, "extraconfig-catalog-") + + glance := integrationGlance("glance", ns) + glance.Spec.ExtraConfig = map[string]map[string]string{ + "glance_store": {"default_backend_typo": "s3"}, + } + + err := c.Create(ctx, glance) + g.Expect(err).To(HaveOccurred(), "unknown extraConfig option should be rejected by the webhook") + g.Expect(apierrors.IsInvalid(err) || apierrors.IsForbidden(err)).To(BeTrue(), + fmt.Sprintf("expected Invalid or Forbidden status error, got: %v", err)) + g.Expect(err.Error()).To(ContainSubstring("no such option in the glance 2025.2 option catalog")) +} diff --git a/operators/glance/api/v1alpha1/option_catalog.go b/operators/glance/api/v1alpha1/option_catalog.go new file mode 100644 index 000000000..1fa71f302 --- /dev/null +++ b/operators/glance/api/v1alpha1/option_catalog.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "embed" + + "github.com/c5c3/forge/internal/common/config" +) + +// catalogFS holds the generated per-release glance option catalogs embedded into +// the operator binary. Each file is named ".json" and decodes into a +// config.OptionCatalog, so the validating webhook can reject spec.extraConfig +// option names the pinned release does not accept without reaching a live glance. +// +//go:embed catalogs/*.json +var catalogFS embed.FS + +// optionCatalogs maps a YYYY.N release base (the embedded file's stem) to the +// parsed glance option catalog for that release. It is built once at package +// initialization. +var optionCatalogs = config.MustParseEmbeddedCatalogs(catalogFS) + +// CatalogExemptSections names the reserved glance_store sections the operator +// projects but the generated catalog cannot enumerate: the glance-image-service +// staging and tasks stores are per-worker file stores whose options are the +// backend-driver keys glance_store registers at runtime, not options the release +// catalog lists. They are exempt from the unknown-option scan so an override of +// any file-store option under them stays settable; the two operator-owned +// datadir keys in OwnedConfigKeys are a subset of what this exempts. +var CatalogExemptSections = []string{"os_glance_staging_store", "os_glance_tasks_store"} + +// OptionCatalogForRelease returns the embedded glance option catalog for the +// OpenStack release named by spec.openStackRelease. See config.LookupCatalog for +// the release-to-catalog resolution rules. +func OptionCatalogForRelease(openStackRelease string) (*config.OptionCatalog, bool) { + return config.LookupCatalog(optionCatalogs, openStackRelease) +} diff --git a/operators/glance/api/v1alpha1/option_catalog_test.go b/operators/glance/api/v1alpha1/option_catalog_test.go new file mode 100644 index 000000000..7b9fd1078 --- /dev/null +++ b/operators/glance/api/v1alpha1/option_catalog_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "testing" + + "github.com/onsi/gomega" +) + +// TestGlanceOptionCatalogs_EmbeddedReleasesParse pins the exact set of embedded +// catalogs and proves each parsed cleanly at package init +// (config.MustParseEmbeddedCatalogs would have panicked otherwise, so merely +// reading optionCatalogs exercises the happy path and keeps the panic branch +// unreachable in production). +func TestGlanceOptionCatalogs_EmbeddedReleasesParse(t *testing.T) { + g := gomega.NewWithT(t) + + g.Expect(optionCatalogs).To(gomega.HaveLen(2)) + g.Expect(optionCatalogs).To(gomega.HaveKey("2025.2")) + g.Expect(optionCatalogs).To(gomega.HaveKey("2026.1")) + + for rel, catalog := range optionCatalogs { + g.Expect(catalog.Sections).To(gomega.HaveKey("DEFAULT"), "release %s must have a DEFAULT section", rel) + g.Expect(catalog.Sections["DEFAULT"].Opts).To(gomega.ContainElement("debug"), + "release %s DEFAULT must list debug", rel) + } +} + +// TestGlanceOptionCatalogs_UseUnderscoreSpelling guards the invariant +// FindUnknownOptions relies on: catalog option names, deprecated keys, and +// deprecated replacements all use the underscore spelling oslo.config reads from +// a file, never the CLI dash form. A dash-spelled entry would silently make a +// valid underscore override look unknown (or vice versa). +func TestGlanceOptionCatalogs_UseUnderscoreSpelling(t *testing.T) { + g := gomega.NewWithT(t) + + for rel, catalog := range optionCatalogs { + g.Expect(catalog.Sections["DEFAULT"].Opts).To(gomega.ContainElement("log_config_append"), + "release %s DEFAULT must list log_config_append", rel) + for section, body := range catalog.Sections { + for _, opt := range body.Opts { + g.Expect(opt).NotTo(gomega.ContainSubstring("-"), + "release %s [%s] opt %q must use underscore spelling", rel, section, opt) + } + for key, replacement := range body.Deprecated { + g.Expect(key).NotTo(gomega.ContainSubstring("-"), + "release %s [%s] deprecated key %q must use underscore spelling", rel, section, key) + g.Expect(replacement).NotTo(gomega.ContainSubstring("-"), + "release %s [%s] deprecated replacement %q must use underscore spelling", rel, section, replacement) + } + } + } +} + +// TestGlanceOptionCatalogForRelease covers the release-to-catalog resolution: a +// base release and its patch suffix both resolve to the same catalog, while an +// empty release, a digest-style "latest", and a parseable-but-unshipped release +// all miss. +func TestGlanceOptionCatalogForRelease(t *testing.T) { + g := gomega.NewWithT(t) + + cat2025, ok := OptionCatalogForRelease("2025.2") + g.Expect(ok).To(gomega.BeTrue()) + g.Expect(cat2025).NotTo(gomega.BeNil()) + g.Expect(cat2025.Release).To(gomega.Equal("2025.2")) + + // A patch suffix strips to the same base-release catalog (same pointer). + catPatch, ok := OptionCatalogForRelease("2025.2-p1") + g.Expect(ok).To(gomega.BeTrue()) + g.Expect(catPatch).To(gomega.BeIdenticalTo(cat2025)) + + // Values that do not resolve to an embedded catalog. "2024.2" parses as a + // release but no catalog is embedded for it. + for _, rel := range []string{"", "latest", "2024.2"} { + catalog, ok := OptionCatalogForRelease(rel) + g.Expect(ok).To(gomega.BeFalse(), "release %q must not resolve to a catalog", rel) + g.Expect(catalog).To(gomega.BeNil(), "release %q must return a nil catalog", rel) + } +} + +// TestGlanceOptionCatalog2025_DeprecatesLogfile pins one representative +// deprecation the webhook's deprecated-option warning depends on. +func TestGlanceOptionCatalog2025_DeprecatesLogfile(t *testing.T) { + g := gomega.NewWithT(t) + + catalog, ok := OptionCatalogForRelease("2025.2") + g.Expect(ok).To(gomega.BeTrue()) + g.Expect(catalog.Sections["DEFAULT"].Deprecated).To(gomega.HaveKeyWithValue("logfile", "[DEFAULT] log_file")) +} + +// TestGlanceCatalogExemptSections pins the reserved glance_store sections the +// generated catalog cannot enumerate. Their non-registry keys stay settable, so +// validateExtraConfigOptions must skip them whole alongside the plugin sections. +func TestGlanceCatalogExemptSections(t *testing.T) { + g := gomega.NewWithT(t) + + g.Expect(CatalogExemptSections).To(gomega.ConsistOf("os_glance_staging_store", "os_glance_tasks_store")) +} diff --git a/operators/keystone/api/v1alpha1/catalogs/2025.2.json b/operators/keystone/api/v1alpha1/catalogs/2025.2.json new file mode 100644 index 000000000..cd8b2ecbd --- /dev/null +++ b/operators/keystone/api/v1alpha1/catalogs/2025.2.json @@ -0,0 +1,646 @@ +{ + "release": "2025.2", + "sections": { + "DEFAULT": { + "deprecated": { + "log_config": "[DEFAULT] log_config_append", + "logdir": "[DEFAULT] log_dir", + "logfile": "[DEFAULT] log_file", + "rpc_thread_pool_size": "[DEFAULT] executor_thread_pool_size" + }, + "opts": [ + "admin_token", + "control_exchange", + "debug", + "default_log_levels", + "default_publisher_id", + "executor_thread_pool_size", + "fatal_deprecations", + "insecure_debug", + "instance_format", + "instance_uuid_format", + "list_limit", + "log_color", + "log_config_append", + "log_date_format", + "log_dir", + "log_file", + "log_rotate_interval", + "log_rotate_interval_type", + "log_rotation_type", + "logging_context_format_string", + "logging_debug_format_suffix", + "logging_default_format_string", + "logging_exception_prefix", + "logging_user_identity_format", + "max_db_limit", + "max_logfile_count", + "max_logfile_size_mb", + "max_param_size", + "max_project_tree_depth", + "max_token_size", + "notification_format", + "notification_opt_out", + "public_endpoint", + "publish_errors", + "rate_limit_burst", + "rate_limit_except_level", + "rate_limit_interval", + "rpc_ping_enabled", + "rpc_response_timeout", + "strict_password_check", + "syslog_log_facility", + "transport_url", + "use_journal", + "use_json", + "use_stderr", + "use_syslog", + "watch_log_file" + ] + }, + "application_credential": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "user_limit" + ] + }, + "assignment": { + "deprecated": { + "cache_time": "[resource] cache_time", + "caching": "[resource] caching", + "list_limit": "[resource] list_limit" + }, + "opts": [ + "driver", + "prohibited_implied_role" + ] + }, + "auth": { + "deprecated": {}, + "opts": [ + "application_credential", + "external", + "mapped", + "methods", + "oauth1", + "password", + "token" + ] + }, + "cache": { + "deprecated": {}, + "opts": [ + "backend", + "backend_argument", + "backend_expiration_time", + "config_prefix", + "dead_timeout", + "debug_cache_backend", + "enable_retry_client", + "enable_socket_keepalive", + "enabled", + "enforce_fips_mode", + "expiration_time", + "hashclient_retry_attempts", + "hashclient_retry_delay", + "memcache_dead_retry", + "memcache_password", + "memcache_pool_connection_get_timeout", + "memcache_pool_flush_on_reconnect", + "memcache_pool_maxsize", + "memcache_pool_unused_timeout", + "memcache_sasl_enabled", + "memcache_servers", + "memcache_socket_timeout", + "memcache_username", + "proxies", + "redis_db", + "redis_password", + "redis_sentinel_service_name", + "redis_sentinels", + "redis_server", + "redis_socket_timeout", + "redis_username", + "retry_attempts", + "retry_delay", + "socket_keepalive_count", + "socket_keepalive_idle", + "socket_keepalive_interval", + "tls_allowed_ciphers", + "tls_cafile", + "tls_certfile", + "tls_enabled", + "tls_keyfile" + ] + }, + "catalog": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "list_limit" + ] + }, + "cors": { + "deprecated": {}, + "opts": [ + "allow_credentials", + "allow_headers", + "allow_methods", + "allowed_origin", + "expose_headers", + "max_age" + ] + }, + "credential": { + "deprecated": {}, + "opts": [ + "auth_ttl", + "cache_time", + "caching", + "driver", + "key_repository", + "provider", + "user_limit" + ] + }, + "database": { + "deprecated": {}, + "opts": [ + "asyncio_connection", + "asyncio_slave_connection", + "backend", + "connection", + "connection_debug", + "connection_parameters", + "connection_recycle_time", + "connection_trace", + "db_inc_retry_interval", + "db_max_retries", + "db_max_retry_interval", + "db_retry_interval", + "max_overflow", + "max_pool_size", + "max_retries", + "mysql_sql_mode", + "mysql_wsrep_sync_wait", + "pool_timeout", + "retry_interval", + "slave_connection", + "sqlite_synchronous", + "use_db_reconnect" + ] + }, + "domain_config": { + "deprecated": {}, + "opts": [ + "additional_sensitive_options", + "additional_whitelisted_options", + "cache_time", + "caching", + "driver" + ] + }, + "endpoint_filter": { + "deprecated": {}, + "opts": [ + "driver", + "return_all_endpoints_if_no_filter" + ] + }, + "endpoint_policy": { + "deprecated": {}, + "opts": [ + "driver" + ] + }, + "federation": { + "deprecated": {}, + "opts": [ + "assertion_prefix", + "attribute_mapping_default_schema_version", + "caching", + "default_authorization_ttl", + "driver", + "federated_domain_name", + "remote_id_attribute", + "sso_callback_template", + "trusted_dashboard" + ] + }, + "fernet_receipts": { + "deprecated": {}, + "opts": [ + "key_repository", + "max_active_keys" + ] + }, + "fernet_tokens": { + "deprecated": {}, + "opts": [ + "key_repository", + "max_active_keys" + ] + }, + "healthcheck": { + "deprecated": {}, + "opts": [ + "allowed_source_ranges", + "backends", + "detailed", + "disable_by_file_path", + "disable_by_file_paths", + "enable_by_file_paths", + "ignore_proxied_requests", + "path" + ] + }, + "identity": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "default_domain_id", + "domain_config_dir", + "domain_configurations_from_database", + "domain_specific_drivers_enabled", + "driver", + "list_limit", + "max_password_length", + "password_hash_algorithm", + "password_hash_rounds", + "salt_bytesize", + "scrypt_block_size", + "scrypt_parallelism" + ] + }, + "identity_mapping": { + "deprecated": {}, + "opts": [ + "backward_compatible_ids", + "driver", + "generator" + ] + }, + "jwt_tokens": { + "deprecated": {}, + "opts": [ + "jws_private_key_repository", + "jws_public_key_repository" + ] + }, + "ldap": { + "deprecated": {}, + "opts": [ + "alias_dereferencing", + "auth_pool_connection_lifetime", + "auth_pool_size", + "chase_referrals", + "connection_timeout", + "debug_level", + "group_ad_nesting", + "group_additional_attribute_mapping", + "group_attribute_ignore", + "group_desc_attribute", + "group_filter", + "group_id_attribute", + "group_member_attribute", + "group_members_are_ids", + "group_name_attribute", + "group_objectclass", + "group_tree_dn", + "page_size", + "password", + "pool_connection_lifetime", + "pool_connection_timeout", + "pool_retry_delay", + "pool_retry_max", + "pool_size", + "query_scope", + "randomize_urls", + "suffix", + "tls_cacertdir", + "tls_cacertfile", + "tls_req_cert", + "url", + "use_auth_pool", + "use_pool", + "use_tls", + "user", + "user_additional_attribute_mapping", + "user_attribute_ignore", + "user_default_project_id_attribute", + "user_description_attribute", + "user_enabled_attribute", + "user_enabled_default", + "user_enabled_emulation", + "user_enabled_emulation_dn", + "user_enabled_emulation_use_group_config", + "user_enabled_invert", + "user_enabled_mask", + "user_filter", + "user_id_attribute", + "user_mail_attribute", + "user_name_attribute", + "user_objectclass", + "user_pass_attribute", + "user_tree_dn" + ] + }, + "oauth1": { + "deprecated": {}, + "opts": [ + "access_token_duration", + "driver", + "request_token_duration" + ] + }, + "oauth2": { + "deprecated": {}, + "opts": [ + "oauth2_authn_methods", + "oauth2_cert_dn_mapping_id" + ] + }, + "oslo_messaging_kafka": { + "deprecated": {}, + "opts": [ + "compression_codec", + "consumer_group", + "enable_auto_commit", + "kafka_consumer_timeout", + "kafka_max_fetch_bytes", + "max_poll_records", + "producer_batch_size", + "producer_batch_timeout", + "sasl_mechanism", + "security_protocol", + "ssl_cafile", + "ssl_client_cert_file", + "ssl_client_key_file", + "ssl_client_key_password" + ] + }, + "oslo_messaging_notifications": { + "deprecated": {}, + "opts": [ + "driver", + "retry", + "topics", + "transport_url" + ] + }, + "oslo_messaging_rabbit": { + "deprecated": { + "kombu_reconnect_timeout": "[oslo_messaging_rabbit] kombu_missing_consumer_retry_timeout" + }, + "opts": [ + "amqp_auto_delete", + "amqp_durable_queues", + "conn_pool_min_size", + "conn_pool_ttl", + "direct_mandatory_flag", + "enable_cancel_on_failover", + "heartbeat_in_pthread", + "heartbeat_rate", + "heartbeat_timeout_threshold", + "hostname", + "kombu_compression", + "kombu_failover_strategy", + "kombu_missing_consumer_retry_timeout", + "kombu_reconnect_delay", + "kombu_reconnect_splay", + "processname", + "rabbit_ha_queues", + "rabbit_interval_max", + "rabbit_login_method", + "rabbit_qos_prefetch_count", + "rabbit_quorum_delivery_limit", + "rabbit_quorum_max_memory_bytes", + "rabbit_quorum_max_memory_length", + "rabbit_quorum_queue", + "rabbit_retry_backoff", + "rabbit_retry_interval", + "rabbit_stream_fanout", + "rabbit_transient_queues_ttl", + "rabbit_transient_quorum_queue", + "rpc_conn_pool_size", + "ssl", + "ssl_ca_file", + "ssl_cert_file", + "ssl_enforce_fips_mode", + "ssl_key_file", + "ssl_version", + "use_queue_manager" + ] + }, + "oslo_middleware": { + "deprecated": {}, + "opts": [ + "enable_proxy_headers_parsing", + "http_basic_auth_user_file", + "max_request_body_size" + ] + }, + "oslo_policy": { + "deprecated": {}, + "opts": [ + "enforce_new_defaults", + "enforce_scope", + "policy_default_rule", + "policy_dirs", + "policy_file", + "remote_content_type", + "remote_ssl_ca_crt_file", + "remote_ssl_client_crt_file", + "remote_ssl_client_key_file", + "remote_ssl_verify_server_crt", + "remote_timeout" + ] + }, + "policy": { + "deprecated": {}, + "opts": [ + "driver", + "list_limit" + ] + }, + "profiler": { + "deprecated": { + "profiler_enabled": "[profiler] enabled" + }, + "opts": [ + "connection_string", + "enabled", + "es_doc_type", + "es_scroll_size", + "es_scroll_time", + "filter_error_trace", + "hmac_keys", + "sentinel_service_name", + "socket_timeout", + "trace_requests", + "trace_sqlalchemy" + ] + }, + "profiler_jaeger": { + "deprecated": {}, + "opts": [ + "process_tags", + "service_name_prefix" + ] + }, + "profiler_otlp": { + "deprecated": {}, + "opts": [ + "service_name_prefix" + ] + }, + "receipt": { + "deprecated": {}, + "opts": [ + "cache_on_issue", + "cache_time", + "caching", + "expiration", + "provider" + ] + }, + "resource": { + "deprecated": {}, + "opts": [ + "admin_project_domain_name", + "admin_project_name", + "cache_time", + "caching", + "domain_name_url_safe", + "driver", + "list_limit", + "project_name_url_safe" + ] + }, + "revoke": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "expiration_buffer" + ] + }, + "role": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "list_limit" + ] + }, + "saml": { + "deprecated": {}, + "opts": [ + "assertion_expiration_time", + "certfile", + "idp_contact_company", + "idp_contact_email", + "idp_contact_name", + "idp_contact_surname", + "idp_contact_telephone", + "idp_contact_type", + "idp_entity_id", + "idp_lang", + "idp_metadata_path", + "idp_organization_display_name", + "idp_organization_name", + "idp_organization_url", + "idp_sso_endpoint", + "keyfile", + "relay_state_prefix", + "xmlsec1_binary" + ] + }, + "security_compliance": { + "deprecated": {}, + "opts": [ + "change_password_upon_first_use", + "disable_user_account_days_inactive", + "invalid_password_hash_function", + "invalid_password_hash_max_chars", + "invalid_password_hash_secret_key", + "lockout_duration", + "lockout_failure_attempts", + "minimum_password_age", + "password_expires_days", + "password_regex", + "password_regex_description", + "report_invalid_password_hash", + "unique_last_password_count" + ] + }, + "shadow_users": { + "deprecated": {}, + "opts": [ + "driver" + ] + }, + "token": { + "deprecated": { + "revocation_cache_time": "[revoke] cache_time" + }, + "opts": [ + "allow_expired_window", + "allow_rescope_scoped_token", + "cache_on_issue", + "cache_time", + "caching", + "expiration", + "provider", + "revoke_by_id" + ] + }, + "tokenless_auth": { + "deprecated": {}, + "opts": [ + "issuer_attribute", + "protocol", + "trusted_issuer" + ] + }, + "totp": { + "deprecated": {}, + "opts": [ + "included_previous_windows" + ] + }, + "trust": { + "deprecated": {}, + "opts": [ + "allow_redelegation", + "driver", + "max_redelegation_count" + ] + }, + "unified_limit": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "enforcement_model", + "list_limit" + ] + }, + "wsgi": { + "deprecated": {}, + "opts": [ + "debug_middleware" + ] + } + }, + "service": "keystone" +} diff --git a/operators/keystone/api/v1alpha1/catalogs/2026.1.json b/operators/keystone/api/v1alpha1/catalogs/2026.1.json new file mode 100644 index 000000000..941a6e4a5 --- /dev/null +++ b/operators/keystone/api/v1alpha1/catalogs/2026.1.json @@ -0,0 +1,657 @@ +{ + "release": "2026.1", + "sections": { + "DEFAULT": { + "deprecated": { + "log_config": "[DEFAULT] log_config_append", + "logdir": "[DEFAULT] log_dir", + "logfile": "[DEFAULT] log_file", + "rpc_thread_pool_size": "[DEFAULT] executor_thread_pool_size" + }, + "opts": [ + "admin_token", + "control_exchange", + "debug", + "default_log_levels", + "default_publisher_id", + "executor_thread_pool_size", + "fatal_deprecations", + "insecure_debug", + "instance_format", + "instance_uuid_format", + "list_limit", + "log_color", + "log_config_append", + "log_date_format", + "log_dir", + "log_file", + "log_rotate_interval", + "log_rotate_interval_type", + "log_rotation_type", + "logging_context_format_string", + "logging_debug_format_suffix", + "logging_default_format_string", + "logging_exception_prefix", + "logging_user_identity_format", + "max_db_limit", + "max_logfile_count", + "max_logfile_size_mb", + "max_param_size", + "max_project_tree_depth", + "max_token_size", + "notification_format", + "notification_opt_out", + "public_endpoint", + "publish_errors", + "rate_limit_burst", + "rate_limit_except_level", + "rate_limit_interval", + "rpc_ping_enabled", + "rpc_response_timeout", + "strict_password_check", + "syslog_log_facility", + "transport_url", + "use_journal", + "use_json", + "use_stderr", + "use_syslog" + ] + }, + "api": { + "deprecated": {}, + "opts": [ + "response_validation" + ] + }, + "application_credential": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "user_limit" + ] + }, + "assignment": { + "deprecated": { + "cache_time": "[resource] cache_time", + "caching": "[resource] caching", + "list_limit": "[resource] list_limit" + }, + "opts": [ + "driver", + "prohibited_implied_role" + ] + }, + "auth": { + "deprecated": {}, + "opts": [ + "application_credential", + "external", + "mapped", + "methods", + "oauth1", + "password", + "token" + ] + }, + "cache": { + "deprecated": { + "dead_timeout": "[cache] hashclient_dead_timeout", + "hashclient_retry_delay": "[cache] hashclient_retry_timeout", + "memcache_password": "[cache] password", + "memcache_socket_timeout": "[cache] socket_timeout", + "memcache_username": "[cache] username", + "redis_password": "[cache] password", + "redis_socket_timeout": "[cache] socket_timeout", + "redis_username": "[cache] username" + }, + "opts": [ + "backend", + "backend_argument", + "backend_expiration_time", + "config_prefix", + "debug_cache_backend", + "enable_retry_client", + "enable_socket_keepalive", + "enabled", + "enforce_fips_mode", + "expiration_time", + "hashclient_dead_timeout", + "hashclient_retry_attempts", + "hashclient_retry_timeout", + "memcache_dead_retry", + "memcache_pool_connection_get_timeout", + "memcache_pool_flush_on_reconnect", + "memcache_pool_maxsize", + "memcache_pool_unused_timeout", + "memcache_sasl_enabled", + "memcache_servers", + "password", + "proxies", + "redis_db", + "redis_sentinel_service_name", + "redis_sentinels", + "redis_server", + "retry_attempts", + "retry_delay", + "socket_keepalive_count", + "socket_keepalive_idle", + "socket_keepalive_interval", + "socket_timeout", + "tls_allowed_ciphers", + "tls_cafile", + "tls_certfile", + "tls_enabled", + "tls_keyfile", + "username" + ] + }, + "catalog": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "list_limit" + ] + }, + "cors": { + "deprecated": {}, + "opts": [ + "allow_credentials", + "allow_headers", + "allow_methods", + "allowed_origin", + "expose_headers", + "max_age" + ] + }, + "credential": { + "deprecated": {}, + "opts": [ + "auth_ttl", + "cache_time", + "caching", + "driver", + "key_repository", + "provider", + "user_limit" + ] + }, + "database": { + "deprecated": {}, + "opts": [ + "asyncio_connection", + "asyncio_slave_connection", + "backend", + "connection", + "connection_debug", + "connection_parameters", + "connection_recycle_time", + "connection_trace", + "db_inc_retry_interval", + "db_max_retries", + "db_max_retry_interval", + "db_retry_interval", + "max_overflow", + "max_pool_size", + "max_retries", + "mysql_sql_mode", + "mysql_wsrep_sync_wait", + "pool_timeout", + "retry_interval", + "slave_connection", + "sqlite_synchronous", + "synchronous_reader", + "use_db_reconnect" + ] + }, + "domain_config": { + "deprecated": {}, + "opts": [ + "additional_sensitive_options", + "additional_whitelisted_options", + "cache_time", + "caching", + "driver" + ] + }, + "endpoint_filter": { + "deprecated": {}, + "opts": [ + "driver", + "return_all_endpoints_if_no_filter" + ] + }, + "endpoint_policy": { + "deprecated": {}, + "opts": [ + "driver" + ] + }, + "federation": { + "deprecated": {}, + "opts": [ + "assertion_prefix", + "attribute_mapping_default_schema_version", + "caching", + "default_authorization_ttl", + "driver", + "federated_domain_name", + "remote_id_attribute", + "sso_callback_template", + "trusted_dashboard" + ] + }, + "fernet_receipts": { + "deprecated": {}, + "opts": [ + "key_repository", + "max_active_keys" + ] + }, + "fernet_tokens": { + "deprecated": {}, + "opts": [ + "key_repository", + "max_active_keys" + ] + }, + "healthcheck": { + "deprecated": {}, + "opts": [ + "allowed_source_ranges", + "backends", + "detailed", + "disable_by_file_path", + "disable_by_file_paths", + "enable_by_file_paths", + "ignore_proxied_requests" + ] + }, + "identity": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "default_domain_id", + "domain_config_dir", + "domain_configurations_from_database", + "domain_specific_drivers_enabled", + "driver", + "list_limit", + "max_password_length", + "password_hash_algorithm", + "password_hash_rounds", + "salt_bytesize", + "scrypt_block_size", + "scrypt_parallelism" + ] + }, + "identity_mapping": { + "deprecated": {}, + "opts": [ + "backward_compatible_ids", + "driver", + "generator" + ] + }, + "jwt_tokens": { + "deprecated": {}, + "opts": [ + "jws_private_key_repository", + "jws_public_key_repository" + ] + }, + "ldap": { + "deprecated": {}, + "opts": [ + "alias_dereferencing", + "auth_pool_connection_lifetime", + "auth_pool_size", + "chase_referrals", + "connection_timeout", + "debug_level", + "group_ad_nesting", + "group_additional_attribute_mapping", + "group_attribute_ignore", + "group_desc_attribute", + "group_filter", + "group_id_attribute", + "group_member_attribute", + "group_members_are_ids", + "group_name_attribute", + "group_objectclass", + "group_tree_dn", + "page_size", + "password", + "pool_connection_lifetime", + "pool_connection_timeout", + "pool_retry_delay", + "pool_retry_max", + "pool_size", + "query_scope", + "randomize_urls", + "suffix", + "tls_cacertdir", + "tls_cacertfile", + "tls_req_cert", + "url", + "use_auth_pool", + "use_pool", + "use_tls", + "user", + "user_additional_attribute_mapping", + "user_attribute_ignore", + "user_default_project_id_attribute", + "user_description_attribute", + "user_enabled_attribute", + "user_enabled_default", + "user_enabled_emulation", + "user_enabled_emulation_dn", + "user_enabled_emulation_use_group_config", + "user_enabled_invert", + "user_enabled_mask", + "user_filter", + "user_id_attribute", + "user_mail_attribute", + "user_name_attribute", + "user_objectclass", + "user_pass_attribute", + "user_tree_dn" + ] + }, + "oauth1": { + "deprecated": {}, + "opts": [ + "access_token_duration", + "driver", + "request_token_duration" + ] + }, + "oauth2": { + "deprecated": {}, + "opts": [ + "oauth2_authn_methods", + "oauth2_cert_dn_mapping_id" + ] + }, + "oslo_messaging_kafka": { + "deprecated": {}, + "opts": [ + "compression_codec", + "consumer_group", + "enable_auto_commit", + "kafka_consumer_timeout", + "kafka_max_fetch_bytes", + "max_poll_records", + "producer_batch_size", + "producer_batch_timeout", + "sasl_mechanism", + "security_protocol", + "ssl_cafile", + "ssl_client_cert_file", + "ssl_client_key_file", + "ssl_client_key_password" + ] + }, + "oslo_messaging_notifications": { + "deprecated": {}, + "opts": [ + "driver", + "retry", + "topics", + "transport_url" + ] + }, + "oslo_messaging_rabbit": { + "deprecated": { + "kombu_reconnect_timeout": "[oslo_messaging_rabbit] kombu_missing_consumer_retry_timeout" + }, + "opts": [ + "amqp_auto_delete", + "amqp_durable_queues", + "conn_pool_min_size", + "conn_pool_ttl", + "direct_mandatory_flag", + "enable_cancel_on_failover", + "heartbeat_in_pthread", + "heartbeat_rate", + "heartbeat_timeout_threshold", + "hostname", + "kombu_compression", + "kombu_failover_strategy", + "kombu_missing_consumer_retry_timeout", + "kombu_reconnect_delay", + "kombu_reconnect_splay", + "processname", + "rabbit_ha_queues", + "rabbit_interval_max", + "rabbit_login_method", + "rabbit_qos_prefetch_count", + "rabbit_quorum_delivery_limit", + "rabbit_quorum_max_memory_bytes", + "rabbit_quorum_max_memory_length", + "rabbit_quorum_queue", + "rabbit_retry_backoff", + "rabbit_retry_interval", + "rabbit_stream_fanout", + "rabbit_transient_queues_ttl", + "rabbit_transient_quorum_queue", + "rpc_conn_pool_size", + "ssl", + "ssl_ca_file", + "ssl_cert_file", + "ssl_enforce_fips_mode", + "ssl_key_file", + "ssl_version", + "use_queue_manager" + ] + }, + "oslo_middleware": { + "deprecated": {}, + "opts": [ + "enable_proxy_headers_parsing", + "http_basic_auth_user_file", + "max_request_body_size" + ] + }, + "oslo_policy": { + "deprecated": {}, + "opts": [ + "enforce_new_defaults", + "enforce_scope", + "policy_default_rule", + "policy_dirs", + "policy_file", + "remote_content_type", + "remote_ssl_ca_crt_file", + "remote_ssl_client_crt_file", + "remote_ssl_client_key_file", + "remote_ssl_verify_server_crt", + "remote_timeout" + ] + }, + "policy": { + "deprecated": {}, + "opts": [ + "driver", + "list_limit" + ] + }, + "profiler": { + "deprecated": { + "profiler_enabled": "[profiler] enabled" + }, + "opts": [ + "connection_string", + "enabled", + "es_doc_type", + "es_scroll_size", + "es_scroll_time", + "filter_error_trace", + "hmac_keys", + "sentinel_service_name", + "socket_timeout", + "trace_requests", + "trace_sqlalchemy" + ] + }, + "profiler_jaeger": { + "deprecated": {}, + "opts": [ + "process_tags", + "service_name_prefix" + ] + }, + "profiler_otlp": { + "deprecated": {}, + "opts": [ + "service_name_prefix" + ] + }, + "receipt": { + "deprecated": {}, + "opts": [ + "cache_on_issue", + "cache_time", + "caching", + "expiration", + "provider" + ] + }, + "resource": { + "deprecated": {}, + "opts": [ + "admin_project_domain_name", + "admin_project_name", + "cache_time", + "caching", + "domain_name_url_safe", + "driver", + "list_limit", + "project_name_url_safe" + ] + }, + "revoke": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "expiration_buffer" + ] + }, + "role": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "list_limit" + ] + }, + "saml": { + "deprecated": {}, + "opts": [ + "assertion_expiration_time", + "certfile", + "idp_contact_company", + "idp_contact_email", + "idp_contact_name", + "idp_contact_surname", + "idp_contact_telephone", + "idp_contact_type", + "idp_entity_id", + "idp_lang", + "idp_metadata_path", + "idp_organization_display_name", + "idp_organization_name", + "idp_organization_url", + "idp_sso_endpoint", + "keyfile", + "relay_state_prefix", + "xmlsec1_binary" + ] + }, + "security_compliance": { + "deprecated": {}, + "opts": [ + "change_password_upon_first_use", + "disable_user_account_days_inactive", + "invalid_password_hash_function", + "invalid_password_hash_max_chars", + "invalid_password_hash_secret_key", + "lockout_duration", + "lockout_failure_attempts", + "minimum_password_age", + "password_expires_days", + "password_regex", + "password_regex_description", + "report_invalid_password_hash", + "unique_last_password_count" + ] + }, + "shadow_users": { + "deprecated": {}, + "opts": [ + "driver" + ] + }, + "token": { + "deprecated": { + "revocation_cache_time": "[revoke] cache_time" + }, + "opts": [ + "allow_expired_window", + "allow_rescope_scoped_token", + "cache_on_issue", + "cache_time", + "caching", + "expiration", + "provider", + "revoke_by_id" + ] + }, + "tokenless_auth": { + "deprecated": {}, + "opts": [ + "issuer_attribute", + "protocol", + "trusted_issuer" + ] + }, + "totp": { + "deprecated": {}, + "opts": [ + "included_previous_windows" + ] + }, + "trust": { + "deprecated": {}, + "opts": [ + "allow_redelegation", + "driver", + "max_redelegation_count" + ] + }, + "unified_limit": { + "deprecated": {}, + "opts": [ + "cache_time", + "caching", + "driver", + "enforcement_model", + "list_limit" + ] + }, + "wsgi": { + "deprecated": {}, + "opts": [ + "debug_middleware" + ] + } + }, + "service": "keystone" +} diff --git a/operators/keystone/api/v1alpha1/integration_test.go b/operators/keystone/api/v1alpha1/integration_test.go index 99bba8856..8aeeac36e 100644 --- a/operators/keystone/api/v1alpha1/integration_test.go +++ b/operators/keystone/api/v1alpha1/integration_test.go @@ -1095,6 +1095,31 @@ func TestIntegration_AcceptsValidNonDefaultMarkers(t *testing.T) { "valid non-default validation-marker values should be accepted") } +// TestIntegration_WebhookRejectsUnknownExtraConfigOption pins the extraConfig +// option-catalog check through the real API server: a CR whose extraConfig names +// an option the pinned release's catalog does not accept is rejected by the +// validating webhook. "providr" is a typo for the [token] provider option. +func TestIntegration_WebhookRejectsUnknownExtraConfigOption(t *testing.T) { + testutil.SkipIfEnvTestUnavailable(t) + g := NewGomegaWithT(t) + + c, ctx, _ := setupEnvTest(t) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "test-extraconfig-catalog-"}} + g.Expect(c.Create(ctx, ns)).To(Succeed()) + + k := validIntegrationKeystone("extraconfig-unknown-option", ns.Name) + k.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "fernet"}, + } + + err := c.Create(ctx, k) + g.Expect(err).To(HaveOccurred(), "unknown extraConfig option should be rejected by the webhook") + g.Expect(apierrors.IsInvalid(err) || apierrors.IsForbidden(err)).To(BeTrue(), + fmt.Sprintf("expected Invalid or Forbidden status error, got: %v", err)) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) +} + // --- KeystoneIdentityBackend CRD schema (CEL + defaults) --- // validIntegrationIdentityBackend returns a minimal valid backend CR for diff --git a/operators/keystone/api/v1alpha1/keystone_webhook.go b/operators/keystone/api/v1alpha1/keystone_webhook.go index bd4da9ab6..69c9b9d21 100644 --- a/operators/keystone/api/v1alpha1/keystone_webhook.go +++ b/operators/keystone/api/v1alpha1/keystone_webhook.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "net/url" + "reflect" + "sort" appsv1 "k8s.io/api/apps/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -19,7 +21,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "github.com/c5c3/forge/internal/common/config" "github.com/c5c3/forge/internal/common/policy" + "github.com/c5c3/forge/internal/common/release" commonv1 "github.com/c5c3/forge/internal/common/types" "github.com/c5c3/forge/internal/common/validation" ) @@ -224,12 +228,24 @@ func (w *KeystoneWebhook) Default(_ context.Context, obj *Keystone) error { // ValidateCreate implements admission.Validator[*Keystone]. func (w *KeystoneWebhook) ValidateCreate(ctx context.Context, obj *Keystone) (admission.Warnings, error) { - return warnCleartextTrustedDashboards(obj), w.validate(ctx, obj) + catalogWarnings, catalogErrs := validateExtraConfigOptions(field.NewPath("spec"), obj) + return append(warnCleartextTrustedDashboards(obj), catalogWarnings...), w.validate(ctx, obj, catalogErrs) } // ValidateUpdate implements admission.Validator[*Keystone]. -func (w *KeystoneWebhook) ValidateUpdate(ctx context.Context, _, newObj *Keystone) (admission.Warnings, error) { - return warnCleartextTrustedDashboards(newObj), w.validate(ctx, newObj) +// +// The extraConfig option-catalog check is re-run only when one of its inputs +// changed (extraConfig, the plugin config-section set, or the image tag). This +// keeps an unrelated update — for example scaling replicas — from retroactively +// rejecting a CR whose extraConfig was accepted at create time but has since +// been invalidated by a regenerated catalog. +func (w *KeystoneWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj *Keystone) (admission.Warnings, error) { + var catalogWarnings admission.Warnings + var catalogErrs field.ErrorList + if extraConfigCatalogInputsChanged(oldObj, newObj) { + catalogWarnings, catalogErrs = validateExtraConfigOptions(field.NewPath("spec"), newObj) + } + return append(warnCleartextTrustedDashboards(newObj), catalogWarnings...), w.validate(ctx, newObj, catalogErrs) } // ValidateDelete implements admission.Validator[*Keystone]. The method is @@ -243,7 +259,9 @@ func (w *KeystoneWebhook) ValidateDelete(_ context.Context, _ *Keystone) (admiss // validate runs all validation rules against the Keystone spec. // ctx is required for cluster-scoped lookups (PriorityClass validation). -func (w *KeystoneWebhook) validate(ctx context.Context, k *Keystone) error { +// extra carries errors accumulated by the caller (the extraConfig option-catalog +// check) so they aggregate into the single Invalid error alongside the rest. +func (w *KeystoneWebhook) validate(ctx context.Context, k *Keystone, extra field.ErrorList) error { var allErrs field.ErrorList specPath := field.NewPath("spec") @@ -818,6 +836,8 @@ func (w *KeystoneWebhook) validate(ctx context.Context, k *Keystone) error { )...) } + allErrs = append(allErrs, extra...) + if len(allErrs) > 0 { return apierrors.NewInvalid( schema.GroupKind{Group: GroupVersion.Group, Kind: "Keystone"}, @@ -919,3 +939,120 @@ func warnCleartextTrustedDashboards(k *Keystone) admission.Warnings { } return warnings } + +// validateExtraConfigOptions validates the option names in spec.extraConfig +// against the option catalog embedded for the release named by spec.image.tag. +// +// It fails open: when the image cannot be resolved to an embedded catalog it +// returns exactly one warning and no errors, so a digest-pinned image, an +// unparseable tag, or a build that ships no catalog for the release never blocks +// admission. Sections a loaded plugin declares (spec.plugins[].configSection) +// and the operator-owned keys in OwnedConfigKeys are exempt from the scan, so a +// plugin's dynamically named options and an operator-owned override are never +// mistaken for unknown options. Every remaining unknown option or unknown +// section becomes a field error; every deprecated-but-accepted option becomes a +// warning naming its replacement. +func validateExtraConfigOptions(specPath *field.Path, k *Keystone) (admission.Warnings, field.ErrorList) { + if len(k.Spec.ExtraConfig) == 0 { + return nil, nil + } + + catalog, ok := OptionCatalogForRelease(k.Spec.Image.Tag) + if !ok { + // Fail open with exactly one warning. Distinguish an image that does not + // name a release at all (empty tag, digest pin, "latest") from a + // parseable release the operator ships no catalog for. + rel, err := release.ParseRelease(k.Spec.Image.Tag) + if err != nil { + return admission.Warnings{ + "spec.extraConfig was not validated against an option catalog: " + + "spec.image does not name an OpenStack release", + }, nil + } + return admission.Warnings{ + fmt.Sprintf("spec.extraConfig was not validated against an option catalog: "+ + "no catalog for release %q is embedded in this operator build", + fmt.Sprintf("%d.%d", rel.Year, rel.Minor)), + }, nil + } + + // Exempt the sections a loaded plugin owns (their option set is not + // enumerable from the release catalog) and the operator-owned keys, so + // neither is flagged as unknown. + pluginSections := make(map[string]struct{}, len(k.Spec.Plugins)) + for _, p := range k.Spec.Plugins { + pluginSections[p.ConfigSection] = struct{}{} + } + ex := config.CatalogExemptions{ + Sections: pluginSections, + Keys: config.KeyExemptionsFromRegistry(OwnedConfigKeys), + } + + base := catalog.Release + unknown, deprecated := catalog.FindUnknownOptions(k.Spec.ExtraConfig, ex) + + extraConfigPath := specPath.Child("extraConfig") + var errs field.ErrorList + for _, u := range unknown { + fldPath := extraConfigPath.Key(u.Section).Key(u.Key) + if u.SectionUnknown { + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such section in the keystone %s option catalog "+ + "(sections registered by a loaded plugin must be declared via spec.plugins)", base))) + continue + } + errs = append(errs, field.Invalid(fldPath, u.Key, + fmt.Sprintf("no such option in the keystone %s option catalog", base))) + } + + var warnings admission.Warnings + for _, d := range deprecated { + if d.Replacement == "" { + warnings = append(warnings, fmt.Sprintf( + "spec.extraConfig [%s] %s: deprecated option in keystone %s with no replacement", + d.Section, d.Key, base, + )) + continue + } + warnings = append(warnings, fmt.Sprintf( + "spec.extraConfig [%s] %s: deprecated option in keystone %s, replaced by %s", + d.Section, d.Key, base, d.Replacement, + )) + } + return warnings, errs +} + +// extraConfigCatalogInputsChanged reports whether anything the extraConfig +// option-catalog check depends on differs between oldObj and newObj: the +// extraConfig map itself, the image tag that selects the release catalog, or the +// set of plugin config sections (compared order-insensitively, since it only +// feeds an exemption set). ValidateUpdate gates the catalog re-validation on +// this so a CR whose extraConfig went stale-invalid is not rejected by an +// otherwise-unrelated update. +func extraConfigCatalogInputsChanged(oldObj, newObj *Keystone) bool { + if !reflect.DeepEqual(oldObj.Spec.ExtraConfig, newObj.Spec.ExtraConfig) { + return true + } + if oldObj.Spec.Image.Tag != newObj.Spec.Image.Tag { + return true + } + if len(oldObj.Spec.Plugins) != len(newObj.Spec.Plugins) { + return true + } + oldSections := make([]string, len(oldObj.Spec.Plugins)) + for i, p := range oldObj.Spec.Plugins { + oldSections[i] = p.ConfigSection + } + newSections := make([]string, len(newObj.Spec.Plugins)) + for i, p := range newObj.Spec.Plugins { + newSections[i] = p.ConfigSection + } + sort.Strings(oldSections) + sort.Strings(newSections) + for i := range oldSections { + if oldSections[i] != newSections[i] { + return true + } + } + return false +} diff --git a/operators/keystone/api/v1alpha1/keystone_webhook_test.go b/operators/keystone/api/v1alpha1/keystone_webhook_test.go index d0fff2608..27e3b1f4b 100644 --- a/operators/keystone/api/v1alpha1/keystone_webhook_test.go +++ b/operators/keystone/api/v1alpha1/keystone_webhook_test.go @@ -3257,6 +3257,192 @@ func TestDefault_DefaultsModeOnlyWhenBlockPresent(t *testing.T) { }) } +// --- extraConfig option-catalog validation tests --- + +// TestValidate_ExtraConfigUnknownOptionRejected pins that an unknown option name +// under a known catalog section is rejected. "providr" is a typo for the token +// [token] provider option, so the catalog does not list it. +func TestValidate_ExtraConfigUnknownOptionRejected(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "fernet"}, + } + + _, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) +} + +// TestValidate_ExtraConfigUnknownSectionRejected pins that an option under a +// section the catalog does not contain is rejected as an unknown section. +// "fernet_token" (singular) is a typo for "fernet_tokens". +func TestValidate_ExtraConfigUnknownSectionRejected(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.ExtraConfig = map[string]map[string]string{ + "fernet_token": {"max_active_keys": "5"}, + } + + _, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such section in the keystone 2025.2 option catalog")) +} + +// TestValidate_ExtraConfigOwnershipRegistryPairExempt pins that an +// operator-owned (section, key) pair is exempt even when the catalog does not +// contain that section. [memcache] servers is a registry entry whose section is +// deliberately absent from the 2025.2 catalog. +func TestValidate_ExtraConfigOwnershipRegistryPairExempt(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.ExtraConfig = map[string]map[string]string{ + "memcache": {"servers": "mc1:11211,mc2:11211"}, + } + + _, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).NotTo(HaveOccurred()) +} + +// TestValidate_ExtraConfigPluginSectionExempt pins that a section declared by a +// loaded plugin is skipped whole, so options under it are never flagged even +// though the catalog cannot enumerate them. +func TestValidate_ExtraConfigPluginSectionExempt(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.Plugins = []commonv1.PluginSpec{ + {Name: "keycloak-backend", ConfigSection: "keycloak"}, + } + k.Spec.ExtraConfig = map[string]map[string]string{ + "keycloak": {"auth_url": "https://kc.example.com", "client_id": "keystone"}, + } + + _, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).NotTo(HaveOccurred()) +} + +// TestValidate_ExtraConfigDigestPinFailsOpen pins the fail-open behavior for a +// digest-pinned image: the option catalog cannot be resolved, so the check +// emits a single warning and no error even though "providr" is unknown. +func TestValidate_ExtraConfigDigestPinFailsOpen(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.Image.Tag = "" + k.Spec.Image.Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + k.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "x"}, + } + + warnings, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement( + "spec.extraConfig was not validated against an option catalog: spec.image does not name an OpenStack release", + )) +} + +// TestValidate_ExtraConfigUnknownReleaseFailsOpen pins the fail-open behavior for +// a parseable release the operator ships no catalog for. +func TestValidate_ExtraConfigUnknownReleaseFailsOpen(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.Image.Tag = "2027.1" + k.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "x"}, + } + + warnings, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement( + `spec.extraConfig was not validated against an option catalog: no catalog for release "2027.1" is embedded in this operator build`, + )) +} + +// TestValidate_ExtraConfigDeprecatedOptionWarns pins that a deprecated-but-still- +// accepted option is honored (no error) but surfaces a warning naming its +// replacement. [DEFAULT] logfile is deprecated in favor of [DEFAULT] log_file. +func TestValidate_ExtraConfigDeprecatedOptionWarns(t *testing.T) { + g := NewGomegaWithT(t) + w := &KeystoneWebhook{} + k := validKeystone() + k.Spec.ExtraConfig = map[string]map[string]string{ + "DEFAULT": {"logfile": "/var/log/keystone.log"}, + } + + warnings, err := w.ValidateCreate(context.Background(), k) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(warnings).To(ContainElement(ContainSubstring( + "deprecated option in keystone 2025.2, replaced by [DEFAULT] log_file", + ))) +} + +// TestValidateUpdate_ExtraConfigCatalogGate exercises all four arms of the +// UPDATE re-validation gate. The old object carries a since-invalidated +// extraConfig; the check re-runs only when extraConfig, the plugin config-section +// set, or the image tag changes. +func TestValidateUpdate_ExtraConfigCatalogGate(t *testing.T) { + w := &KeystoneWebhook{} + oldObj := func() *Keystone { + k := validKeystone() + k.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "fernet"}, + } + return k + } + + t.Run("unrelated edit is accepted", func(t *testing.T) { + g := NewGomegaWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.Deployment.Replicas = 2 + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).NotTo(HaveOccurred()) + }) + + t.Run("extraConfig edit is re-validated and rejected", func(t *testing.T) { + g := NewGomegaWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.ExtraConfig = map[string]map[string]string{ + "token": {"providr": "uuid"}, + } + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) + }) + + t.Run("image tag edit is re-validated and rejected", func(t *testing.T) { + g := NewGomegaWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.Image.Tag = "2026.1" + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2026.1 option catalog")) + }) + + t.Run("plugin-list edit is re-validated and rejected", func(t *testing.T) { + g := NewGomegaWithT(t) + old := oldObj() + newObj := old.DeepCopy() + newObj.Spec.Plugins = []commonv1.PluginSpec{ + {Name: "keycloak-backend", ConfigSection: "keycloak"}, + } + + _, err := w.ValidateUpdate(context.Background(), old, newObj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("no such option in the keystone 2025.2 option catalog")) + }) +} + // --- Interface compliance --- func TestKeystoneWebhook_ImplementsInterfaces(t *testing.T) { diff --git a/operators/keystone/api/v1alpha1/option_catalog.go b/operators/keystone/api/v1alpha1/option_catalog.go new file mode 100644 index 000000000..c24905ed8 --- /dev/null +++ b/operators/keystone/api/v1alpha1/option_catalog.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "embed" + + "github.com/c5c3/forge/internal/common/config" +) + +// catalogFS holds the generated per-release keystone option catalogs embedded +// into the operator binary. Each file is named ".json" and decodes into +// a config.OptionCatalog, so the validating webhook can reject spec.extraConfig +// option names the pinned release does not accept without reaching a live +// keystone. +// +//go:embed catalogs/*.json +var catalogFS embed.FS + +// optionCatalogs maps a YYYY.N release base (the embedded file's stem) to the +// parsed keystone option catalog for that release. It is built once at package +// initialization. +var optionCatalogs = config.MustParseEmbeddedCatalogs(catalogFS) + +// OptionCatalogForRelease returns the embedded keystone option catalog for the +// OpenStack release named by the given image tag. See config.LookupCatalog for +// the tag-to-catalog resolution rules. +func OptionCatalogForRelease(tag string) (*config.OptionCatalog, bool) { + return config.LookupCatalog(optionCatalogs, tag) +} diff --git a/operators/keystone/api/v1alpha1/option_catalog_test.go b/operators/keystone/api/v1alpha1/option_catalog_test.go new file mode 100644 index 000000000..2cf67a117 --- /dev/null +++ b/operators/keystone/api/v1alpha1/option_catalog_test.go @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +// +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +// TestOptionCatalogs_EmbeddedReleasesParse pins the exact set of embedded +// catalogs and proves each parsed cleanly at package init +// (config.MustParseEmbeddedCatalogs would have panicked otherwise, so merely +// reading optionCatalogs exercises the happy path and keeps the panic branch +// unreachable in production). +func TestOptionCatalogs_EmbeddedReleasesParse(t *testing.T) { + g := NewGomegaWithT(t) + + g.Expect(optionCatalogs).To(HaveLen(2)) + g.Expect(optionCatalogs).To(HaveKey("2025.2")) + g.Expect(optionCatalogs).To(HaveKey("2026.1")) + + for rel, catalog := range optionCatalogs { + g.Expect(catalog.Sections).To(HaveKey("DEFAULT"), "release %s must have a DEFAULT section", rel) + g.Expect(catalog.Sections["DEFAULT"].Opts).To(ContainElement("debug"), + "release %s DEFAULT must list debug", rel) + } +} + +// TestOptionCatalogs_UseUnderscoreSpelling guards the invariant FindUnknownOptions +// relies on: catalog option names, deprecated keys, and deprecated replacements +// all use the underscore spelling oslo.config reads from a file, never the CLI +// dash form. A dash-spelled entry would silently make a valid underscore +// override look unknown (or vice versa). +func TestOptionCatalogs_UseUnderscoreSpelling(t *testing.T) { + g := NewGomegaWithT(t) + + for rel, catalog := range optionCatalogs { + g.Expect(catalog.Sections["DEFAULT"].Opts).To(ContainElement("log_config_append"), + "release %s DEFAULT must list log_config_append", rel) + for section, body := range catalog.Sections { + for _, opt := range body.Opts { + g.Expect(opt).NotTo(ContainSubstring("-"), + "release %s [%s] opt %q must use underscore spelling", rel, section, opt) + } + for key, replacement := range body.Deprecated { + g.Expect(key).NotTo(ContainSubstring("-"), + "release %s [%s] deprecated key %q must use underscore spelling", rel, section, key) + g.Expect(replacement).NotTo(ContainSubstring("-"), + "release %s [%s] deprecated replacement %q must use underscore spelling", rel, section, replacement) + } + } + } +} + +// TestOptionCatalogForRelease covers the release-to-catalog resolution: a base +// release and its patch suffix both resolve to the same catalog, while an empty +// tag, a digest-style "latest", and a parseable-but-unshipped release all miss. +func TestOptionCatalogForRelease(t *testing.T) { + g := NewGomegaWithT(t) + + cat2025, ok := OptionCatalogForRelease("2025.2") + g.Expect(ok).To(BeTrue()) + g.Expect(cat2025).NotTo(BeNil()) + g.Expect(cat2025.Release).To(Equal("2025.2")) + + // A patch suffix strips to the same base-release catalog (same pointer). + catPatch, ok := OptionCatalogForRelease("2025.2-p1") + g.Expect(ok).To(BeTrue()) + g.Expect(catPatch).To(BeIdenticalTo(cat2025)) + + // Tags that do not resolve to an embedded catalog. "2024.2" parses as a + // release but no catalog is embedded for it. + for _, tag := range []string{"", "latest", "2024.2"} { + catalog, ok := OptionCatalogForRelease(tag) + g.Expect(ok).To(BeFalse(), "tag %q must not resolve to a catalog", tag) + g.Expect(catalog).To(BeNil(), "tag %q must return a nil catalog", tag) + } +} + +// TestOptionCatalog2025_DeprecatesLogfile pins one representative deprecation the +// webhook's deprecated-option warning depends on. +func TestOptionCatalog2025_DeprecatesLogfile(t *testing.T) { + g := NewGomegaWithT(t) + + catalog, ok := OptionCatalogForRelease("2025.2") + g.Expect(ok).To(BeTrue()) + g.Expect(catalog.Sections["DEFAULT"].Deprecated).To(HaveKeyWithValue("logfile", "[DEFAULT] log_file")) +} diff --git a/tests/container-images/verify_release_config.sh b/tests/container-images/verify_release_config.sh index 53817e91a..8e62f2892 100755 --- a/tests/container-images/verify_release_config.sh +++ b/tests/container-images/verify_release_config.sh @@ -410,6 +410,46 @@ test_test_excludes_files_match_services() { fi } +# --- Test 8: per-release option catalogs exist and parse as JSON --- +test_option_catalogs_present_and_parse() { + echo "Test: option catalogs exist and parse as JSON for every release" + + local found_any=false + for release_dir in "$PROJECT_ROOT"/releases/*/; do + [ -d "$release_dir" ] || continue + found_any=true + local release_name + release_name=$(basename "$release_dir") + + # Both keystone and glance ship a per-release option catalog. yq reads + # JSON, so it doubles as the parse check. + local catalog_service + for catalog_service in keystone glance; do + local catalog="$PROJECT_ROOT/operators/${catalog_service}/api/v1alpha1/catalogs/${release_name}.json" + local rel_path="${catalog#"$PROJECT_ROOT"/}" + + if [ ! -f "$catalog" ]; then + echo " FAIL: [$release_name] $rel_path is missing" + FAIL=$((FAIL + 1)) + continue + fi + + if yq '.' "$catalog" > /dev/null 2>&1; then + echo " PASS: [$release_name] $rel_path exists and parses as JSON" + PASS=$((PASS + 1)) + else + echo " FAIL: [$release_name] $rel_path does not parse as JSON" + FAIL=$((FAIL + 1)) + fi + done + done + + if [ "$found_any" = false ]; then + echo " FAIL: no releases/*/ directories found" + FAIL=$((FAIL + 1)) + fi +} + # --- Run all tests --- echo "=== Release config verification tests ===" echo "" @@ -427,6 +467,8 @@ test_test_excludes_directory_structure echo "" test_test_excludes_files_match_services echo "" +test_option_catalogs_present_and_parse +echo "" echo "=== Results: $PASS passed, $FAIL failed ===" if [ "$FAIL" -gt 0 ]; then diff --git a/tests/e2e/glance/invalid-cr/11-extraconfig-unknown-option.yaml b/tests/e2e/glance/invalid-cr/11-extraconfig-unknown-option.yaml new file mode 100644 index 000000000..b22047117 --- /dev/null +++ b/tests/e2e/glance/invalid-cr/11-extraconfig-unknown-option.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# spec.extraConfig setting an unknown option 'default_backend_typo' in +# the known [glance_store] section is rejected by the validating webhook: +# the option is absent from the glance 2025.2 option catalog, so a typo'd +# key can never silently reach the rendered glance-api.conf. +apiVersion: glance.openstack.c5c3.io/v1alpha1 +kind: Glance +metadata: + name: glance-invalid-extraconfig-unknown-option +spec: + openStackRelease: "2025.2" + deployment: + replicas: 1 + image: + repository: ghcr.io/c5c3/glance + tag: "2025.2" + database: + clusterRef: + name: openstack-db + database: glance + secretRef: + name: glance-db + cache: + clusterRef: + name: openstack-memcached + keystoneEndpoint: http://keystone.openstack.svc.cluster.local:5000/v3 + serviceUser: + secretRef: + name: glance-service-password + extraConfig: + glance_store: + default_backend_typo: s3 diff --git a/tests/e2e/glance/invalid-cr/12-extraconfig-unknown-section.yaml b/tests/e2e/glance/invalid-cr/12-extraconfig-unknown-section.yaml new file mode 100644 index 000000000..3b342a723 --- /dev/null +++ b/tests/e2e/glance/invalid-cr/12-extraconfig-unknown-section.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# spec.extraConfig declaring an unknown section 'glance_stor' (a typo for +# [glance_store]) is rejected by the validating webhook: the section is +# absent from the glance 2025.2 option catalog, so a typo'd section name +# can never silently reach the rendered glance-api.conf. +apiVersion: glance.openstack.c5c3.io/v1alpha1 +kind: Glance +metadata: + name: glance-invalid-extraconfig-unknown-section +spec: + openStackRelease: "2025.2" + deployment: + replicas: 1 + image: + repository: ghcr.io/c5c3/glance + tag: "2025.2" + database: + clusterRef: + name: openstack-db + database: glance + secretRef: + name: glance-db + cache: + clusterRef: + name: openstack-memcached + keystoneEndpoint: http://keystone.openstack.svc.cluster.local:5000/v3 + serviceUser: + secretRef: + name: glance-service-password + extraConfig: + glance_stor: + default_backend: s3 diff --git a/tests/e2e/glance/invalid-cr/_generate.py b/tests/e2e/glance/invalid-cr/_generate.py index 9accc06e3..2236caf21 100644 --- a/tests/e2e/glance/invalid-cr/_generate.py +++ b/tests/e2e/glance/invalid-cr/_generate.py @@ -273,6 +273,36 @@ def render(self) -> str: " password: s3cr3t\n" ), ), + Fixture( + filename="11-extraconfig-unknown-option.yaml", + comment=( + "spec.extraConfig setting an unknown option 'default_backend_typo' in\n" + "the known [glance_store] section is rejected by the validating webhook:\n" + "the option is absent from the glance 2025.2 option catalog, so a typo'd\n" + "key can never silently reach the rendered glance-api.conf." + ), + name="glance-invalid-extraconfig-unknown-option", + extra=( + " extraConfig:\n" + " glance_store:\n" + " default_backend_typo: s3\n" + ), + ), + Fixture( + filename="12-extraconfig-unknown-section.yaml", + comment=( + "spec.extraConfig declaring an unknown section 'glance_stor' (a typo for\n" + "[glance_store]) is rejected by the validating webhook: the section is\n" + "absent from the glance 2025.2 option catalog, so a typo'd section name\n" + "can never silently reach the rendered glance-api.conf." + ), + name="glance-invalid-extraconfig-unknown-section", + extra=( + " extraConfig:\n" + " glance_stor:\n" + " default_backend: s3\n" + ), + ), ) diff --git a/tests/e2e/glance/invalid-cr/chainsaw-test.yaml b/tests/e2e/glance/invalid-cr/chainsaw-test.yaml index 750fbcade..45304d987 100644 --- a/tests/e2e/glance/invalid-cr/chainsaw-test.yaml +++ b/tests/e2e/glance/invalid-cr/chainsaw-test.yaml @@ -118,3 +118,21 @@ spec: - check: ($error != null): true (contains($error, 'password is managed via spec.serviceUser.secretRef')): true + # spec.extraConfig setting an unknown option (typo'd key) in a known section + # must be rejected: it is absent from the glance 2025.2 option catalog. + - try: + - apply: + file: 11-extraconfig-unknown-option.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such option in the glance 2025.2 option catalog')): true + # spec.extraConfig declaring an unknown section (typo'd section name) must be + # rejected: it is absent from the glance 2025.2 option catalog. + - try: + - apply: + file: 12-extraconfig-unknown-section.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such section in the glance 2025.2 option catalog')): true diff --git a/tests/e2e/glance/invalid-cr/test_generate.py b/tests/e2e/glance/invalid-cr/test_generate.py index 00134789b..065e87e08 100644 --- a/tests/e2e/glance/invalid-cr/test_generate.py +++ b/tests/e2e/glance/invalid-cr/test_generate.py @@ -35,7 +35,7 @@ # Number of fixtures emitted by _generate.py. Bumping this value requires # adding the matching Fixture entry AND the matching `file: ` line in # chainsaw-test.yaml. -_EXPECTED_FIXTURE_COUNT = 11 +_EXPECTED_FIXTURE_COUNT = 13 def _load_generator() -> types.ModuleType: diff --git a/tests/e2e/keystone/config-pruning/chainsaw-test.yaml b/tests/e2e/keystone/config-pruning/chainsaw-test.yaml index 66e278124..77970cc4c 100644 --- a/tests/e2e/keystone/config-pruning/chainsaw-test.yaml +++ b/tests/e2e/keystone/config-pruning/chainsaw-test.yaml @@ -60,6 +60,13 @@ spec: # Each patch sets a unique extraConfig value, producing a new immutable # ConfigMap. With retain=3, after the 4th patch the operator must prune # the oldest ConfigMap, capping the total at retain + 1 = 4. + # The marker key is [DEFAULT] instance_format, a real oslo.log option that + # the extraConfig webhook accepts against the 2025.2 catalog (an invented + # key would now be rejected on every patch). Its value is not validated, and + # instance_format is only interpolated into log records that carry instance + # context — which keystone never emits — so it stays inert while each + # iteration's distinct value still yields a distinct rendered config hash and + # thus a new immutable ConfigMap. - try: - script: timeout: 300s @@ -93,11 +100,14 @@ spec: echo "Initial ConfigMap count: $(count_cms)" - # Apply 4 sequential config changes via extraConfig patches. + # Apply 4 sequential config changes via extraConfig patches. The value + # is inert (instance_format only affects instance-context log records, + # which keystone never emits) but distinct per iteration, so each patch + # renders a new config hash and a new immutable ConfigMap. for i in 1 2 3 4; do echo "=== Patch $i: setting extraConfig ===" kubectl patch keystone "$CR_NAME" -n "$NS" --type=merge -p \ - "{\"spec\":{\"extraConfig\":{\"DEFAULT\":{\"e2e_pruning_marker\":\"iteration-$i\"}}}}" + "{\"spec\":{\"extraConfig\":{\"DEFAULT\":{\"instance_format\":\"iteration-$i\"}}}}" # Wait for the reconciler to process the change. sleep 5 diff --git a/tests/e2e/keystone/invalid-cr/24-extraconfig-unknown-option.yaml b/tests/e2e/keystone/invalid-cr/24-extraconfig-unknown-option.yaml new file mode 100644 index 000000000..bdc45efa8 --- /dev/null +++ b/tests/e2e/keystone/invalid-cr/24-extraconfig-unknown-option.yaml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Keystone CR whose spec.extraConfig sets "providr" (a typo for "provider") in +# the known [token] section. The option does not exist in the keystone 2025.2 +# option catalog, so the validating webhook rejects it — a typo'd key must +# never silently reach the rendered keystone.conf where oslo.config would +# ignore it. Admission must reject this CR with an $error referencing the +# substring "no such option in the keystone 2025.2 option catalog". +apiVersion: keystone.openstack.c5c3.io/v1alpha1 +kind: Keystone +metadata: + name: invalid-extraconfig-unknown-option +spec: + deployment: + replicas: 3 + image: + repository: ghcr.io/c5c3/keystone + tag: "2025.2" + database: + host: db.example.com + port: 3306 + database: keystone + secretRef: + name: keystone-db + cache: + backend: dogpile.cache.pymemcache + servers: + - "mc:11211" + fernet: + rotationSchedule: "0 0 * * 0" + maxActiveKeys: 3 + bootstrap: + adminUser: admin + adminPasswordSecretRef: + name: keystone-admin + region: RegionOne + extraConfig: + token: + providr: fernet diff --git a/tests/e2e/keystone/invalid-cr/25-extraconfig-unknown-section.yaml b/tests/e2e/keystone/invalid-cr/25-extraconfig-unknown-section.yaml new file mode 100644 index 000000000..9c84a0c18 --- /dev/null +++ b/tests/e2e/keystone/invalid-cr/25-extraconfig-unknown-section.yaml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Keystone CR whose spec.extraConfig declares a "fernet_token" section +# (singular — a typo for "fernet_tokens"). The section does not exist in the +# keystone 2025.2 option catalog, so the validating webhook rejects it — a +# typo'd section name must never silently reach the rendered keystone.conf. +# Admission must reject this CR with an $error referencing the substring +# "no such section in the keystone 2025.2 option catalog". +apiVersion: keystone.openstack.c5c3.io/v1alpha1 +kind: Keystone +metadata: + name: invalid-extraconfig-unknown-section +spec: + deployment: + replicas: 3 + image: + repository: ghcr.io/c5c3/keystone + tag: "2025.2" + database: + host: db.example.com + port: 3306 + database: keystone + secretRef: + name: keystone-db + cache: + backend: dogpile.cache.pymemcache + servers: + - "mc:11211" + fernet: + rotationSchedule: "0 0 * * 0" + maxActiveKeys: 3 + bootstrap: + adminUser: admin + adminPasswordSecretRef: + name: keystone-admin + region: RegionOne + extraConfig: + fernet_token: + max_active_keys: "5" diff --git a/tests/e2e/keystone/invalid-cr/_generate.py b/tests/e2e/keystone/invalid-cr/_generate.py index e1ef1e257..00124f811 100644 --- a/tests/e2e/keystone/invalid-cr/_generate.py +++ b/tests/e2e/keystone/invalid-cr/_generate.py @@ -677,6 +677,38 @@ def render(fixture: Fixture) -> str: # Admission must reject this CR with an $error referencing the substring # "trusted_dashboard is managed via spec.federation.trustedDashboards".""", ), + Fixture( + filename="24-extraconfig-unknown-option.yaml", + name="invalid-extraconfig-unknown-option", + trailing=( + " extraConfig:\n" + " token:\n" + " providr: fernet\n" + ), + comment="""\ +# Keystone CR whose spec.extraConfig sets "providr" (a typo for "provider") in +# the known [token] section. The option does not exist in the keystone 2025.2 +# option catalog, so the validating webhook rejects it — a typo'd key must +# never silently reach the rendered keystone.conf where oslo.config would +# ignore it. Admission must reject this CR with an $error referencing the +# substring "no such option in the keystone 2025.2 option catalog".""", + ), + Fixture( + filename="25-extraconfig-unknown-section.yaml", + name="invalid-extraconfig-unknown-section", + trailing=( + " extraConfig:\n" + " fernet_token:\n" + ' max_active_keys: "5"\n' + ), + comment="""\ +# Keystone CR whose spec.extraConfig declares a "fernet_token" section +# (singular — a typo for "fernet_tokens"). The section does not exist in the +# keystone 2025.2 option catalog, so the validating webhook rejects it — a +# typo'd section name must never silently reach the rendered keystone.conf. +# Admission must reject this CR with an $error referencing the substring +# "no such section in the keystone 2025.2 option catalog".""", + ), ] diff --git a/tests/e2e/keystone/invalid-cr/chainsaw-test.yaml b/tests/e2e/keystone/invalid-cr/chainsaw-test.yaml index 983fa73d8..8be7ff967 100644 --- a/tests/e2e/keystone/invalid-cr/chainsaw-test.yaml +++ b/tests/e2e/keystone/invalid-cr/chainsaw-test.yaml @@ -343,3 +343,21 @@ spec: - check: ($error != null): true (contains($error, 'trusted_dashboard is managed via spec.federation.trustedDashboards')): true + # spec.extraConfig sets an unknown option (typo'd key) in a known section: + # rejected because it is absent from the keystone 2025.2 option catalog. + - try: + - apply: + file: 24-extraconfig-unknown-option.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such option in the keystone 2025.2 option catalog')): true + # spec.extraConfig declares an unknown section (typo'd section name): + # rejected because it is absent from the keystone 2025.2 option catalog. + - try: + - apply: + file: 25-extraconfig-unknown-section.yaml + expect: + - check: + ($error != null): true + (contains($error, 'no such section in the keystone 2025.2 option catalog')): true diff --git a/tests/e2e/keystone/invalid-cr/test_generate.py b/tests/e2e/keystone/invalid-cr/test_generate.py index 06dfb10bb..5cc0e2e69 100644 --- a/tests/e2e/keystone/invalid-cr/test_generate.py +++ b/tests/e2e/keystone/invalid-cr/test_generate.py @@ -39,12 +39,14 @@ # Number of fixtures emitted by _generate.py: eleven create-rejection fixtures # (02-12 plus 13-policy-overrides-empty-rule-value), five update-rejection -# fixtures (13-immutable-base and 14-17, #466), and eight validation-marker -# fixtures (13-image-empty-tag through 20-perloggerlevels-invalid-value). The +# fixtures (13-immutable-base and 14-17, #466), eight validation-marker +# fixtures (13-image-empty-tag through 20-perloggerlevels-invalid-value), and +# the two extraConfig option-catalog fixtures (24-extraconfig-unknown-option +# and 25-extraconfig-unknown-section). The # fixtures (00-, 01-) predate and are intentionally NOT generated. Bumping this # value requires adding the matching Fixture entry AND the matching # `file: ` line in chainsaw-test.yaml. -_EXPECTED_FIXTURE_COUNT = 27 +_EXPECTED_FIXTURE_COUNT = 29 def _load_generator() -> types.ModuleType: diff --git a/tests/unit/hack/gen_option_catalog_workdir_perms_test.sh b/tests/unit/hack/gen_option_catalog_workdir_perms_test.sh new file mode 100644 index 000000000..00014d830 --- /dev/null +++ b/tests/unit/hack/gen_option_catalog_workdir_perms_test.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Verify hack/gen-option-catalog.sh opens up its mktemp work directory before +# bind-mounting it into the service image. mktemp -d creates the directory +# 0700 for the invoking user, but oslo-config-generator reads /work as the +# image's non-root user; on a Linux bind mount that user cannot traverse a +# 0700 host directory, so gen.conf must be world-readable behind a +# world-searchable directory. Docker Desktop's file sharing masks this on +# macOS, which is why only CI saw the failure. +# +# The script is exercised with a recording docker stub prepended to PATH: at +# the moment of the first `docker run` the stub captures the permission bits +# of the mount source and of gen.conf inside it, then exits non-zero so the +# script stops there. No image is pulled and no container runs. +# +# Usage: bash tests/unit/hack/gen_option_catalog_workdir_perms_test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +GEN_CATALOG_SH="$PROJECT_ROOT/hack/gen-option-catalog.sh" + +PASS=0 +FAIL=0 +SKIP=0 + +# shellcheck source=tests/lib/assertions.sh +source "$PROJECT_ROOT/tests/lib/assertions.sh" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# make_stubs +# Writes yq and docker shims into : +# yq — resolves any source-ref lookup to a fixed ref. +# docker — `docker image inspect` succeeds so nothing is pulled; +# `docker run` records `ls`-style permission strings of the -v +# mount source and its gen.conf to $PERM_LOG, then exits 1 so the +# script under test stops after the recording. +make_stubs() { + local dir="$1" + + cat > "$dir/yq" <<'EOF' +#!/bin/bash +echo "stable/2025.2" +EOF + + cat > "$dir/docker" <<'EOF' +#!/bin/bash +if [ "$1" = "image" ]; then + exit 0 +fi +if [ "$1" = "run" ]; then + src="" + prev="" + for arg in "$@"; do + if [ "$prev" = "-v" ]; then + src="${arg%%:*}" + fi + prev="$arg" + done + { + echo "workdir $(ls -ld "$src" | cut -c1-10)" + echo "genconf $(ls -l "$src/gen.conf" | cut -c1-10)" + } >> "$PERM_LOG" + exit 1 +fi +exit 0 +EOF + + chmod +x "$dir/yq" "$dir/docker" +} + +# run_script +# Runs gen-option-catalog.sh for keystone 2025.2 with the stubs active and a +# local SOURCE_DIR fixture (so no curl happens). The script is expected to +# exit non-zero because the docker stub aborts the generator run. +run_script() { + local perm_log="$1" + local stub_dir src_dir + stub_dir="$(mktemp -d)" + src_dir="$(mktemp -d)" + make_stubs "$stub_dir" + mkdir -p "$src_dir/config-generator" + cat > "$src_dir/config-generator/keystone.conf" <<'EOF' +[DEFAULT] +namespace = keystone +EOF + + PATH="$stub_dir:$PATH" PERM_LOG="$perm_log" SOURCE_DIR="$src_dir" \ + bash "$GEN_CATALOG_SH" keystone 2025.2 ghcr.io/c5c3/keystone:test >/dev/null 2>&1 + rm -rf "$stub_dir" "$src_dir" +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +test_workdir_world_searchable() { + echo "Test: bind-mounted work directory is world-searchable" + local perm_log + perm_log="$(mktemp)" + run_script "$perm_log" + + local dir_perm conf_perm + dir_perm="$(awk '/^workdir /{print $2}' "$perm_log")" + conf_perm="$(awk '/^genconf /{print $2}' "$perm_log")" + + assert_not_empty "docker stub recorded the mount source permissions" "$dir_perm" + assert_eq "work directory grants other r-x" "r-x" "${dir_perm:7:3}" + assert_eq "gen.conf grants other read" "r" "${conf_perm:7:1}" + rm -f "$perm_log" +} + +test_workdir_world_searchable + +echo "" +echo "Results: $PASS passed, $FAIL failed, $SKIP skipped" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0