Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Work on this branch is **3.0** (M1–M3 plus the follow-on issues below). Linux

### Changed

- **Content Library schema is closed**: the Content Library entry is either Splunkbase `app_id: 5391` (any name) or `source: local` named `DA-ITSI-ContentLibrary`. It needs `itsi_content_pack: true` and exactly one of `content_pack_apps` (key present, non-empty) or `install_all_apps: true`. `install_all_apps` and `content_pack_install` must be booleans when set, so a quoted `"true"` or a null is rejected. The entry accepts `update_mode` and rejects other keys. `content_pack_install`, `force_run_playbook_after_restart`, and each `content_pack_api` value are type-checked. A local entry takes no `app_id`, and its `path`, when set, is a non-empty string. The folder name `DA-ITSI-ContentLibrary` is reserved for the library, so no other app can use it. Pack names are compared without surrounding whitespace, and a pack cannot repeat the library name or another pack name. `spa apps add 5391 --install-all-content-packs` writes top-level `install_all_apps: true`, the key the Ansible role reads, instead of `content_pack_api.install_all_apps`.

- **Validate flag renamed**: `spa validate --splunk-config-aws` is now `spa validate --splunk-config aws`. The old flag is removed, so scripts that pass it must switch to the new form.

- **Skill gate for new environments**: `/spa-create-config` no longer writes config until the location, name, and summary gate passes. A new-environment request never selects an existing environment, and a write to a registered environment needs that environment named first. Each config or apps write checks `resolved_env_name` in its response. Provision, deploy, host check, destroy, suspend, resume, and logs now run from `/spa` after Phase 9 option A hands off there.
Expand Down
170 changes: 170 additions & 0 deletions ansible/plugins/inventory/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,153 @@ class SplunkAppsConfig(BaseModel):
ALLOWED_APP_STATES = ("installed", "absent")
ALLOWED_UPDATE_MODES = ("clean", "merge")
ALLOWED_PREMIUM_APPS = ("itsi",) # Premium apps: target_roles not required; deployment is role-based
CONTENT_LIBRARY_APP_ID = 5391 # Splunk Content Library for ITSI (Splunkbase)
CONTENT_LIBRARY_FOLDER = "DA-ITSI-ContentLibrary" # On-disk folder of the library, also its name for a local archive


def _app_id_value(app: Dict[str, Any]) -> Optional[int]:
"""Return app_id as an int when it is an integer or digit string, else None."""
raw = app.get("app_id")
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str) and raw.strip().isdigit():
return int(raw.strip())
return None


def _is_content_library_entry(app: Dict[str, Any]) -> bool:
"""Splunkbase app_id 5391 (any name), or a local entry named DA-ITSI-ContentLibrary."""
if app.get("source", "splunkbase") == "splunkbase":
return _app_id_value(app) == CONTENT_LIBRARY_APP_ID
return app.get("source") == "local" and app.get("name") == CONTENT_LIBRARY_FOLDER


CONTENT_PACK_ITEM_KEYS = ("name", "content_pack_install", "content_pack_api", "customizations")
# Value type per content_pack_api key, as the apps_itsi_content_pack role reads them.
CONTENT_PACK_API_TYPES = {
"install_all": bool,
"saved_search_action": str,
"backfill": bool,
"resolution": str,
"enabled": bool,
"prefix": str,
}

CONTENT_PACK_CUSTOMIZATION_KEYS = ("run_playbook_after_restart", "force_run_playbook_after_restart", "extra_vars")

CONTENT_LIBRARY_ENTRY_KEYS = (
"name",
"source",
"app_id",
"path",
"itsi_content_pack",
"version",
"state",
"install_all_apps",
"content_pack_apps",
"update_mode",
)


def _validate_content_library_entry(app: Dict[str, Any], i: int, name: str) -> None:
"""Closed key set for the Content Library entry. Pack-level rules are added separately."""
unknown = sorted(str(key) for key in app if key not in CONTENT_LIBRARY_ENTRY_KEYS)
if unknown:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): the Content Library entry does not allow {unknown}. "
f"Allowed keys: {list(CONTENT_LIBRARY_ENTRY_KEYS)}"
)
if "install_all_apps" in app and not isinstance(app["install_all_apps"], bool):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): install_all_apps must be true or false, "
f"got {type(app['install_all_apps']).__name__}"
)
if app.get("install_all_apps") is True and "content_pack_apps" in app:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): set either content_pack_apps or install_all_apps: true, not both"
)
if app.get("source", "splunkbase") == "splunkbase" and "path" in app:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): 'path' is only for a local Content Library archive; "
"a splunkbase library (app_id 5391) downloads from Splunkbase and must not set path"
)
if app.get("source") == "local":
if "app_id" in app:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): a local Content Library archive does not take app_id; "
"app_id is for Splunkbase downloads"
)
if "path" in app and not (isinstance(app["path"], str) and app["path"].strip()):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): 'path' must be a non-empty string naming the local archive"
)
packs = app.get("content_pack_apps")
if isinstance(packs, list):
seen_pack_names: set = set()
for j, item in enumerate(packs):
if not isinstance(item, dict):
continue # the generic content-pack checks report non-object items
if "content_pack_install" in item and not isinstance(item["content_pack_install"], bool):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].content_pack_install must be a bool, "
f"got {type(item['content_pack_install']).__name__}"
)
pack_name = item.get("name")
if isinstance(pack_name, str):
pack_key = pack_name.strip()
if pack_key in seen_pack_names:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}] duplicate name {pack_name!r}; "
"each pack may appear once"
)
seen_pack_names.add(pack_key)
unknown_item = sorted(str(key) for key in item if key not in CONTENT_PACK_ITEM_KEYS)
if unknown_item:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}] does not allow {unknown_item}. "
f"Allowed keys: {list(CONTENT_PACK_ITEM_KEYS)}"
)
api_opts = item.get("content_pack_api")
if api_opts is not None:
if not isinstance(api_opts, dict):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].content_pack_api must be a dictionary"
)
unknown_api = sorted(str(key) for key in api_opts if key not in CONTENT_PACK_API_TYPES)
if unknown_api:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].content_pack_api does not allow "
f"{unknown_api}. Allowed keys: {list(CONTENT_PACK_API_TYPES)}"
)
for api_key, api_value in api_opts.items():
expected = CONTENT_PACK_API_TYPES[api_key]
if not isinstance(api_value, expected):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].content_pack_api.{api_key} "
f"must be a {expected.__name__}, got {type(api_value).__name__}"
)
cust_opts = item.get("customizations")
if isinstance(cust_opts, dict):
unknown_cust = sorted(str(key) for key in cust_opts if key not in CONTENT_PACK_CUSTOMIZATION_KEYS)
if unknown_cust:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].customizations does not allow "
f"{unknown_cust}. Allowed keys: {list(CONTENT_PACK_CUSTOMIZATION_KEYS)}"
)
if "force_run_playbook_after_restart" in cust_opts and not isinstance(
cust_opts["force_run_playbook_after_restart"], bool
):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}].customizations."
f"force_run_playbook_after_restart must be a bool, got {type(cust_opts['force_run_playbook_after_restart']).__name__}"
)
if isinstance(pack_name, str) and pack_name.strip() == str(app.get("name", "")).strip():
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): content_pack_apps[{j}] repeats the library {app.get('name')!r}. "
"The library is the top-level name; list only the additional packs in content_pack_apps"
)


class SplunkAppDeploymentConfig(BaseModel):
Expand Down Expand Up @@ -564,6 +711,29 @@ def validate_app_deployment_apps(self) -> 'SplunkAppDeploymentConfig':
# Normal apps (no premium_app, no itsi_content_pack) must specify target_roles.
is_premium = premium_app and isinstance(premium_app, str) and premium_app.strip()
is_itsi_content_pack = itsi_content_pack is True or (isinstance(itsi_content_pack, bool) and itsi_content_pack)
if _app_id_value(app) == CONTENT_LIBRARY_APP_ID:
if app.get("source", "splunkbase") != "splunkbase":
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): app_id {CONTENT_LIBRARY_APP_ID} (Content Library) "
f"requires source 'splunkbase'. A local library is named {CONTENT_LIBRARY_FOLDER!r} and uses a path to its archive."
)
if not is_itsi_content_pack:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): app_id {CONTENT_LIBRARY_APP_ID} (Content Library) "
"requires itsi_content_pack: true"
)
if app.get("name") == CONTENT_LIBRARY_FOLDER and not (is_itsi_content_pack and _is_content_library_entry(app)):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): {CONTENT_LIBRARY_FOLDER!r} is reserved for "
f"Content Library app_id {CONTENT_LIBRARY_APP_ID} (splunkbase) or a local library archive"
)
if is_itsi_content_pack and _is_content_library_entry(app):
_validate_content_library_entry(app, i, name)
if not app.get("content_pack_apps") and app.get("install_all_apps") is not True:
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): the Content Library requires content_pack_apps "
"(a non-empty list) or install_all_apps: true"
)
if not is_premium and not is_itsi_content_pack and (target_roles is None or not isinstance(target_roles, list) or len(target_roles) == 0):
raise ValueError(
f"splunk_app_deployment.apps[{i}] (name={name!r}): 'target_roles' is required for normal apps (must be a non-empty list)"
Expand Down
2 changes: 1 addition & 1 deletion lib/spa/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ def build_entry(
elif kind == "itsi_content_library":
entry["itsi_content_pack"] = True
if install_all_content_packs:
entry["content_pack_api"] = {"install_all_apps": True}
entry["install_all_apps"] = True
else:
entry["content_pack_apps"] = [
{"name": str(pack)} for pack in (content_packs or [])
Expand Down
2 changes: 1 addition & 1 deletion lib/spa/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,14 @@ def walk(model: Any, prefix: str) -> None:
"sc_blacklist": "list[string]",
"premium_app": "string",
"itsi_content_pack": "boolean",
"install_all_apps": "boolean",
"content_pack_apps": "list[map]",
"content_pack_api": "map",
"customizations": "map",
}
_APP_ENTRY_NESTED_FIELD_TYPES = {
"content_pack_api.enabled": "boolean",
"content_pack_api.install_all": "boolean",
"content_pack_api.install_all_apps": "boolean",
"customizations.remove": "list[string]",
"customizations.local_configs": "map",
"customizations.run_playbook": "string",
Expand Down
4 changes: 2 additions & 2 deletions lib/spa/config_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,8 +1595,8 @@ def mutate(document: MutableMapping[str, Any], warnings: List[str]) -> None:
apps.append(copy.deepcopy(entry))
return
if entry.get("app_id") == 5391:
current_all = bool((match.get("content_pack_api") or {}).get("install_all_apps"))
incoming_all = bool((entry.get("content_pack_api") or {}).get("install_all_apps"))
current_all = match.get("install_all_apps") is True
incoming_all = entry.get("install_all_apps") is True
current_named = bool(match.get("content_pack_apps"))
incoming_named = bool(entry.get("content_pack_apps"))
if (current_all and incoming_named) or (current_named and incoming_all):
Expand Down
14 changes: 13 additions & 1 deletion tests/test_config_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,18 @@ def test_content_pack_adds_itsi_dependency_and_remove_pack_keeps_itsi(self, tmp_
assert any(app.get("app_id") == 1841 for app in apps)
assert any(app.get("app_id") == 5391 for app in apps)

def test_content_library_install_all_writes_top_level_install_all_apps(self):
"""The install-all policy is top-level install_all_apps, the key the apps_itsi_content_pack role reads."""
library = {
"uid": 5391,
"appid": "DA-ITSI-ContentLibrary",
"title": "Content Pack for Splunk App for Content Packs",
}
built = build_entry(5391, install_all_content_packs=True, client=self._client(library))
assert built["entry"]["install_all_apps"] is True
assert "content_pack_api" not in built["entry"]
assert "content_pack_apps" not in built["entry"]

def test_content_pack_kind_and_policy_combinations_are_enforced(self):
"""CFG-APP-005 CFG-APP-006."""
library = {
Expand Down Expand Up @@ -2088,7 +2100,7 @@ def test_existing_content_library_policy_cannot_be_switched(self, tmp_path):
"name": "DA-ITSI-ContentLibrary",
"app_id": 5391,
"itsi_content_pack": True,
"content_pack_api": {"install_all_apps": True},
"install_all_apps": True,
},
validate=False,
)
Expand Down
Loading
Loading