SOCFWPackManager

SOC Framework bootloader. Lists the SOC Framework pack catalog, installs and configures packs from xsoar_config.json, and re-runs configuration only. Also includes sync-tags, a backward-compat action for older SOC Framework deployments still using the value_tags lookup; modern versions use the SOCActionTimeMap_V3 list and do not require it.

python · SOC Framework Pack Manager

Source

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401,F403

import json
import time
from typing import Any
from urllib.parse import urlparse

import requests

# ============================================================
# SOCFWPackManager (bootloader)
# - list: shows SOC Framework pack catalog (paging/filtering)
# - apply: resolves pack_id via secops-framework pack_catalog.json
# - marketplace install: uses XSIAMContentPackInstaller (when available)
# - custom ZIP install: socfw-install-pack (SOCFWPackManager integration instance)
#   credentials stored masked in integration params
# - configure: integrations/jobs/lookups from xsoar_config.json (via core-api-*)
#
# FIXES INCLUDED:
# - Lookups: reliably CREATE dataset if missing (direct /public_api/v1/xql/add_dataset),
#           then POPULATE if empty (even when overwrite_lookup=false).
# - Lookups: add_data uses same request shape as LookupDatasetCreator.
# - Avoid /xsoar routes that 303 redirect in some tenants (prefer /public_api).
# - http_get_json supports JSON-stream / concatenated JSON objects
#           (prevents "Extra data: line 2 column 1 ...")
# - Dependencies: install custom pack dependencies from xsoar_config.json "custom_packs"
# - Polling: timeout fallback checks for REAL pack id (not zip filename)
# - Stream normalize: handles stream-returned list containing list-of-dicts
#
# FIX NOW (minimal change):
# - Jobs: DO NOT configure/create/update if job already exists (prevents duplicates on rerun)
# ============================================================

SCRIPT_NAME = "SOCFWPackManager"

# ---------------------------
# Basic helpers
# ---------------------------


def _norm(s: Any) -> str:
    return (str(s) if s is not None else "").strip()


def _to_lower(s: Any) -> str:
    return _norm(s).lower()


def _safe_sort_key(row: dict[str, Any], key: str) -> str:
    return _norm(row.get(key, "")).lower()


def _guess_pack_id_from_label(label: str) -> str:
    """
    Convert:
      - soc-common-playbooks-unified.zip               -> soc-common-playbooks-unified
      - soc-common-playbooks-unified-v2.7.53.zip       -> soc-common-playbooks-unified
      - soc-common-playbooks-unified-v2.7.53           -> soc-common-playbooks-unified
      - soc-common-playbooks-unified                   -> soc-common-playbooks-unified
    Used ONLY for polling detection (timeout fallback).
    """
    s = _norm(label)
    if not s:
        return s
    if s.endswith(".zip"):
        s = s[:-4]
    # Strip release suffix "-vX.Y.Z" if present
    if "-v" in s:
        s = s.split("-v")[0]
    return s.strip()


def _extract_custom_packs_from_xsoar_cfg(xsoar_cfg: dict[str, Any]) -> list[dict[str, str]]:
    """
    Returns a normalized list of custom packs to install from xsoar_config.json.
    Expected input key: custom_packs
    Each item: { "id": "...", "url": "...", "system": "yes" }
    Output: [{ "name": "<id>", "url": "<url>" }, ...]
    """
    packs = xsoar_cfg.get("custom_packs") or []
    out: list[dict[str, str]] = []

    if isinstance(packs, list):
        for p in packs:
            if not isinstance(p, dict):
                continue
            url = _norm(p.get("url"))
            pid = _norm(p.get("id") or p.get("name") or url)
            system = _norm(p.get("system") or "true")
            if url:
                out.append({"name": pid or url, "url": url, "system": system})
    return out


# ---------------------------
# Demisto helpers
# ---------------------------


def socfw_get_error(res):
    # Renamed from get_error to avoid shadowing CommonServerPython.get_error
    try:
        return res[0].get("Contents") or res[0].get("HumanReadable") or str(res[0])
    except Exception:
        return str(res)


def socfw_is_error(res0):
    # Renamed from is_error to avoid shadowing CommonServerPython.is_error
    try:
        return bool(res0.get("Type") == 4)  # entryTypes["error"] == 4
    except Exception:
        return False


def get_contents(res):
    if not res or not isinstance(res, list) or not res[0]:
        return {}
    return res[0].get("Contents") or {}


def arg_to_bool(val, default=False) -> bool:
    if val is None:
        return default
    if isinstance(val, bool):
        return val
    s = str(val).strip().lower()
    if s == "":
        return default
    return s in ("true", "1", "yes", "y", "on")


def to_int(val, default: int) -> int:
    try:
        return int(val)
    except Exception:
        return default


def bool_str_tf(val: bool) -> str:
    return "true" if bool(val) else "false"


def is_timeout_error(err_text: str) -> bool:
    if not err_text:
        return False
    t = err_text.lower()
    return (
        "timeout" in t
        or "timed out" in t
        or "read timed out" in t
        or "request timed out" in t
        or "context deadline exceeded" in t
        or "client.timeout exceeded" in t
        or "awaiting headers" in t
        or "context deadline exceeded (client.timeout exceeded while awaiting headers)" in t
    )


def emit_progress(message: str, stage: str | None = None):
    title = f"{SCRIPT_NAME}{stage}" if stage else SCRIPT_NAME
    return_results(
        {
            "Type": 1,
            "ContentsFormat": "markdown",
            "Contents": message,
            "HumanReadable": f"### {title}\n{message}",
        }
    )


def log(message: str, stage: str | None, debug: bool, always: bool = False):
    if always or debug:
        emit_progress(message, stage=stage)


def exec_cmd(command: str, args: dict[str, Any], fail_on_error: bool = True):
    res = demisto.executeCommand(command, args)
    if not res:
        if fail_on_error:
            raise Exception(f"{command} returned empty response")
        return res
    if socfw_is_error(res[0]):
        if fail_on_error:
            raise Exception(socfw_get_error(res))
        return res
    return res


def exec_with_retry(
    command: str,
    args: dict[str, Any],
    retry_count: int,
    retry_sleep_seconds: int,
    context_for_error: str,
    fail_on_error: bool = True,
):
    last_err = None
    for attempt in range(1, max(1, retry_count) + 1):
        try:
            return exec_cmd(command, args, fail_on_error=fail_on_error)
        except Exception as e:
            last_err = str(e)
            if attempt >= retry_count:
                break
            # Backoff via platform Sleep script — no time.sleep in pack code.
            demisto.executeCommand("Sleep", {"seconds": str(max(1, retry_sleep_seconds))})
            continue
    if fail_on_error:
        raise Exception(f"{context_for_error}\nError: {last_err}")
    return None


def is_instance_already_exists_error(err_text: str) -> bool:
    if not err_text:
        return False
    return "already exists (33)" in err_text.lower()


# ---------------------------
# Pre/Post docs helpers (LOUD + optional content)
# ---------------------------


def _md_link(name: str, url: str) -> str:
    n = (name or "").strip() or url
    u = (url or "").strip()
    if not u:
        return f"- {n}"
    return f"- [{n}]({u})"


def _github_blob_to_raw(url: str) -> str:
    """
    Convert a github.com /blob/ URL to its raw.githubusercontent.com equivalent.
    Pass-through for already-raw URLs and anything we don't recognize.

    Hostname comparison is exact (not substring) to prevent URLs like
    https://attacker.com/?x=raw.githubusercontent.com from masquerading as
    trusted GitHub URLs.
    """
    u = (url or "").strip()
    if not u:
        return u
    try:
        parsed = urlparse(u)
    except ValueError:
        return u
    if parsed.scheme not in ("http", "https"):
        return u
    host = (parsed.hostname or "").lower()
    # Already a raw GitHub URL — pass through unchanged
    if host == "raw.githubusercontent.com":
        return u
    # Convert https://github.com/<org>/<repo>/blob/<branch>/<path...> to raw URL
    if host == "github.com":
        parts = (parsed.path or "").lstrip("/").split("/")
        if len(parts) >= 5 and parts[2] == "blob":
            org = parts[0]
            repo = parts[1]
            branch = parts[3]
            path = "/".join(parts[4:])
            return f"https://raw.githubusercontent.com/{org}/{repo}/{branch}/{path}"
    return u


def _fetch_text(url: str, timeout: int = 20) -> str:
    r = requests.get(url, timeout=timeout)
    r.raise_for_status()
    return r.text or ""


def _truncate_text(s: str, max_chars: int, max_lines: int) -> str:
    if not s:
        return ""
    lines = s.splitlines()
    if max_lines and len(lines) > max_lines:
        lines = lines[:max_lines]
        s = "\n".join(lines) + "\n\n... (truncated by max_lines) ..."
    if max_chars and len(s) > max_chars:
        s = s[:max_chars] + "\n\n... (truncated by max_chars) ..."
    return s


def has_config_docs(xsoar_cfg: dict[str, Any], when: str) -> bool:
    key = "pre_config_docs" if when == "pre" else "post_config_docs"
    docs = xsoar_cfg.get(key) or []
    if not isinstance(docs, list):
        return False
    for d in docs:
        if isinstance(d, dict) and _norm(d.get("url") or d.get("name")):
            return True
        if isinstance(d, str) and _norm(d):
            return True
    return False


def print_config_docs(
    xsoar_cfg: dict[str, Any],
    when: str,
    debug: bool,
    include_doc_content: bool = False,
    doc_content_max_chars: int = 6000,
    doc_content_max_lines: int = 200,
):
    key = "pre_config_docs" if when == "pre" else "post_config_docs"
    docs = xsoar_cfg.get(key) or []
    if not isinstance(docs, list) or not docs:
        log(f"No {key} found in xsoar_config.json.", stage=f"docs.{when}", debug=debug)
        return

    banner_title = (
        " 🚧 PRE-INSTALL / PRE-CONFIG REQUIRED STEPS" if when == "pre" else "✅ POST-INSTALL / POST-CONFIG MANUAL STEPS"
    )
    banner_sub = (
        "_These docs usually contain prerequisites / manual steps you must complete BEFORE install._"
        if when == "pre"
        else "_These docs usually contain manual follow-ups and validation steps AFTER completion._"
    )

    banner = "\n".join(["---", f"## {banner_title}", banner_sub, "---"])

    link_lines: list[str] = []
    normalized_docs: list[dict[str, str]] = []
    for d in docs:
        if isinstance(d, dict):
            name = _norm(d.get("name") or "")
            url = _norm(d.get("url") or "")
            if url or name:
                link_lines.append(_md_link(name, url))
                normalized_docs.append({"name": name or url, "url": url})
        elif isinstance(d, str):
            s = _norm(d)
            if s:
                link_lines.append(f"- {s}")
                normalized_docs.append({"name": s, "url": s})

    if not link_lines:
        log(f"No valid entries in {key}.", stage=f"docs.{when}", debug=debug)
        return

    want_content = bool(include_doc_content or debug)
    body: list[str] = [banner, "### Links", *link_lines]

    if want_content and normalized_docs:
        body += ["", "### Doc contents (preview)", " _Showing a truncated preview._", ""]

        for d in normalized_docs:
            name = d.get("name") or ""
            url = d.get("url") or ""
            raw_url = _github_blob_to_raw(url)
            try:
                text = _fetch_text(raw_url, timeout=20)
                text = _truncate_text(text, max_chars=doc_content_max_chars, max_lines=doc_content_max_lines)

                body.append(
                    "\n".join(
                        [
                            "<details>",
                            f"<summary><b>{name}</b> (click to expand)</summary>",
                            "",
                            "```markdown",
                            text,
                            "```",
                            "",
                            f"_Source: {raw_url}_",
                            "</details>",
                            "",
                        ]
                    )
                )
            except Exception as e:
                body.append(f"- **{name}**: could not fetch preview ({e})")

    emit_progress("\n".join(body), stage=f"docs.{when}")


# ---------------------------
# Core API wrappers
# ---------------------------


def core_api_get(path: str, using: str = "", execution_timeout: int = 600) -> dict[str, Any]:
    args = {"uri": path, "execution-timeout": str(execution_timeout)}
    if using:
        args["using"] = using
    res = exec_cmd("core-api-get", args)
    return get_contents(res) or {}


def core_api_post(path: str, body: Any, using: str = "", execution_timeout: int = 600) -> dict[str, Any]:
    args = {"uri": path, "body": json.dumps(body if body is not None else {}), "execution-timeout": str(execution_timeout)}
    if using:
        args["using"] = using
    res = exec_cmd("core-api-post", args)
    return get_contents(res) or {}


def core_api_put(path: str, body: Any, using: str = "", execution_timeout: int = 600) -> dict[str, Any]:
    args = {"uri": path, "body": json.dumps(body if body is not None else {}), "execution-timeout": str(execution_timeout)}
    if using:
        args["using"] = using
    res = exec_cmd("core-api-put", args)
    return get_contents(res) or {}


# ---------------------------
# HTTP JSON helpers
# ---------------------------


def _parse_json_stream(raw: str) -> Any:
    """
    Parse a string that may contain:
      - one JSON document (object/array)
      - NDJSON/JSONL (one JSON value per line)
      - concatenated JSON values (e.g. {}{} or {}\n{} with extra whitespace)
    Returns:
      - single parsed value if exactly one JSON value
      - list of parsed values if multiple JSON values are present
    """
    raw = (raw or "").strip()
    if not raw:
        return []

    dec = json.JSONDecoder()
    idx = 0
    n = len(raw)
    values: list[Any] = []

    while idx < n:
        while idx < n and raw[idx].isspace():
            idx += 1
        if idx >= n:
            break
        val, end = dec.raw_decode(raw, idx)
        values.append(val)
        idx = end

    if len(values) == 1:
        return values[0]
    return values


def http_get_text(url: str, timeout: int = 30) -> str:
    r = requests.get(url, timeout=timeout)
    r.raise_for_status()
    return r.text or ""


def http_get_json(url: str, timeout: int = 30) -> Any:
    """
    Supports:
      - standard JSON (object/array)
      - NDJSON / JSONL
      - concatenated JSON stream (prevents 'Extra data' errors)
    """
    raw = http_get_text(url, timeout=timeout).strip()
    if not raw:
        return []
    try:
        return json.loads(raw)
    except Exception:
        return _parse_json_stream(raw)


# ---------------------------
# Catalog + Manifest resolver
# ---------------------------

DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Palo-Cortex/secops-framework/refs/heads/main/pack_catalog.json"


def fetch_pack_catalog(catalog_url: str = DEFAULT_CATALOG_URL) -> dict[str, Any]:
    data = http_get_json(catalog_url)
    if not isinstance(data, dict):
        raise Exception(f"pack_catalog.json unexpected format at {catalog_url}")
    return data


def find_pack_in_catalog(catalog: dict[str, Any], pack_id: str) -> dict[str, Any] | None:
    packs = catalog.get("packs") or catalog.get("Packs") or catalog.get("items") or []
    if not isinstance(packs, list):
        return None
    for p in packs:
        if isinstance(p, dict) and (p.get("id") == pack_id):
            return p
    return None


def resolve_manifest(pack_id: str, include_hidden: bool, catalog_url: str) -> dict[str, Any]:
    if pack_id.startswith(("http://", "https://")):
        return http_get_json(pack_id)

    catalog = fetch_pack_catalog(catalog_url)
    pack = find_pack_in_catalog(catalog, pack_id)
    if not pack:
        raise Exception(f"Pack '{pack_id}' not found in pack_catalog.json")

    visible = bool(pack.get("visible", True))
    if (not include_hidden) and (not visible):
        raise Exception(
            f"Pack '{pack_id}' is marked visible=false in the catalog. " f"Re-run with include_hidden=true to install it anyway."
        )

    version = (pack.get("version") or "").strip()
    if not version:
        raise Exception(f"Pack '{pack_id}' missing version in pack_catalog.json")

    xsoar_config_url = (
        f"https://raw.githubusercontent.com/Palo-Cortex/secops-framework/refs/heads/main/Packs/{pack_id}/xsoar_config.json"
    )
    release_tag = f"{pack_id}-v{version}"
    zip_url = f"https://github.com/Palo-Cortex/secops-framework/releases/download/{release_tag}/{release_tag}.zip"

    marketplace_packs = [
        {"id": "Base", "version": "latest"},
        {"id": "CommonScripts", "version": "latest"},
        {"id": "CommonPlaybooks", "version": "latest"},
        {"id": "DemistoRESTAPI", "version": "latest"},
        {"id": "Whois", "version": "latest"},
    ]

    return {
        "marketplace_packs": marketplace_packs,
        "custom_zip_urls": [{"url": zip_url, "name": release_tag}],
        "xsoar_config_url": xsoar_config_url,
        "pack_catalog_entry": pack,
        "pack_version": version,
    }


# ---------------------------
# list action (filter + paging)
# ---------------------------


def do_list(args: dict[str, Any]):
    using = _norm(args.get("using") or "")
    include_hidden = arg_to_bool(args.get("include_hidden"), False)

    text_filter = _to_lower(args.get("filter") or args.get("q") or "")
    visible_only_raw = arg_to_bool(args.get("visible_only"), True)
    visible_only = bool(visible_only_raw) and (not include_hidden)

    limit = max(1, to_int(args.get("limit"), 50))
    offset = max(0, to_int(args.get("offset"), 0))
    sort_by = (_norm(args.get("sort_by")) or "id").strip()
    sort_dir = (_norm(args.get("sort_dir")) or "asc").strip().lower()
    fields = argToList(args.get("fields")) or ["id", "display_name", "version", "visible", "path"]
    show_total = arg_to_bool(args.get("show_total"), True)

    catalog_url = _norm(args.get("catalog_url") or DEFAULT_CATALOG_URL)

    emit_progress("Fetching catalog…", stage="list")

    catalog = fetch_pack_catalog(catalog_url)
    packs = catalog.get("packs") or catalog.get("Packs") or catalog.get("items") or []
    if not isinstance(packs, list):
        raise Exception("pack_catalog.json is missing 'packs' list")

    rows: list[dict[str, Any]] = []
    for p in packs:
        if not isinstance(p, dict):
            continue

        visible = bool(p.get("visible", True))

        if (not include_hidden) and (not visible):
            continue
        if visible_only and (not visible):
            continue

        row = {
            "id": p.get("id", ""),
            "display_name": p.get("display_name") or p.get("name") or "",
            "version": p.get("version", ""),
            "visible": str(visible).lower(),
            "path": p.get("path") or f"Packs/{p.get('id','')}",
        }

        if text_filter:
            hay = " ".join([_to_lower(row.get("id")), _to_lower(row.get("display_name")), _to_lower(row.get("path"))])
            if text_filter not in hay:
                continue

        rows.append(row)

    total = len(rows)

    allowed_sort = {"id", "display_name", "version", "visible", "path"}
    if sort_by not in allowed_sort:
        sort_by = "id"
    reverse = sort_dir == "desc"
    rows.sort(key=lambda r: _safe_sort_key(r, sort_by), reverse=reverse)

    page = rows[offset : offset + limit]
    start = offset + 1 if page else 0
    end = offset + len(page)

    allowed_fields = ["id", "display_name", "version", "visible", "path"]
    fields = [f for f in fields if f in allowed_fields] or ["id", "display_name", "version", "visible", "path"]

    header_line = "| " + " | ".join(fields) + " |\n"
    sep_line = "| " + " | ".join(["---"] * len(fields)) + " |\n"
    table = header_line + sep_line
    for r in page:
        table += "| " + " | ".join([_norm(r.get(f, "")) for f in fields]) + " |\n"

    summary_lines = [
        f"using: {(using or '(default)')}",
        f"catalog_url: {catalog_url}",
        f"include_hidden: {include_hidden}",
        f"visible_only: {visible_only}",
    ]
    if text_filter:
        summary_lines.append(f"filter: `{text_filter}`")
    summary_lines.append(f"sort: {sort_by} {sort_dir}")
    summary_lines.append(f"page: limit={limit}, offset={offset}")
    if show_total:
        summary_lines.append(f"showing: {start}-{end} of {total}")

    emit_progress("\n".join(summary_lines) + "\n\n" + table, stage="list")


# ---------------------------
# Marketplace install
# ---------------------------


def install_marketplace_packs(
    marketplace_packs: list[dict[str, str]],
    using: str,
    retry_count: int,
    retry_sleep_seconds: int,
    debug: bool,
) -> dict[str, Any]:
    if debug:
        emit_progress(
            "Installing marketplace packs via **XSIAMContentPackInstaller**…\n"
            + "\n".join([f'{p.get("id")} @ {p.get("version")}' for p in marketplace_packs]),
            stage="packs.marketplace",
        )
    else:
        emit_progress(
            f"Installing marketplace packs via **XSIAMContentPackInstaller**… ({len(marketplace_packs)} pack(s))",
            stage="packs.marketplace",
        )

    args = {
        "packs_data": marketplace_packs,
        "pack_id_key": "id",
        "pack_version_key": "version",
        "install_dependencies": "true",
    }
    if using:
        args["using"] = using

    res = exec_with_retry(
        "XSIAMContentPackInstaller",
        args,
        retry_count=retry_count,
        retry_sleep_seconds=retry_sleep_seconds,
        context_for_error="Failed installing marketplace packs via XSIAMContentPackInstaller",
        fail_on_error=True,
    )
    return get_contents(res) if res else {}


def fetch_installed_marketplace_pack_ids(using: str) -> list[str]:
    """
    Note: This endpoint returns installed content pack IDs.
    We'll also use it for custom zip installs polling (best-effort).
    """
    try:
        r = core_api_get("/public/v1/contentpacks/metadata/installed", using=using)
        packs = (r.get("response") or []) if isinstance(r, dict) else []
        ids = []
        for p in packs:
            pid = p.get("id")
            if pid:
                ids.append(pid)
        return ids
    except Exception:
        return []


# ---------------------------
# xsoar_config
# ---------------------------


def fetch_xsoar_config(xsoar_config_url: str) -> dict[str, Any]:
    data = http_get_json(xsoar_config_url)
    if not isinstance(data, dict):
        raise Exception(f"xsoar_config.json unexpected format at {xsoar_config_url}")
    return data


# ---------------------------
# Custom packs install (with timeout -> polling fallback)
# ---------------------------


def wait_for_pack_installed(
    pack_id: str,
    using: str,
    poll_seconds: int,
    poll_interval_seconds: int,
    debug: bool,
) -> bool:
    deadline = time.time() + max(0, poll_seconds)
    interval = max(5, poll_interval_seconds)

    log(
        f"Polling for pack install completion: **{pack_id}** (up to {poll_seconds}s, every {interval}s)…",
        stage="packs.custom.poll",
        debug=debug,
        always=True,
    )

    while True:
        try:
            installed = fetch_installed_marketplace_pack_ids(using)
            if pack_id in installed:
                log(f"Pack **{pack_id}** is now installed.", stage="packs.custom.poll", debug=debug, always=True)
                return True
        except Exception as e:
            log(f" Poll check error (will retry): {e}", stage="packs.custom.poll.debug", debug=debug)

        if time.time() >= deadline:
            log(
                f"Polling window expired; pack **{pack_id}** not detected as installed yet.",
                stage="packs.custom.poll",
                debug=debug,
                always=True,
            )
            return False

        # Backoff via platform Sleep script — no time.sleep in pack code.
        demisto.executeCommand("Sleep", {"seconds": str(interval)})


def install_custom_pack_zip(
    url: str,
    asset_filename: str,
    using: str,
    retry_count: int,
    retry_sleep_seconds: int,
    debug: bool,
):
    """
    Install a custom pack ZIP via the SOCFWPackManager integration instance.

    The integration holds XSIAM credentials (masked) and uploads the pack as
    system content via demisto-sdk upload_content_entity.

    Integration instance name defaults to "SOCFWPackManager".
    The using arg overrides this if multiple instances exist.
    """
    if debug:
        emit_progress(
            "\n".join(
                [
                    "install_custom_pack_zip (socfw-install-pack):",
                    f"- url:      {url}",
                    f"- filename: {asset_filename}",
                ]
            ),
            stage="packs.custom.debug",
        )

    # Do not pass 'using' — let XSIAM route to whichever instance supports
    # socfw-install-pack. Hardcoding the instance name breaks when XSIAM
    # appends "_instance_1" or similar to the default instance name.
    args = {
        "url": url,
        "filename": asset_filename,
    }

    exec_with_retry(
        "socfw-install-pack",
        args,
        retry_count=retry_count,
        retry_sleep_seconds=retry_sleep_seconds,
        context_for_error=f"Failed installing {asset_filename}",
        fail_on_error=True,
    )

    emit_progress(
        f"Pack **{asset_filename}** installed.",
        stage="packs.custom.result",
    )


def configure_integrations_from_xsoar_config(
    xsoar_cfg: dict[str, Any],
    using: str,
    retry_count: int,
    retry_sleep_seconds: int,
    installed_pack_ids: list[str],
    debug: bool,
) -> dict[str, Any]:
    items = [x for x in (xsoar_cfg.get("integration_instances", []) or []) if isinstance(x, dict)]
    emit_progress(f"Configuring integration instances… ({len(items)} instance(s))", stage="configure.integrations")

    summary = {
        "attempted": 0,
        "ok": 0,
        "already_exists": 0,
        "skipped_missing_pack": 0,
        "skipped_missing_brand": 0,
        "failed": 0,
        "failed_items": [],
    }

    for inst in items:
        instance_name = (inst.get("name") or "").strip()
        if not instance_name:
            continue

        required_pack = (inst.get("required_pack_id") or inst.get("marketplace_pack") or inst.get("pack_id") or "").strip()
        if required_pack and required_pack not in installed_pack_ids:
            summary["skipped_missing_pack"] += 1  # type: ignore[operator]
            log(
                f"Skipping integration instance **{instance_name}** — marketplace pack **{required_pack}** not installed.",
                stage="configure.integrations.debug",
                debug=debug,
            )
            continue

        brand = (inst.get("brand") or "").strip()
        if not brand:
            summary["skipped_missing_brand"] += 1  # type: ignore[operator]
            log(
                f"Skipping integration instance **{instance_name}** — missing required field `brand`.",
                stage="configure.integrations.debug",
                debug=debug,
            )
            continue

        summary["attempted"] += 1  # type: ignore[operator]

        payload = {
            "name": instance_name,
            "brand": brand,
            "enabled": inst.get("enabled", "true"),
            "category": inst.get("category") or "",
            "data": inst.get("data") or [],
        }

        log(
            f"Creating/updating integration instance: **{instance_name}** (brand: **{brand}**)",
            stage="configure.integrations.debug",
            debug=debug,
        )

        def _do_put(_payload=payload):
            return core_api_put(
                "/xsoar/public/v1/settings/integration",
                _payload,
                using=using,
                execution_timeout=600,
            )

        last_err = None
        for attempt in range(1, max(1, retry_count) + 1):
            try:
                resp = _do_put()
                rid = (resp.get("id") if isinstance(resp, dict) else None) or ""
                summary["ok"] += 1  # type: ignore[operator]
                log(
                    f"Integration instance **{instance_name}** created/updated. id={rid or '(unknown)'}",
                    stage="configure.integrations.result",
                    debug=debug,
                )
                break
            except Exception as e:
                last_err = str(e)

                if is_instance_already_exists_error(last_err):
                    summary["already_exists"] += 1  # type: ignore[operator]
                    log(
                        f"Integration instance **{instance_name}** already exists — skipping (idempotent).",
                        stage="configure.integrations.result",
                        debug=debug,
                    )
                    break

                if attempt >= retry_count:
                    summary["failed"] += 1  # type: ignore[operator]
                    summary["failed_items"].append({"name": instance_name, "error": last_err})  # type: ignore[attr-defined]
                    emit_progress(
                        f"Failed configuring integration instance **{instance_name}**.\nError: {last_err}",
                        stage="configure.integrations.error",
                    )
                    break

                # Backoff via platform Sleep script — no time.sleep in pack code.
                demisto.executeCommand("Sleep", {"seconds": str(max(1, retry_sleep_seconds))})

    emit_progress(
        "\n".join(
            [
                "Integration instances summary:",
                f"- attempted: {summary['attempted']}",
                f"- ok: {summary['ok']}",
                f"- already exists: {summary['already_exists']}",
                f"- skipped (missing pack): {summary['skipped_missing_pack']}",
                f"- skipped (missing brand): {summary['skipped_missing_brand']}",
                f"- failed: {summary['failed']}",
                "",
                "_Note: UI/index propagation can take a few minutes after instance create/update._",
            ]
        ),
        stage="configure.integrations.summary",
    )

    return summary


# ---------------------------
# Lookup dataset population (FIXED + aligned to LookupDatasetCreator)
# ---------------------------


def _is_dataset_not_found_error(err_text: str, dataset_name: str) -> bool:
    t = (err_text or "").lower()
    dn = (dataset_name or "").lower()
    return (
        ("dataset" in t and "not found" in t and dn in t)
        or (f"dataset {dn} not found" in t)
        or (f"dataset '{dn}' not found" in t)
        or (f'dataset "{dn}" not found' in t)
    )


def _xql_get_datasets(using: str, debug: bool) -> list[dict[str, Any]]:
    try:
        resp = core_api_post("/public_api/v1/xql/get_datasets", body={}, using=using, execution_timeout=600) or {}
        if isinstance(resp, dict):
            if isinstance(resp.get("reply"), list):
                return [x for x in resp.get("reply") if isinstance(x, dict)]  # type: ignore[union-attr]
            r = resp.get("response") or {}
            if isinstance(r, dict) and isinstance(r.get("reply"), list):
                return [x for x in r.get("reply") if isinstance(x, dict)]  # type: ignore[union-attr]
        return []
    except Exception as e:
        if debug:
            emit_progress(f"get_datasets failed: {e}", stage="configure.lookups.debug")
        return []


def _dataset_exists(dataset_name: str, using: str, debug: bool) -> bool:
    want = _norm(dataset_name).lower()
    for d in _xql_get_datasets(using=using, debug=debug):
        n = _norm(d.get("Dataset Name") or d.get("dataset_name") or d.get("name"))
        if n and n.lower() == want:
            return True
    return False


def _wait_for_dataset(dataset_name: str, using: str, debug: bool, wait_seconds: int = 90, interval_seconds: int = 3) -> bool:
    deadline = time.time() + max(1, wait_seconds)
    while time.time() < deadline:
        if _dataset_exists(dataset_name, using=using, debug=debug):
            return True
        # Backoff via platform Sleep script — no time.sleep in pack code.
        demisto.executeCommand("Sleep", {"seconds": str(max(1, interval_seconds))})
    return False


def _xql_call_first_working(paths: list[str], body: dict[str, Any], using: str, debug: bool) -> dict[str, Any]:
    last_err = None
    for p in paths:
        try:
            return core_api_post(p, body=body, using=using, execution_timeout=600) or {}
        except Exception as e:
            last_err = str(e)
            if debug:
                emit_progress(f"Lookup API probe failed on {p}: {e}", stage="configure.lookups.debug")
    raise Exception(f"Lookup API call failed on all known endpoints. Last error: {last_err}")


def _xql_lookup_get_total_count(dataset_name: str, using: str, debug: bool) -> int | None:
    body = {"request_data": {"dataset_name": dataset_name, "filters": [], "limit": 1}}
    try:
        resp = _xql_call_first_working(
            paths=["/public_api/v1/xql/lookups/get_data"],
            body=body,
            using=using,
            debug=debug,
        )
    except Exception as e:
        if _is_dataset_not_found_error(str(e), dataset_name):
            return None
        raise

    reply = resp.get("reply") if isinstance(resp, dict) else None
    if isinstance(reply, dict):
        tc = reply.get("total_count")
        try:
            return int(tc)  # type: ignore[arg-type]
        except Exception:
            return 0

    tc = resp.get("total_count") if isinstance(resp, dict) else None
    try:
        return int(tc)  # type: ignore[arg-type]
    except Exception:
        return 0


def _normalize_lookup_rows(source_obj: Any) -> list[dict[str, Any]]:
    """
    Accept:
      - [ {row}, {row} ]
      - { "data": [ {row}, ... ] }
      - { "<anything>": [ {row}, ... ] } (first list-of-dict value wins)
      - stream list containing a list-of-dicts somewhere: [ {...meta...}, [ {row}, ... ] ]
    """
    if isinstance(source_obj, list):
        if source_obj and all(isinstance(x, dict) for x in source_obj):
            return [r for r in source_obj if isinstance(r, dict)]
        for v in source_obj:
            if isinstance(v, list) and v and all(isinstance(x, dict) for x in v):
                return v
        return [r for r in source_obj if isinstance(r, dict)]

    if isinstance(source_obj, dict):
        if isinstance(source_obj.get("data"), list):
            return [r for r in source_obj.get("data") if isinstance(r, dict)]  # type: ignore[union-attr]
        for _k, v in source_obj.items():
            if isinstance(v, list) and v and all(isinstance(x, dict) for x in v):
                return v

    return []


def _remove_omitted_fields(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    omitted = ["_collector_name", "_collector_type", "_insert_time", "_update_time"]
    for r in rows:
        for f in omitted:
            r.pop(f, None)
    return rows


def _xql_lookup_add_data_list(dataset_name: str, rows: list[dict[str, Any]], using: str, debug: bool):
    if not rows:
        raise Exception("No rows to upload")
    body = {"request_data": {"dataset_name": dataset_name, "data": rows}}
    _ = _xql_call_first_working(
        paths=["/public_api/v1/xql/lookups/add_data"],
        body=body,
        using=using,
        debug=debug,
    )


def _xql_create_dataset_direct(ds: dict[str, Any], using: str, debug: bool):
    dataset_name = _norm(ds.get("dataset_name") or ds.get("name"))
    if not dataset_name:
        raise Exception("Lookup dataset definition missing 'dataset_name'/'name'")

    dataset_type = ds.get("dataset_type") or "lookup"
    dataset_schema = ds.get("dataset_schema") or {}

    body = {
        "request_data": {
            "dataset_name": dataset_name,
            "dataset_type": dataset_type,
            "dataset_schema": dataset_schema,
        }
    }

    _ = core_api_post("/public_api/v1/xql/add_dataset", body=body, using=using, execution_timeout=600)


def configure_lookups_from_xsoar_config(
    xsoar_cfg: dict[str, Any],
    using: str,
    retry_count: int,
    retry_sleep_seconds: int,
    overwrite_lookup: bool,
    debug: bool,
) -> dict[str, Any]:
    dsets = [x for x in (xsoar_cfg.get("lookup_datasets", []) or []) if isinstance(x, dict)]
    emit_progress(f"Configuring lookup datasets… ({len(dsets)} dataset(s))", stage="configure.lookups")

    summary = {"attempted": 0, "ok": 0, "failed": 0, "failed_items": []}

    for ds in dsets:
        name = (ds.get("name") or ds.get("dataset_name") or "").strip()
        if not name:
            continue

        summary["attempted"] += 1  # type: ignore[operator]
        log(f"Configuring lookup dataset: **{name}**", stage="configure.lookups.debug", debug=debug)

        # Ensure dataset exists
        exists = _wait_for_dataset(name, using=using, debug=debug, wait_seconds=45, interval_seconds=3)
        if not exists:
            emit_progress(
                f"Dataset **{name}** not visible via get_datasets. Creating directly via /public_api/v1/xql/add_dataset…",
                stage="configure.lookups.create",
            )
            try:
                _xql_create_dataset_direct(ds, using=using, debug=debug)
            except Exception as e:
                summary["failed"] += 1  # type: ignore[operator]
                summary["failed_items"].append({"name": name, "error": f"Direct create failed: {e}"})  # type: ignore[attr-defined]
                emit_progress(f"Failed creating lookup dataset **{name}**.\nError: {e}", stage="configure.lookups.error")
                continue

            exists = _wait_for_dataset(name, using=using, debug=debug, wait_seconds=90, interval_seconds=3)

        if not exists:
            summary["failed"] += 1  # type: ignore[operator]
            summary["failed_items"].append(  # type: ignore[attr-defined]
                {
                    "name": name,
                    "error": f"Dataset '{name}' not found via get_datasets after direct create.",
                }
            )
            emit_progress(
                f"Failed configuring lookup dataset **{name}**.\n"
                f"Error: Dataset '{name}' not found via get_datasets after direct create.",
                stage="configure.lookups.error",
            )
            continue

        # Decide whether to populate
        try:
            before_count = _xql_lookup_get_total_count(name, using=using, debug=debug)
        except Exception as e:
            summary["failed"] += 1  # type: ignore[operator]
            summary["failed_items"].append({"name": name, "error": str(e)})  # type: ignore[attr-defined]
            emit_progress(f"Failed reading lookup count for **{name}**.\nError: {e}", stage="configure.lookups.error")
            continue

        if before_count is not None and before_count > 0 and (not overwrite_lookup):
            summary["ok"] += 1  # type: ignore[operator]
            emit_progress(
                f"Lookup **{name}** already has data (total_count={before_count}). "
                f"Not modifying it unless `overwrite_lookup=true`.",
                stage="configure.lookups.result",
            )
            continue

        should_populate = overwrite_lookup or (before_count is None) or (before_count == 0)
        if not should_populate:
            summary["ok"] += 1  # type: ignore[operator]
            emit_progress(f"Lookup **{name}** is present; no population needed.", stage="configure.lookups.result")
            continue

        # Populate
        try:
            url = _norm(ds.get("url"))
            if not url:
                raise Exception("Dataset needs population but `url` is missing in xsoar_config.json")

            emit_progress(
                "\n".join(
                    [
                        f"Populating lookup **{name}** from URL:",
                        f"- {url}",
                        f"- overwrite_lookup={overwrite_lookup}",
                        f"- total_count(before)={before_count if before_count is not None else '(not readable yet)'}",
                    ]
                ),
                stage="configure.lookups.load",
            )

            source_obj = http_get_json(url)
            rows = _normalize_lookup_rows(source_obj)
            rows = _remove_omitted_fields(rows)

            if not rows:
                raise Exception(f"Downloaded JSON but found 0 usable rows. url={url}")

            _xql_lookup_add_data_list(dataset_name=name, rows=rows, using=using, debug=debug)

            # Brief settle before count check — via platform Sleep script.
            demisto.executeCommand("Sleep", {"seconds": "2"})

            after_count = None
            try:
                after_count = _xql_lookup_get_total_count(name, using=using, debug=debug)
            except Exception:
                after_count = None

            emit_progress(
                f"Lookup **{name}** population complete."
                + (f" total_count(after)={after_count}" if after_count is not None else " (count not yet readable)"),
                stage="configure.lookups.result",
            )

            summary["ok"] += 1  # type: ignore[operator]

        except Exception as e:
            summary["failed"] += 1  # type: ignore[operator]
            summary["failed_items"].append({"name": name, "error": str(e)})  # type: ignore[attr-defined]
            emit_progress(f"Failed populating lookup dataset **{name}**.\nError: {e}", stage="configure.lookups.error")

    emit_progress(
        "\n".join(
            [
                "Lookups summary:",
                f"- attempted: {summary['attempted']}",
                f"- ok: {summary['ok']}",
                f"- failed: {summary['failed']}",
            ]
        ),
        stage="configure.lookups.summary",
    )
    return summary


# ---------------------------
# Jobs verification + upsert
# ---------------------------


def _extract_list(resp: Any) -> list[dict[str, Any]]:
    if isinstance(resp, dict):
        v = resp.get("response")
        if isinstance(v, dict):
            data = v.get("data")
            if isinstance(data, list):
                return [x for x in data if isinstance(x, dict)]
        if isinstance(v, list):
            return [x for x in v if isinstance(x, dict)]
        for k in ("data", "jobs", "result"):
            vv = resp.get(k)
            if isinstance(vv, list):
                return [x for x in vv if isinstance(x, dict)]
    if isinstance(resp, list):
        return [x for x in resp if isinstance(x, dict)]
    return []


def _job_name(job_obj: dict[str, Any]) -> str:
    return _norm(job_obj.get("name") or job_obj.get("jobName") or job_obj.get("job_name") or job_obj.get("displayName") or "")


def _job_id(job_obj: dict[str, Any]) -> str:
    return _norm(job_obj.get("id") or job_obj.get("_id") or job_obj.get("jobId") or "")


def jobs_api_endpoints() -> dict[str, str]:
    return {
        "search_xsoar": "/xsoar/public/v1/jobs/search",
        "search_public": "/public/v1/jobs/search",
        "create_xsoar": "/xsoar/public/v1/jobs",
        "create_public": "/public/v1/jobs",
        "update_xsoar": "/xsoar/public/v1/jobs",
        "update_public": "/public/v1/jobs",
    }


def jobs_api_search_probe(using: str) -> str | None:
    eps = jobs_api_endpoints()
    probe_body = {"page": 0, "size": 1, "query": "", "sort": [{"field": "id", "asc": True}]}
    for p in (eps["search_xsoar"], eps["search_public"]):
        try:
            _ = core_api_post(p, body=probe_body, using=using, execution_timeout=600)
            return p
        except Exception:
            continue
    return None


def jobs_api_find_by_name(name: str, using: str, search_path: str | None, debug: bool) -> dict[str, Any] | None:
    n = _norm(name).lower()

    if search_path:
        try:
            body = {"page": 0, "size": 50, "query": f'name:"{name}"', "sort": [{"field": "id", "asc": True}]}
            resp = core_api_post(search_path, body=body, using=using, execution_timeout=600)
            rows = _extract_list(resp)
            for r in rows:
                if _job_name(r).lower() == n:
                    return r
        except Exception as e:
            if debug:
                emit_progress(f"Job search failed on {search_path}: {e}", stage="configure.jobs.debug")

    if search_path:
        try:
            body = {"page": 0, "size": 200, "query": "", "sort": [{"field": "id", "asc": True}]}
            resp = core_api_post(search_path, body=body, using=using, execution_timeout=600)
            rows = _extract_list(resp)
            for r in rows:
                if _job_name(r).lower() == n:
                    return r
        except Exception:
            pass

    return None


def jobs_api_upsert(job: dict[str, Any], using: str, search_path: str, debug: bool) -> dict[str, Any]:
    eps = jobs_api_endpoints()
    name = _job_name(job)
    if not name:
        raise Exception("Job object missing name")

    existing = jobs_api_find_by_name(name, using=using, search_path=search_path, debug=debug)
    existing_id = _job_id(existing) if existing else ""

    create_paths = [eps["create_xsoar"], eps["create_public"]]
    update_paths = [eps["update_xsoar"], eps["update_public"]]

    if existing_id:
        last_err = None
        for base in update_paths:
            try:
                resp = core_api_put(f"{base}/{existing_id}", body=job, using=using, execution_timeout=600)
                return {"action": "updated", "endpoint": f"{base}/{existing_id}", "response": resp, "job_id": existing_id}
            except Exception as e:
                last_err = str(e)
                continue

        for base in create_paths:
            try:
                resp = core_api_post(base, body=job, using=using, execution_timeout=600)
                return {
                    "action": "created_via_post_fallback",
                    "endpoint": base,
                    "response": resp,
                    "job_id": existing_id,
                    "warning": last_err,
                }
            except Exception as e:
                last_err = str(e)
                continue

        raise Exception(f"Failed updating job '{name}'. Last error: {last_err}")

    last_err = None
    for base in create_paths:
        try:
            resp = core_api_post(base, body=job, using=using, execution_timeout=600)
            return {"action": "created", "endpoint": base, "response": resp}
        except Exception as e:
            last_err = str(e)
            continue

    raise Exception(f"Failed creating job '{name}'. Last error: {last_err}")


def configure_jobs_from_xsoar_config(
    xsoar_cfg: dict[str, Any],
    using: str,
    retry_count: int,
    retry_sleep_seconds: int,
    debug: bool,
) -> dict[str, Any]:
    jobs = [x for x in (xsoar_cfg.get("jobs", []) or []) if isinstance(x, dict)]
    emit_progress(f"Configuring jobs… ({len(jobs)} job(s))", stage="configure.jobs")

    summary = {
        "attempted": 0,
        "ok": 0,
        "failed": 0,
        "failed_items": [],
        "notes": [],
    }

    search_path = jobs_api_search_probe(using=using)
    if not search_path:
        emit_progress(
            "\n".join(
                [
                    "❌ Jobs API is not reachable (permissions/endpoint).",
                    "This script will NOT claim jobs were configured if it cannot verify them.",
                    "Fix permissions/role or confirm the correct jobs endpoint, then rerun.",
                ]
            ),
            stage="configure.jobs.error",
        )
        summary["notes"].append("jobs_api_unreachable=true")  # type: ignore[attr-defined]

    for job in jobs:
        name = _norm(job.get("name") or job.get("job_name") or "")
        if not name:
            continue

        summary["attempted"] += 1  # type: ignore[operator]
        log(f"Configuring job: **{name}**", stage="configure.jobs.debug", debug=debug)

        if not search_path:
            summary["failed"] += 1  # type: ignore[operator]
            summary["failed_items"].append(  # type: ignore[attr-defined]
                {"name": name, "error": "Jobs API verification unavailable; cannot confirm job creation/update."}
            )
            continue

        # ✅ FIX: If the job already exists, do nothing (prevents duplicates on rerun).
        existing = None
        for _i in range(1, 6):  # small settle loop for index propagation
            existing = jobs_api_find_by_name(name, using=using, search_path=search_path, debug=debug)
            if existing:
                break
            # Backoff via platform Sleep script — no time.sleep in pack code.
            demisto.executeCommand("Sleep", {"seconds": "1"})

        if existing:
            summary["ok"] += 1  # type: ignore[operator]
            log(f"⏭️ Job **{name}** already exists — skipping.", stage="configure.jobs.result", debug=debug, always=True)
            continue

        try:
            _ = jobs_api_upsert(job, using=using, search_path=search_path, debug=debug)

            verified = None
            for _i in range(1, 8):
                verified = jobs_api_find_by_name(name, using=using, search_path=search_path, debug=debug)
                if verified:
                    break
                # Backoff via platform Sleep script — no time.sleep in pack code.
                demisto.executeCommand("Sleep", {"seconds": "2"})

            if not verified:
                raise Exception("Upsert ran but job still not visible via Jobs API.")

            summary["ok"] += 1  # type: ignore[operator]
            log(f"✅ Job **{name}** created and verified.", stage="configure.jobs.result", debug=debug, always=True)

        except Exception as e:
            summary["failed"] += 1  # type: ignore[operator]
            summary["failed_items"].append({"name": name, "error": str(e)})  # type: ignore[attr-defined]
            emit_progress(f"Failed configuring job **{name}**.\nError: {e}", stage="configure.jobs.error")

    emit_progress(
        "\n".join(
            [
                "Jobs summary:",
                f"- attempted: {summary['attempted']}",
                f"- ok (verified/skip): {summary['ok']}",
                f"- failed: {summary['failed']}",
                f"- notes: {', '.join(summary['notes']) if summary['notes'] else '(none)'}",  # type: ignore[arg-type]
            ]
        ),
        stage="configure.jobs.summary",
    )
    return summary


# ---------------------------
# Main
# ---------------------------


# ─────────────────────────────────────────
# action=configure — config only, no install
# ─────────────────────────────────────────


def do_configure(args):
    pack_id = (args.get("pack_id") or "").strip()
    catalog_url = _norm(args.get("catalog_url") or DEFAULT_CATALOG_URL)
    using = (args.get("using") or "").strip()
    retry_count = to_int(args.get("retry_count"), 5)
    retry_sleep = to_int(args.get("retry_sleep_seconds"), 15)
    overwrite = arg_to_bool(args.get("overwrite_lookup"), False)
    cfg_jobs = arg_to_bool(args.get("configure_jobs"), True)
    cfg_integrations = arg_to_bool(args.get("configure_integrations"), True)
    cfg_lookups = arg_to_bool(args.get("configure_lookups"), True)
    debug = arg_to_bool(args.get("debug"), False)
    include_doc_content = arg_to_bool(args.get("include_doc_content"), False)
    doc_content_max_chars = to_int(args.get("doc_content_max_chars"), 6000)
    doc_content_max_lines = to_int(args.get("doc_content_max_lines"), 200)

    if not pack_id:
        raise Exception("pack_id is required for action=configure")

    catalog = fetch_pack_catalog(catalog_url)
    pack = find_pack_in_catalog(catalog, pack_id)

    xsoar_config_url = (
        (pack.get("xsoar_config") or pack.get("xsoar_config_url") or "")
        if pack
        else f"https://raw.githubusercontent.com/Palo-Cortex/secops-framework/refs/heads/main/Packs/{pack_id}/xsoar_config.json"
    )

    emit_progress(
        "\n".join(
            [
                f"action=configure for **{pack_id}**",
                f"- xsoar_config_url: {xsoar_config_url}",
                f"- jobs={cfg_jobs}, integrations={cfg_integrations}, lookups={cfg_lookups}",
                f"- overwrite_lookup={overwrite}",
            ]
        ),
        stage="configure.start",
    )

    xsoar_cfg = fetch_xsoar_config(xsoar_config_url) or {}

    emit_progress(
        "\n".join(
            [
                "xsoar_config loaded.",
                f"- integration_instances: {len(xsoar_cfg.get('integration_instances', []) or [])}",
                f"- jobs:                  {len(xsoar_cfg.get('jobs', []) or [])}",
                f"- lookup_datasets:       {len(xsoar_cfg.get('lookup_datasets', []) or [])}",
            ]
        ),
        stage="configure.summary",
    )

    installed_pack_ids = fetch_installed_marketplace_pack_ids(using)

    if cfg_integrations:
        configure_integrations_from_xsoar_config(
            xsoar_cfg=xsoar_cfg,
            using=using,
            retry_count=retry_count,
            retry_sleep_seconds=retry_sleep,
            installed_pack_ids=installed_pack_ids,
            debug=debug,
        )
    if cfg_jobs:
        configure_jobs_from_xsoar_config(
            xsoar_cfg=xsoar_cfg,
            using=using,
            retry_count=retry_count,
            retry_sleep_seconds=retry_sleep,
            debug=debug,
        )
    if cfg_lookups:
        configure_lookups_from_xsoar_config(
            xsoar_cfg=xsoar_cfg,
            using=using,
            retry_count=retry_count,
            retry_sleep_seconds=retry_sleep,
            overwrite_lookup=overwrite,
            debug=debug,
        )

    emit_progress("Configuration complete.", stage="configure.done")
    print_config_docs(
        xsoar_cfg,
        when="post",
        debug=debug,
        include_doc_content=include_doc_content,
        doc_content_max_chars=doc_content_max_chars,
        doc_content_max_lines=doc_content_max_lines,
    )


# ─────────────────────────────────────────
# action=sync-tags — update value_tags lookup
# ─────────────────────────────────────────

VALUE_TAGS_URL = (
    "https://raw.githubusercontent.com/Palo-Cortex/secops-framework"
    "/refs/heads/main/Packs/soc-optimization-unified/Lookup/value_tags.json"
)
VALUE_TAGS_DATASET = "value_tags"


def _compute_hash(obj):
    import hashlib

    canonical = json.dumps(obj, sort_keys=True, separators=(",", ":"))
    return hashlib.md5(canonical.encode()).hexdigest()  # guardrails-disable-line # nosec


VALUE_TAGS_LIST_NAME = "SOCFWTagsVersion"


def _get_current_meta(using, debug):
    """
    Read version metadata from the SOCFWTagsVersion XSIAM List.
    Lists are the reliable way to persist small state across script runs —
    lookup dataset schemas don't accommodate arbitrary extra fields.
    Returns None if the list doesn't exist or has never been written.
    """
    try:
        res = exec_cmd("getList", {"listName": VALUE_TAGS_LIST_NAME}, fail_on_error=False)
        if not res or socfw_is_error(res[0]):
            return None
        contents = get_contents(res)
        if not contents:
            return None
        # contents may be the raw string or a dict depending on list format
        raw = contents if isinstance(contents, str) else json.dumps(contents)
        if not raw or raw.strip() == "":
            return None
        return json.loads(raw)
    except Exception:
        return None


def _set_current_meta(meta: dict[str, Any], using: str, debug: bool):
    """
    Write version metadata to the SOCFWTagsVersion XSIAM List.
    Creates the list if it doesn't exist, updates it if it does.
    """
    payload = json.dumps(meta)
    # Try setList first (update); fall back to createList if it doesn't exist
    res = exec_cmd("setList", {"listName": VALUE_TAGS_LIST_NAME, "listData": payload}, fail_on_error=False)
    if res and socfw_is_error(res[0]):
        # List may not exist yet — create it
        exec_cmd(
            "createList",
            {"listName": VALUE_TAGS_LIST_NAME, "listData": payload},
            fail_on_error=True,
        )


def do_sync_tags(args):
    using = (args.get("using") or "").strip()
    force = arg_to_bool(args.get("force"), False)
    tags_url = _norm(args.get("tags_url") or VALUE_TAGS_URL)
    debug = arg_to_bool(args.get("debug"), False)

    emit_progress(
        "\n".join(
            [
                "action=sync-tags",
                f"- dataset: {VALUE_TAGS_DATASET}",
                f"- source:  {tags_url}",
                f"- force:   {force}",
            ]
        ),
        stage="sync-tags.start",
    )

    # Fetch incoming
    source_obj = http_get_json(tags_url)
    rows = _normalize_lookup_rows(source_obj)
    rows = _remove_omitted_fields(rows)

    if not rows:
        raise Exception("Downloaded value_tags.json but found 0 usable rows.")

    incoming_hash = _compute_hash(rows)
    emit_progress(
        f"Downloaded **{len(rows)}** rows. Incoming hash: `{incoming_hash}`",
        stage="sync-tags.fetch",
    )

    # Check current version
    meta = _get_current_meta(using=using, debug=debug)
    current_hash = (meta or {}).get("hash", "")
    current_version = (meta or {}).get("version", "")
    current_updated = (meta or {}).get("updated_at", "")

    if meta:
        emit_progress(
            "\n".join(
                [
                    "Current value_tags version:",
                    f"- version:    `{current_version}` (hash: `{current_hash}`)",
                    f"- updated_at: {current_updated}",
                ]
            ),
            stage="sync-tags.version",
        )
    else:
        emit_progress(
            "No version metadata found — first sync or metadata row missing.",
            stage="sync-tags.version",
        )

    # Up to date?
    if not force and current_hash and current_hash == incoming_hash:
        emit_progress(
            "\n".join(
                [
                    "**value_tags is already up to date.** No update needed.",
                    f"  Version: `{current_version}` (hash: `{current_hash}`)",
                    "",
                    "Run with `force=true` to overwrite anyway.",
                ]
            ),
            stage="sync-tags.result",
        )
        return_results(
            {
                "action": "sync-tags",
                "status": "up_to_date",
                "dataset": VALUE_TAGS_DATASET,
                "version": current_version,
                "hash": current_hash,
                "rows": len(rows),
                "updated": False,
            }
        )
        return

    # Apply update
    import time as _time

    updated_at = _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime())
    short_ver = incoming_hash[:8]
    # Upload only the actual value_tags rows — no meta row in the dataset.
    # Version state is stored in the SOCFWTagsVersion List (reliable persistence).
    _xql_lookup_add_data_list(
        dataset_name=VALUE_TAGS_DATASET,
        rows=rows,
        using=using,
        debug=debug,
    )

    # Persist version metadata to List
    _set_current_meta(
        {
            "hash": incoming_hash,
            "version": short_ver,
            "updated_at": updated_at,
            "row_count": str(len(rows)),
        },
        using=using,
        debug=debug,
    )

    changed = current_hash != incoming_hash if current_hash else True
    emit_progress(
        "\n".join(
            [
                f"{'value_tags **updated**.' if changed else 'value_tags force-refreshed.'}",
                f"- Rows:         {len(rows)}",
                f"- New version:  `{short_ver}` (hash: `{incoming_hash}`)",
                f"- Updated at:   {updated_at}",
                *(
                    ["- Previous:     `" + current_hash[:8] + "` (hash: `" + current_hash + "`)"]
                    if current_hash and changed
                    else []
                ),
            ]
        ),
        stage="sync-tags.result",
    )

    return_results(
        {
            "action": "sync-tags",
            "status": "updated",
            "dataset": VALUE_TAGS_DATASET,
            "version": short_ver,
            "hash": incoming_hash,
            "rows": len(rows),
            "updated": True,
            "previous_hash": current_hash or None,
            "updated_at": updated_at,
        }
    )


def _run_main():
    args = demisto.args()

    action = (args.get("action") or "apply").strip().lower()
    pack_id = (args.get("pack_id") or "").strip()
    include_hidden = arg_to_bool(args.get("include_hidden"), False)
    dry_run = arg_to_bool(args.get("dry_run"), False)

    install_marketplace_flag = arg_to_bool(args.get("install_marketplace"), True)
    apply_configure = arg_to_bool(args.get("apply_configure"), True)
    configure_jobs = arg_to_bool(args.get("configure_jobs"), True)
    configure_integrations = arg_to_bool(args.get("configure_integrations"), True)
    configure_lookups = arg_to_bool(args.get("configure_lookups"), True)
    overwrite_lookup = arg_to_bool(args.get("overwrite_lookup"), False)

    include_doc_content = arg_to_bool(args.get("include_doc_content"), False)
    doc_content_max_chars = to_int(args.get("doc_content_max_chars"), 6000)
    doc_content_max_lines = to_int(args.get("doc_content_max_lines"), 200)

    pre_config_done = arg_to_bool(args.get("pre_config_done"), False)
    pre_config_gate = arg_to_bool(args.get("pre_config_gate"), True)

    retry_count = to_int(args.get("retry_count"), 5)
    retry_sleep_seconds = to_int(args.get("retry_sleep_seconds"), 15)
    using = (args.get("using") or "").strip()
    execution_timeout = to_int(args.get("execution_timeout"), 1200)

    skip_verify = arg_to_bool(args.get("skip_verify"), True)
    skip_validation = arg_to_bool(args.get("skip_validation"), False)

    install_timeout = to_int(args.get("install_timeout"), 3600)

    post_install_poll_seconds = to_int(args.get("post_install_poll_seconds"), 1800)
    post_install_poll_interval_seconds = to_int(args.get("post_install_poll_interval_seconds"), 60)
    continue_on_install_timeout = arg_to_bool(args.get("continue_on_install_timeout"), False)

    fail_on_marketplace_errors = arg_to_bool(args.get("fail_on_marketplace_errors"), False)

    debug = arg_to_bool(args.get("debug"), False)

    catalog_url = _norm(args.get("catalog_url") or DEFAULT_CATALOG_URL)

    if action not in ("apply", "list", "configure", "sync-tags"):
        raise Exception(f"Unsupported action: {action}")

    if action == "list":
        return do_list(args)

    if action == "configure":
        return do_configure(args)

    if action == "sync-tags":
        return do_sync_tags(args)

    if not pack_id:
        raise Exception("pack_id is required for action=apply")

    emit_progress(
        "\n".join(
            [
                f"Starting {action} for **{pack_id}**",
                f"- catalog_url={catalog_url}",
                f"- include_hidden={include_hidden}",
                f"- dry_run={dry_run}",
                f"- install_marketplace={install_marketplace_flag}",
                f"- apply_configure={apply_configure} (jobs={configure_jobs}, "
                f"integrations={configure_integrations}, lookups={configure_lookups})",
                f"- overwrite_lookup={overwrite_lookup}",
                f"- retries={retry_count}, retry_sleep_seconds={retry_sleep_seconds}",
                f"- using={(using or '(default)')}",
                f"- execution_timeout={execution_timeout}",
                f"- install_timeout={install_timeout}",
                f"- skip_verify={skip_verify}",
                f"- skip_validation={skip_validation}",
                f"- post_install_poll_seconds={post_install_poll_seconds}",
                f"- post_install_poll_interval_seconds={post_install_poll_interval_seconds}",
                f"- continue_on_install_timeout={continue_on_install_timeout}",
                f"- fail_on_marketplace_errors={fail_on_marketplace_errors}",
                f"- include_doc_content={include_doc_content} "
                f"(max_chars={doc_content_max_chars}, max_lines={doc_content_max_lines})",
                f"- pre_config_gate={pre_config_gate}",
                f"- pre_config_done={pre_config_done}",
                f"- debug={debug}",
            ]
        ),
        stage="start",
    )

    emit_progress("Resolving install manifest…", stage="manifest")
    manifest = resolve_manifest(pack_id, include_hidden=include_hidden, catalog_url=catalog_url)

    marketplace_packs = manifest.get("marketplace_packs") or []
    custom_zip_urls = manifest.get("custom_zip_urls") or []
    xsoar_config_url = manifest.get("xsoar_config_url") or ""

    emit_progress(
        "\n".join(
            [
                "Manifest resolved.",
                f"- marketplace_packs: {len(marketplace_packs)}",
                f"- custom ZIP URLs: {len(custom_zip_urls)}",
                f"- xsoar_config_url: {xsoar_config_url or '(none)'}",
            ]
        ),
        stage="manifest.summary",
    )

    xsoar_cfg: dict[str, Any] = {}
    if xsoar_config_url:
        emit_progress("Fetching xsoar_config.json…", stage="xsoar_config.fetch")
        xsoar_cfg = fetch_xsoar_config(xsoar_config_url) or {}

        cfg_marketplace_packs = xsoar_cfg.get("marketplace_packs") or []
        if isinstance(cfg_marketplace_packs, list) and cfg_marketplace_packs:
            marketplace_packs = cfg_marketplace_packs

        # ✅ Pull custom pack dependencies from xsoar_config.json (authoritative)
        cfg_custom_packs = _extract_custom_packs_from_xsoar_cfg(xsoar_cfg)
        if cfg_custom_packs:
            custom_zip_urls = cfg_custom_packs
            emit_progress(
                "\n".join(
                    [
                        "Using custom_packs from xsoar_config.json:",
                        *[f"- {x.get('name')} -> {x.get('url')}" for x in custom_zip_urls],
                    ]
                ),
                stage="packs.custom.from_config",
            )

        emit_progress(
            "\n".join(
                [
                    "xsoar_config loaded.",
                    f"- integration_instances: {len(xsoar_cfg.get('integration_instances', []) or [])}",
                    f"- jobs: {len(xsoar_cfg.get('jobs', []) or [])}",
                    f"- lookup_datasets: {len(xsoar_cfg.get('lookup_datasets', []) or [])}",
                    f"- has_pre_config_docs: {has_config_docs(xsoar_cfg, 'pre')}",
                    f"- has_post_config_docs: {has_config_docs(xsoar_cfg, 'post')}",
                ]
            ),
            stage="xsoar_config.summary",
        )

        print_config_docs(
            xsoar_cfg,
            when="pre",
            debug=debug,
            include_doc_content=include_doc_content,
            doc_content_max_chars=doc_content_max_chars,
            doc_content_max_lines=doc_content_max_lines,
        )

        if pre_config_gate and has_config_docs(xsoar_cfg, "pre") and not pre_config_done:
            emit_progress(
                "\n".join(
                    [
                        "🛑 **Pre-config required**",
                        "Pre-config docs were printed above.",
                        "",
                        "After completing those steps, rerun with:",
                        "- `pre_config_done=true`",
                        "",
                        f"Example:\n`!SOCFWPackManager action=apply pack_id={pack_id} pre_config_done=true`",
                        "",
                        "To bypass this stop (not recommended), run with:",
                        "- `pre_config_gate=false`",
                    ]
                ),
                stage="docs.pre.gate",
            )
            return_results(
                {
                    "pack_id": pack_id,
                    "xsoar_config_url": xsoar_config_url,
                    "stopped_after_pre_docs": True,
                    "next_command_hint": f"!SOCFWPackManager action=apply pack_id={pack_id} pre_config_done=true",
                }
            )
            return None

    if dry_run:
        emit_progress("dry_run=True — not installing or configuring anything.", stage="done")
        return None

    marketplace_errors: list[str] = []
    if install_marketplace_flag and marketplace_packs:
        mp = []
        for p in marketplace_packs:
            if isinstance(p, dict) and p.get("id"):
                mp.append({"id": p.get("id"), "version": p.get("version", "latest")})

        try:
            _ = install_marketplace_packs(mp, using, retry_count, retry_sleep_seconds, debug=debug)
        except Exception as e:
            marketplace_errors.append(str(e))
            emit_progress(f"Marketplace install failed.\nError: {e}", stage="packs.marketplace.error")
            if fail_on_marketplace_errors:
                raise

    if custom_zip_urls:
        emit_progress(f"Installing custom pack ZIPs… ({len(custom_zip_urls)} ZIP(s))", stage="packs.custom")
        for item in custom_zip_urls:
            if isinstance(item, str):
                url = item
            else:
                url = item.get("url") or item.get("zip_url") or ""
            if not url:
                continue

            # Filename = last URL segment including .zip
            asset_fname = url.rstrip("/").split("/")[-1]
            if not asset_fname.endswith(".zip"):
                asset_fname += ".zip"

            emit_progress(f"Installing: **{asset_fname}**\n- {url}", stage="packs.custom")

            install_custom_pack_zip(
                url=url,
                asset_filename=asset_fname,
                using=using,
                retry_count=retry_count,
                retry_sleep_seconds=retry_sleep_seconds,
                debug=debug,
            )

    integration_summary = None
    jobs_summary = None
    lookups_summary = None

    if apply_configure and xsoar_cfg:
        emit_progress("Configuring from xsoar_config…", stage="configure")

        emit_progress(
            "\n".join(
                [
                    "Configure plan:",
                    f"- integration_instances: {len(xsoar_cfg.get('integration_instances', []) or [])}",
                    f"- jobs: {len(xsoar_cfg.get('jobs', []) or [])}",
                    f"- lookup_datasets: {len(xsoar_cfg.get('lookup_datasets', []) or [])}",
                ]
            ),
            stage="configure.plan",
        )

        installed_pack_ids = fetch_installed_marketplace_pack_ids(using)

        if configure_integrations:
            integration_summary = configure_integrations_from_xsoar_config(
                xsoar_cfg=xsoar_cfg,
                using=using,
                retry_count=retry_count,
                retry_sleep_seconds=retry_sleep_seconds,
                installed_pack_ids=installed_pack_ids,
                debug=debug,
            )

        if configure_jobs:
            jobs_summary = configure_jobs_from_xsoar_config(
                xsoar_cfg=xsoar_cfg,
                using=using,
                retry_count=retry_count,
                retry_sleep_seconds=retry_sleep_seconds,
                debug=debug,
            )

        if configure_lookups:
            lookups_summary = configure_lookups_from_xsoar_config(
                xsoar_cfg=xsoar_cfg,
                using=using,
                retry_count=retry_count,
                retry_sleep_seconds=retry_sleep_seconds,
                overwrite_lookup=overwrite_lookup,
                debug=debug,
            )

    emit_progress("Done.", stage="done")

    results_obj = {
        "pack_id": pack_id,
        "xsoar_config_url": xsoar_config_url,
        "catalog_url": catalog_url,
        "marketplace_errors": marketplace_errors,
        "debug": debug,
        "install_timeout": install_timeout,
        "skip_verify": skip_verify,
        "skip_validation": skip_validation,
        "post_install_poll_seconds": post_install_poll_seconds,
        "post_install_poll_interval_seconds": post_install_poll_interval_seconds,
        "continue_on_install_timeout": continue_on_install_timeout,
        "configure_summary": {
            "integrations": integration_summary,
            "jobs": jobs_summary,
            "lookups": lookups_summary,
        },
    }

    return_results(results_obj)

    if xsoar_cfg:
        print_config_docs(
            xsoar_cfg,
            when="post",
            debug=debug,
            include_doc_content=include_doc_content,
            doc_content_max_chars=doc_content_max_chars,
            doc_content_max_lines=doc_content_max_lines,
        )
        return None
    return None


def main():
    """Entry point with standard XSOAR error handling.

    Wraps the orchestrator in try/except so any uncaught exception surfaces
    via return_error() instead of crashing with a raw traceback.
    """
    try:
        _run_main()
    except Exception as exc:
        return_error(f"{SCRIPT_NAME} failed: {exc}")


if __name__ in ("__main__", "__builtin__", "builtins"):
    main()

README

SOC Framework Pack Manager — bootloader script for the SOC Framework on
XSIAM. Lists the SOC Framework pack catalog, installs and configures packs,
re-runs configuration only, and synchronizes the value_tags lookup against
the catalog version metadata.

Architecture

This script is the user-facing entry point. It depends on the SOC
Framework Pack Manager
integration, also shipped in this pack, for
credential storage and the actual pack-install HTTP. Configure an instance
of that integration before running action=apply. End users should never
call the integration’s socfw-install-pack command directly — invoke it
through this script.

Script Data

Name Description
Script Type python3
Tags configuration, Content Management, SOC, SOC_Framework, SOC_Framework_Unified, SOCFWBootloader
Cortex XSIAM Version 5.0.0 and later

Inputs

Argument Name Description
action One of list, apply, configure, or sync-tags. list shows the catalog. apply installs and configures a pack. configure re-runs configuration only (no pack install). sync-tags is a backward-compat action that updates the legacy value_tags lookup; modern SOC Framework deployments use SOCActionTimeMap_V3 and do not need it.
pack_id The pack ID from pack_catalog.json (for example, soc-optimization-unified). Required for action=apply and action=configure.
catalog_url Override the catalog URL without modifying the integration instance parameters.
using Integration instance name to route core-api commands through. Defaults to the active instance.
include_hidden Allow installing packs where visible=false in the catalog.
dry_run Show what would happen without installing or configuring.
install_marketplace Whether to install marketplace_packs from xsoar_config.json.
skip_verify Pass-through to core-api-install-packs for ZIP installs.
skip_validation Pass-through to core-api-install-packs for ZIP installs.
apply_configure Whether to apply the config sections from xsoar_config.json (instances, jobs, lookups).
overwrite_lookup Overwrite the SOC Framework lookup table. Save your customizations first.
configure_jobs When action=apply, run job configuration from xsoar_config.json. Ignored if apply_configure=false.
configure_integrations When action=apply, create or update integration instances from xsoar_config.json. Ignored if apply_configure=false.
configure_lookups When action=apply, create or update lookup datasets from xsoar_config.json. Ignored if apply_configure=false.
retry_count Number of retry attempts for install or configure operations that fail transiently.
retry_sleep_seconds Seconds to wait between retry attempts.
execution_timeout Timeout in seconds for individual core-api commands invoked during configure.
install_timeout Timeout in seconds for the full custom-pack install command before falling back to polling.
post_install_poll_seconds After an install timeout, total seconds to poll the tenant for the pack to appear installed.
post_install_poll_interval_seconds Interval in seconds between install completion polls.
continue_on_install_timeout Continue with configuration steps if a custom-pack install times out and polling does not confirm installation.
fail_on_marketplace_errors Raise on marketplace install errors instead of recording them and continuing.
debug Verbose War Room logging and additional install detail.
filter action=list only. Case-insensitive free-text filter applied to id, display_name, and path.
limit action=list only. Maximum number of rows to display per page.
offset action=list only. Row offset for paging.
sort_by action=list only. Column to sort by. One of id, display_name, version, visible, path.
sort_dir action=list only. Sort direction. asc or desc.
visible_only action=list only. Hide packs marked visible=false. Implied false when include_hidden=true.
fields action=list only. Comma-separated list of columns to show. Unknown fields are ignored.
show_total action=list only. Display “showing X-Y of Z” paging information.
include_doc_content When printing pre and post config docs, also fetch a truncated preview of the README content into the War Room output.
doc_content_max_chars Maximum characters per doc preview when include_doc_content=true.
doc_content_max_lines Maximum lines per doc preview when include_doc_content=true.
pre_config_done Set to true to acknowledge pre-config docs have been completed and continue with install or configure.
pre_config_gate When true, the script prints pre_config_docs and stops until pre_config_done=true.
force action=sync-tags only. Update value_tags even if the content hash matches the current version.
tags_url action=sync-tags only. Override the value_tags.json source URL. Defaults to the soc-optimization-unified pack on main.

Outputs

Path Description Type
SOCFramework.PackManager.pack_id Pack ID acted on for action=apply or action=configure. String
SOCFramework.PackManager.xsoar_config_url URL of the xsoar_config.json fetched for the pack. String
SOCFramework.PackManager.catalog_url URL of the pack catalog used to resolve the manifest. String
SOCFramework.PackManager.marketplace_errors Marketplace install errors recorded during action=apply. Unknown
SOCFramework.PackManager.configure_summary.integrations Integration instance configuration summary. Unknown
SOCFramework.PackManager.configure_summary.jobs Job configuration summary. Unknown
SOCFramework.PackManager.configure_summary.lookups Lookup dataset configuration summary. Unknown
SOCFramework.PackManager.SyncTags.status action=sync-tags result. up_to_date or updated. String
SOCFramework.PackManager.SyncTags.dataset Dataset name updated (value_tags). String
SOCFramework.PackManager.SyncTags.version Short hash of the value_tags content currently installed. String
SOCFramework.PackManager.SyncTags.hash Full content hash of the value_tags content currently installed. String
SOCFramework.PackManager.SyncTags.rows Number of value_tags rows uploaded to the dataset. Number
SOCFramework.PackManager.SyncTags.updated Whether the dataset was updated on this run. Boolean
SOCFramework.PackManager.SyncTags.previous_hash Previous content hash before this run, when applicable. String
SOCFramework.PackManager.SyncTags.updated_at ISO 8601 timestamp the value_tags dataset was last updated. String

Command examples

action=list

!SOCFWPackManager action=list

Human Readable Output

Renders a paged table of available SOC Framework packs (id, display_name, version, visible, path).

action=apply

!SOCFWPackManager action=apply pack_id=soc-optimization-unified

Human Readable Output

Streams progress to the war room as the marketplace dependencies install, the custom pack ZIP is uploaded as system content, and xsoar_config.json is applied.

action=configure

!SOCFWPackManager action=configure pack_id=SocFrameworkTrendMicroVisionOne

Human Readable Output

Re-runs configuration only — integration instances, jobs, and lookup datasets — without reinstalling the pack.

action=sync-tags

!SOCFWPackManager action=sync-tags

Human Readable Output

Backward-compat action. Updates the legacy value_tags lookup dataset for older deployments. Modern SOC Framework deployments use SOCActionTimeMap_V3 and do not need this command.