diff --git a/README.rst b/README.rst index 7a61bcba..05c5cd6f 100644 --- a/README.rst +++ b/README.rst @@ -168,7 +168,7 @@ For shell scripting, CI/CD pipelines, and agentic workflows, install the CLI ext nipyapi ci get_status --process_group_id See ``docs/cli.rst`` for the complete CLI reference and ``docs/ci.rst`` for CI/CD pipeline examples. -See the companion repository `nipyapi-actions `_ for GitHub Actions and GitLab CI examples and support. +See the companion repository `nipyapi-actions `_ for GitHub Actions, GitLab CI, and Azure Pipelines examples and support. **Built-in Docker Profiles:** diff --git a/docs/ci.rst b/docs/ci.rst index d1301983..3e8f24f6 100644 --- a/docs/ci.rst +++ b/docs/ci.rst @@ -74,7 +74,7 @@ Available Operations ensure_registry --------------- -Create or update a Git Flow Registry Client (GitHub or GitLab). +Create or update a Git Flow Registry Client (GitHub, GitLab, or Azure DevOps). .. code-block:: console @@ -93,7 +93,7 @@ Parameter Description Env ``--token`` Personal Access Token ``GH_REGISTRY_TOKEN`` or ``GL_REGISTRY_TOKEN`` ``--repo`` Repository in owner/repo format ``NIFI_REGISTRY_REPO`` ``--client_name`` Registry client name ``NIFI_REGISTRY_CLIENT_NAME`` -``--provider`` Git provider (github/gitlab) ``NIFI_REGISTRY_PROVIDER`` +``--provider`` Git provider (github/gitlab/azuredevops) ``NIFI_REGISTRY_PROVIDER`` ``--api_url`` API URL override ``NIFI_REGISTRY_API_URL`` ``--default_branch`` Default branch (default: main) ``NIFI_REGISTRY_BRANCH`` ``--repository_path`` Path within repository ``NIFI_REPOSITORY_PATH`` @@ -101,6 +101,42 @@ Parameter Description Env **Returns:** ``registry_client_id``, ``registry_client_name`` +**Azure DevOps:** + +Azure DevOps authenticates with a **service principal** (OAuth2 client credentials), not a +personal access token. Provide the service-principal credentials and repository coordinates +as arguments or via ``NIFI_ADO_*`` environment variables: + +.. code-block:: console + + nipyapi ci ensure_registry \ + --provider azuredevops \ + --organization myorg \ + --project myproject \ + --repository nifi-flows \ + --tenant_id $NIFI_ADO_TENANT_ID \ + --client_id $NIFI_ADO_CLIENT_ID \ + --client_secret $NIFI_ADO_CLIENT_SECRET \ + --default_branch main + +============================= ============================================ ================================ +Parameter Description Environment Variable +============================= ============================================ ================================ +``--provider`` Set to ``azuredevops`` ``NIFI_REGISTRY_PROVIDER`` +``--organization`` Azure DevOps organization ``NIFI_ADO_ORG`` +``--project`` Azure DevOps project ``NIFI_ADO_PROJECT`` +``--repository`` Azure DevOps repository name ``NIFI_ADO_REPO`` +``--tenant_id`` Entra (Azure AD) tenant id ``NIFI_ADO_TENANT_ID`` +``--client_id`` Service principal client id ``NIFI_ADO_CLIENT_ID`` +``--client_secret`` Service principal client secret ``NIFI_ADO_CLIENT_SECRET`` +``--default_branch`` Default branch (default: main) ``NIFI_REGISTRY_BRANCH`` +``--repository_path`` Path within repository ``NIFI_REPOSITORY_PATH`` +============================= ============================================ ================================ + +Provisioning an Azure DevOps registry client also creates the supporting management +controller services (a web client service and an OAuth2 token provider) that NiFi +needs to authenticate to Azure DevOps. + deploy_flow ----------- @@ -589,10 +625,16 @@ Variable Description ``NIFI_REGISTRY_REPO`` Repository in owner/repo format ``NIFI_REGISTRY_CLIENT_ID`` Registry client ID ``NIFI_REGISTRY_CLIENT_NAME`` Registry client name -``NIFI_REGISTRY_PROVIDER`` Git provider (github/gitlab) +``NIFI_REGISTRY_PROVIDER`` Git provider (github/gitlab/azuredevops) ``NIFI_REGISTRY_API_URL`` API URL override ``NIFI_REGISTRY_BRANCH`` Default branch ``NIFI_REPOSITORY_PATH`` Path within repository +``NIFI_ADO_ORG`` Azure DevOps organization +``NIFI_ADO_PROJECT`` Azure DevOps project +``NIFI_ADO_REPO`` Azure DevOps repository name +``NIFI_ADO_TENANT_ID`` Azure DevOps (Entra) tenant id +``NIFI_ADO_CLIENT_ID`` Azure DevOps service principal client id +``NIFI_ADO_CLIENT_SECRET`` Azure DevOps service principal secret ============================= ============================================ Flow Operations @@ -712,6 +754,29 @@ For GitLab CI, include the fragments template: - !reference [.nipyapi, deploy-flow] - !reference [.nipyapi, start-flow] +Azure Pipelines +--------------- + +For Azure Pipelines, the CLI auto-detects the environment (via ``TF_BUILD``) and emits +``##vso[task.setvariable]`` logging commands, so operation outputs become pipeline +variables referenceable in later steps. See the `nipyapi-actions +`_ repository for a complete pipeline +and reusable step template. + +.. code-block:: yaml + + variables: + - group: nipyapi-ado # NIFI_API_ENDPOINT, NIFI_ADO_* credentials, etc. + + steps: + - script: pip install "nipyapi[cli]" + displayName: Install nipyapi + - script: | + nipyapi ci ensure_registry --provider azuredevops + nipyapi ci deploy_flow --bucket connectors --flow postgresql + nipyapi ci start_flow --process_group_id $(process-group-id) + displayName: Deploy NiFi flow + Python Usage ============ diff --git a/docs/cli.rst b/docs/cli.rst index ab6b399e..8e3e7377 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -265,14 +265,26 @@ The CLI automatically formats output based on the execution environment: nipyapi ci deploy_flow ... > deploy.env # Output: PROCESS_GROUP_ID=abc-123 +**Azure Pipelines** + Auto-detected when ``TF_BUILD`` env var is set. Emits ``##vso[task.setvariable]`` + logging commands so each output becomes a pipeline variable (``isOutput=true``), + referenceable in later steps/jobs. + + .. code-block:: console + + # In an Azure Pipelines step + nipyapi ci deploy_flow ... + # Output: ##vso[task.setvariable variable=process_group_id;isOutput=true]abc-123 + **Manual Override** Force a specific format using ``NIFI_OUTPUT_FORMAT``: .. code-block:: console - export NIFI_OUTPUT_FORMAT=json # JSON (default) - export NIFI_OUTPUT_FORMAT=github # GitHub Actions format - export NIFI_OUTPUT_FORMAT=dotenv # GitLab dotenv format + export NIFI_OUTPUT_FORMAT=json # JSON (default) + export NIFI_OUTPUT_FORMAT=github # GitHub Actions format + export NIFI_OUTPUT_FORMAT=dotenv # GitLab dotenv format + export NIFI_OUTPUT_FORMAT=azurepipelines # Azure Pipelines format Log Level Control ================= diff --git a/docs/history.rst b/docs/history.rst index 677ef2cf..31199e1c 100644 --- a/docs/history.rst +++ b/docs/history.rst @@ -5,12 +5,32 @@ History 1.7.0 (2026-07-18) ------------------- -| Upgrade to NiFi 2.10.0 support with regenerated API clients +| NiFi 2.10.0 support and Azure DevOps Flow Registry Client integration **Supported Versions** - Bumped the supported and tested NiFi/Registry version from 2.7.2 to 2.10.0 and regenerated the NiFi and Registry API clients against the 2.10.0 OpenAPI specification. New Connectors API operations introduced ``operationId`` collisions in the generated clients; the affected hand-written call sites were updated accordingly (for example ``get_flow1``, ``create_asset1``, ``get_assets1``, and the renumbered ``update_run_status`` variants). +**Registry Client** + +- **ensure_azuredevops_registry()**: New ``nipyapi.versioning`` function to create or update an Azure DevOps Flow Registry Client. Unlike GitHub and GitLab (which use a personal access token), Azure DevOps authenticates with a service principal via OAuth2 client credentials, so the client is provisioned with the supporting management controller services. + +**Canvas Module** + +- **create_management_controller()** / **ensure_management_controller()**: New functions for controller-level (management) Controller Services — the kind referenced by Flow Registry Clients, which live at the Controller Settings level rather than inside a Process Group. ``ensure_management_controller`` is idempotent by name and validates supplied properties against the service's property descriptors. + +**CI Module** + +- **ensure_registry()**: Now accepts ``provider=azuredevops`` in addition to ``github`` and ``gitlab``. Azure DevOps organization, project, repository, and service-principal credentials are read from arguments or the corresponding ``NIFI_ADO_*`` environment variables. + +**CLI** + +- **Azure Pipelines output**: The CLI now auto-detects Azure Pipelines (via the ``TF_BUILD`` environment variable) and emits ``##vso[task.setvariable]`` logging commands so operation results round-trip as pipeline output variables. Can be forced with ``NIFI_OUTPUT_FORMAT=azurepipelines``. + +**Documentation** + +- Added Azure DevOps / Azure Pipelines coverage to the CI operations guide (``docs/ci.rst``), the CLI output-format reference (``docs/cli.rst``), and the project README. See the companion ``nipyapi-actions`` repository for a complete Azure Pipelines example. + 1.6.0 (2026-06-21) ------------------- diff --git a/nipyapi/canvas.py b/nipyapi/canvas.py index c863adbb..ead9a91b 100644 --- a/nipyapi/canvas.py +++ b/nipyapi/canvas.py @@ -55,6 +55,8 @@ "delete_connection", "get_component_connections", "create_controller", + "create_management_controller", + "ensure_management_controller", "list_all_controllers", "delete_controller", "update_controller", @@ -2149,6 +2151,98 @@ def create_controller(parent_pg, controller, name=None): ) +def create_management_controller(controller, name=None): + """ + Creates a new controller-level (management) Controller Service of the given + type, with the given Name. + + Management controller services live at the Controller level (Controller + Settings), not inside a Process Group, and are the kind referenced by Flow + Registry Clients, Reporting Tasks, and similar. Unlike + :func:`create_controller`, no parent Process Group is required; the service + is created via the Controller API. + + Args: + controller (:class:`~nipyapi.nifi.models.DocumentedTypeDTO`): Type of + Controller Service to create, found via the list_all_controller_types + method + name (str, optional): Name for the new Controller Service. If not + provided, defaults to the short type name (e.g., + "StandardOauth2AccessTokenProvider"). + + Returns: + :class:`~nipyapi.nifi.models.ControllerServiceEntity`: The created controller service + + """ + assert isinstance(controller, nipyapi.nifi.DocumentedTypeDTO) + assert name is None or isinstance(name, str) + # Default name to short type name if not provided (consistent with create_controller) + if name is None: + controller_name = controller.type.split(".")[-1] + else: + controller_name = name + with nipyapi.utils.rest_exceptions(): + # NiFi 2.x creates a controller-level Controller Service via ControllerApi + return nipyapi.nifi.ControllerApi().create_controller_service( + body=nipyapi.nifi.ControllerServiceEntity( + revision={"version": 0}, + component=nipyapi.nifi.ControllerServiceDTO( + bundle=controller.bundle, + type=controller.type, + name=controller_name, + ), + ), + ) + + +def ensure_management_controller(name, controller_type, properties=None, enable=True): + """ + Ensure a controller-level (management) Controller Service exists, is + configured, and (optionally) enabled. Idempotent by name. + + Composes the existing controller-service functions: finds an existing + service by name, creates it via :func:`create_management_controller` if + absent, applies ``properties`` via :func:`update_controller`, and enables it + via :func:`schedule_controller`. + + Args: + name (str): Controller Service name (used for idempotent lookup). + controller_type (str): Fully-qualified controller service class name, + e.g. ``org.apache.nifi.oauth2.StandardOauth2AccessTokenProvider``. + properties (dict, optional): Properties to set on the service. + enable (bool): Whether to enable the service (default True). + + Returns: + :class:`~nipyapi.nifi.models.ControllerServiceEntity`: The ensured controller service + + Raises: + ValueError: If the controller service type is not available on this NiFi. + + """ + assert isinstance(name, str) + assert isinstance(controller_type, str) + assert properties is None or isinstance(properties, dict) + controller = get_controller(name, identifier_type="name", bool_response=True, greedy=False) + if not controller: + cs_type = get_controller_type(controller_type, identifier_type="name", greedy=False) + if cs_type is None: + raise ValueError( + f"Controller service type '{controller_type}' is not available " + "on this NiFi instance" + ) + controller = create_management_controller(cs_type, name=name) + if properties: + # Validate the property keys against the service descriptors (same guard + # as elsewhere), so a typo raises instead of silently creating a dynamic + # property. auto_disable lets us re-apply to an existing, already-enabled + # service (idempotent re-invocation) instead of raising. + config = prepare_controller_config(controller, properties) + controller = update_controller(controller, update=config, auto_disable=True) + if enable: + controller = schedule_controller(controller, scheduled=True, refresh=True) + return controller + + def list_all_controllers( # pylint: disable=too-many-arguments,too-many-positional-arguments pg_id="root", descendants=True, diff --git a/nipyapi/ci/ensure_registry.py b/nipyapi/ci/ensure_registry.py index e5be341d..01c35706 100644 --- a/nipyapi/ci/ensure_registry.py +++ b/nipyapi/ci/ensure_registry.py @@ -4,7 +4,7 @@ Wraps nipyapi.versioning.ensure_registry_client with: - Environment variable support for CI/CD -- Provider abstraction (GitHub/GitLab) +- Provider abstraction (GitHub/GitLab/Azure DevOps) - Sensible defaults """ @@ -39,7 +39,8 @@ } -def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-arguments +# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def ensure_registry( token: Optional[str] = None, repo: Optional[str] = None, client_name: Optional[str] = None, @@ -47,6 +48,12 @@ def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-a api_url: Optional[str] = None, default_branch: Optional[str] = None, repository_path: Optional[str] = None, + tenant_id: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + organization: Optional[str] = None, + project: Optional[str] = None, + repository: Optional[str] = None, ) -> dict: """ Ensure a Git Flow Registry Client exists with the desired configuration. @@ -55,10 +62,16 @@ def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-a token: Personal Access Token. Env: GH_REGISTRY_TOKEN or GL_REGISTRY_TOKEN repo: Repository in owner/repo format. Env: NIFI_REGISTRY_REPO client_name: Registry client name. Env: NIFI_REGISTRY_CLIENT_NAME - provider: "github" or "gitlab". Env: NIFI_REGISTRY_PROVIDER + provider: "github", "gitlab", or "azuredevops". Env: NIFI_REGISTRY_PROVIDER api_url: API URL override. Env: NIFI_REGISTRY_API_URL default_branch: Default branch. Env: NIFI_REGISTRY_BRANCH repository_path: Path in repo. Env: NIFI_REPOSITORY_PATH + tenant_id: (azuredevops) Entra tenant id. Env: NIFI_ADO_TENANT_ID + client_id: (azuredevops) service principal client id. Env: NIFI_ADO_CLIENT_ID + client_secret: (azuredevops) service principal secret. Env: NIFI_ADO_CLIENT_SECRET + organization: (azuredevops) Azure DevOps organization. Env: NIFI_ADO_ORG + project: (azuredevops) Azure DevOps project. Env: NIFI_ADO_PROJECT + repository: (azuredevops) Azure DevOps repository name. Env: NIFI_ADO_REPO Returns: dict with registry_client_id and registry_client_name @@ -71,6 +84,23 @@ def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-a # Determine provider first so we can select the correct token env var provider = (provider or os.environ.get("NIFI_REGISTRY_PROVIDER") or "github").lower() + # Azure DevOps authenticates with a service principal (OAuth2 client + # credentials) and requires two controller services, which do not fit the + # token-property provider model below. Dispatch to the dedicated helper. + if provider in ("azuredevops", "azure-devops", "ado"): + return _ensure_registry_azuredevops( + client_name=client_name, + default_branch=default_branch, + repository_path=repository_path, + api_url=api_url, + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + organization=organization, + project=project, + repository=repository, + ) + # Select token based on provider - check provider-specific env var first if not token: if provider == "gitlab": @@ -86,7 +116,9 @@ def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-a # Validate if provider not in PROVIDERS: - raise ValueError(f"Invalid provider '{provider}'. Must be 'github' or 'gitlab'") + raise ValueError( + f"Invalid provider '{provider}'. Must be 'github', 'gitlab', or 'azuredevops'" + ) if not token: raise ValueError("token is required (or set GH_REGISTRY_TOKEN / GL_REGISTRY_TOKEN)") if not repo or "/" not in repo: @@ -138,3 +170,82 @@ def ensure_registry( # pylint: disable=too-many-arguments,too-many-positional-a "registry_client_id": client.id, "registry_client_name": client.component.name, } + + +# pylint: disable=too-many-arguments,too-many-positional-arguments +def _ensure_registry_azuredevops( + client_name=None, + default_branch=None, + repository_path=None, + api_url=None, + tenant_id=None, + client_id=None, + client_secret=None, + organization=None, + project=None, + repository=None, +) -> dict: + """ + Ensure an Azure DevOps Flow Registry Client (service-principal auth). + + Resolves Azure DevOps parameters from arguments or NIFI_ADO_* environment + variables, then provisions the required controller services and the registry + client via nipyapi.versioning.ensure_azuredevops_registry. + """ + tenant_id = tenant_id or os.environ.get("NIFI_ADO_TENANT_ID") + client_id = client_id or os.environ.get("NIFI_ADO_CLIENT_ID") + client_secret = client_secret or os.environ.get("NIFI_ADO_CLIENT_SECRET") + organization = organization or os.environ.get("NIFI_ADO_ORG") + project = project or os.environ.get("NIFI_ADO_PROJECT") + repository = repository or os.environ.get("NIFI_ADO_REPO") + default_branch = default_branch or os.environ.get("NIFI_REGISTRY_BRANCH") or "main" + repository_path = repository_path or os.environ.get("NIFI_REPOSITORY_PATH") or None + client_name = ( + client_name or os.environ.get("NIFI_REGISTRY_CLIENT_NAME") or "AzureDevOps-FlowRegistry" + ) + + required = { + "tenant_id": tenant_id, + "client_id": client_id, + "client_secret": client_secret, + "organization": organization, + "project": project, + "repository": repository, + } + missing = [key for key, value in required.items() if not value] + if missing: + raise ValueError( + "Azure DevOps provider requires " + + ", ".join(missing) + + " (or the corresponding NIFI_ADO_* environment variables)" + ) + + extra = {} + if api_url: + extra["api_url"] = api_url + if repository_path: + extra["repository_path"] = repository_path + + log.info( + "Ensuring azuredevops registry client '%s' for %s/%s/%s", + client_name, + organization, + project, + repository, + ) + client = nipyapi.versioning.ensure_azuredevops_registry( + name=client_name, + organization=organization, + project=project, + repository=repository, + tenant_id=tenant_id, + client_id=client_id, + client_secret=client_secret, + default_branch=default_branch, + **extra, + ) + log.info("Registry client ready: %s (ID: %s)", client.component.name, client.id) + return { + "registry_client_id": client.id, + "registry_client_name": client.component.name, + } diff --git a/nipyapi/cli.py b/nipyapi/cli.py index 2f45bffa..0cc2dd1d 100644 --- a/nipyapi/cli.py +++ b/nipyapi/cli.py @@ -46,11 +46,12 @@ Output Formatting: Complex objects are serialized to JSON by default. Override with: - NIFI_OUTPUT_FORMAT=github GitHub Actions format (key=value, heredoc for complex) - NIFI_OUTPUT_FORMAT=dotenv GitLab CI format (KEY=VALUE) - NIFI_OUTPUT_FORMAT=json JSON format (default) + NIFI_OUTPUT_FORMAT=github GitHub Actions format (key=value, heredoc for complex) + NIFI_OUTPUT_FORMAT=dotenv GitLab CI format (KEY=VALUE) + NIFI_OUTPUT_FORMAT=azurepipelines Azure Pipelines format (##vso[task.setvariable]) + NIFI_OUTPUT_FORMAT=json JSON format (default) - CI environments are auto-detected via GITHUB_ACTIONS or GITLAB_CI env vars. + CI environments are auto-detected via GITHUB_ACTIONS, GITLAB_CI, or TF_BUILD env vars. Log Level Control: NIFI_LOG_LEVEL=WARNING Default - only warnings and errors in output @@ -73,7 +74,7 @@ def _detect_output_format(): Priority: 1. Explicit NIFI_OUTPUT_FORMAT env var - 2. Auto-detect CI environment (GITHUB_ACTIONS, GITLAB_CI) + 2. Auto-detect CI environment (GITHUB_ACTIONS, GITLAB_CI, TF_BUILD) 3. Default to 'json' for structured output """ explicit = os.environ.get("NIFI_OUTPUT_FORMAT") @@ -85,11 +86,34 @@ def _detect_output_format(): return "github" if os.environ.get("GITLAB_CI"): return "dotenv" + if os.environ.get("TF_BUILD"): + # Azure Pipelines sets TF_BUILD=True on every agent + return "azurepipelines" # Default to JSON for complex objects return "json" +def _format_azure_pipelines_value(key, value): + """Format a key-value pair as an Azure Pipelines setvariable logging command. + + Emits ``##vso[task.setvariable variable=;isOutput=true]`` which + Azure Pipelines captures from stdout as an output variable, referenceable in + later steps as ``$(.)``. Values are percent-encoded per the + Azure Pipelines logging-command convention so they round-trip intact + (notably newlines, which would otherwise truncate the value). + """ + # JSON-serialize lists/dicts for valid single-line output, str() for scalars + if isinstance(value, (list, dict)): + v_str = json.dumps(value, default=str) + else: + v_str = str(value) + # Azure Pipelines logging-command escaping. Order matters: % must be first + # so we do not double-encode the escape sequences introduced below. + v_escaped = v_str.replace("%", "%AZP25").replace("\r", "%0D").replace("\n", "%0A") + return f"##vso[task.setvariable variable={key};isOutput=true]{v_escaped}" + + def _format_dotenv_value(key, value): """Format a key-value pair for dotenv output, quoting if needed.""" # JSON-serialize lists/dicts for valid output, str() for scalars @@ -154,6 +178,10 @@ def _serialize_result(obj, output_format="json"): # pylint: disable=too-many-re # GitLab dotenv format: KEY=VALUE (quoted if special chars) lines = [_format_dotenv_value(k, v) for k, v in _flatten_dict(data).items()] return "\n".join(line for line in lines if line is not None) + if output_format in ("azurepipelines", "azure"): + # Azure Pipelines: one ##vso[task.setvariable] logging command per output + lines = [_format_azure_pipelines_value(k, v) for k, v in _flatten_dict(data).items()] + return "\n".join(lines) # JSON format (default) return json.dumps(data, indent=2, default=str) diff --git a/nipyapi/versioning.py b/nipyapi/versioning.py index 4a33c259..3de3c73b 100644 --- a/nipyapi/versioning.py +++ b/nipyapi/versioning.py @@ -39,6 +39,8 @@ "deploy_git_registry_flow", "save_git_flow_ver", "get_local_modifications", + # Azure DevOps Registry Provisioning (service-principal auth) + "ensure_azuredevops_registry", # Generic Version Control (works with any registry type) "get_version_info", # Process Group Export/Import (no registry required) @@ -1744,3 +1746,121 @@ def import_process_group_definition( ) return result + + +# ============================================================================= +# Azure DevOps Flow Registry Client helpers +# ============================================================================= +# NiFi ships a native Azure DevOps Flow Registry Client. Unlike GitHub/GitLab +# (which authenticate with a single Personal Access Token property), the Azure +# DevOps client authenticates only via a Microsoft Entra service principal using +# OAuth2 client credentials, and therefore requires two management-level +# controller services: a Web Client Service and an OAuth2 Access Token Provider. +# These helpers abstract that away behind a single ensure_azuredevops_registry() +# call, consistent with the other ensure_* convenience functions. + +# The fixed Microsoft-assigned Azure DevOps resource application ID; the OAuth2 +# client-credentials scope is this id suffixed with "/.default". +AZURE_DEVOPS_OAUTH_SCOPE = "499b84ac-1321-427f-aa17-267ca6975798/.default" + +AZURE_DEVOPS_REGISTRY_TYPE = "org.apache.nifi.azure.devops.AzureDevOpsFlowRegistryClient" +_WEB_CLIENT_SERVICE_TYPE = ( + "org.apache.nifi.web.client.provider.service.StandardWebClientServiceProvider" +) +_OAUTH2_TOKEN_PROVIDER_TYPE = "org.apache.nifi.oauth2.StandardOauth2AccessTokenProvider" + + +# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals +def ensure_azuredevops_registry( + name, + organization, + project, + repository, + tenant_id, + client_id, + client_secret, + default_branch="main", + repository_path=None, + scope=None, + api_url="https://dev.azure.com", +): + """ + Ensure an Azure DevOps Flow Registry Client exists and is ready to use, + creating and wiring the required controller services automatically. + + This single call abstracts the full Azure DevOps setup: it ensures a Web + Client Service and an OAuth2 Access Token Provider (client-credentials, using + the supplied Entra service principal), then creates or updates the Azure + DevOps Flow Registry Client referencing them. Mirrors the one-call developer + experience of the GitHub/GitLab registry setup. + + Args: + name (str): Registry client name. + organization (str): Azure DevOps organization. + project (str): Azure DevOps project. + repository (str): Repository name holding the flows. + tenant_id (str): Microsoft Entra tenant id (for the OAuth2 token endpoint). + client_id (str): Service principal application (client) id. + client_secret (str): Service principal client secret. + default_branch (str): Default branch (default 'main'). + repository_path (str, optional): Subfolder path in the repo for flows. + scope (str, optional): OAuth2 scope. Defaults to the Azure DevOps + resource scope (AZURE_DEVOPS_OAUTH_SCOPE). + api_url (str): Azure DevOps API base URL (default 'https://dev.azure.com'). + + Returns: + :class:`~nipyapi.nifi.models.FlowRegistryClientEntity`: The registry client. + """ + scope = scope or AZURE_DEVOPS_OAUTH_SCOPE + _required = { + "name": name, + "organization": organization, + "project": project, + "repository": repository, + "tenant_id": tenant_id, + "client_id": client_id, + "client_secret": client_secret, + } + _missing = [key for key, value in _required.items() if not value] + if _missing: + raise ValueError( + "ensure_azuredevops_registry missing required argument(s): " + ", ".join(_missing) + ) + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + web_client = nipyapi.canvas.ensure_management_controller( + name=f"{name}-WebClient", + controller_type=_WEB_CLIENT_SERVICE_TYPE, + ) + oauth_provider = nipyapi.canvas.ensure_management_controller( + name=f"{name}-OAuth2", + controller_type=_OAUTH2_TOKEN_PROVIDER_TYPE, + properties={ + "Authorization Server URL": token_url, + "Grant Type": "client_credentials", + "Client Authentication Strategy": "REQUEST_BODY", + "Client ID": client_id, + "Client Secret": client_secret, + "Scope": scope, + }, + ) + + properties = { + "Azure DevOps API URL": api_url, + "Organization": organization, + "Project": project, + "Repository Name": repository, + "Default Branch": default_branch, + "Authentication Strategy": "SERVICE_PRINCIPAL", + "OAuth2 Access Token Provider": oauth_provider.id, + "Web Client Service": web_client.id, + } + if repository_path: + properties["Repository Path"] = repository_path + + return ensure_registry_client( + name=name, + reg_type=AZURE_DEVOPS_REGISTRY_TYPE, + description=f"Azure DevOps Flow Registry Client for {organization}/{project}/{repository}", + properties=properties, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 72ec301a..d71af9ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,6 +81,26 @@ def remove_test_git_registry_clients(): ] +def remove_test_ado_controller_services(): + """Delete controller-level controller services created by ADO registry tests. + + The Azure DevOps registry helper creates two management (controller-level) + controller services named ``-WebClient`` and ``-OAuth2``. + Registry-client teardown does not remove them, so clean them up by name. + """ + if SKIP_TEARDOWN: + return None + _ = [nipyapi.canvas.delete_controller(li, True) for li + in nipyapi.canvas.list_all_controllers(include_reporting_tasks=True) if + li.component and test_git_registry_client_name in (li.component.name or "")] + + +def remove_test_ado_registry_artifacts(): + """Full ADO teardown: registry client(s) then their controller services.""" + remove_test_git_registry_clients() + remove_test_ado_controller_services() + + def _wait_until_service_up(gui_url: str): if not nipyapi.utils.wait_to_complete( nipyapi.utils.is_endpoint_up, @@ -852,6 +872,65 @@ def generate(self, suffix=''): return Dummy() +# Azure DevOps registry fixtures require a service principal via ADO_* env vars: +# ADO_TENANT_ID, ADO_CLIENT_ID, ADO_CLIENT_SECRET, ADO_ORG, ADO_PROJECT, ADO_REPO +# Unlike GitHub/GitLab (PAT string), Azure DevOps auth is SERVICE_PRINCIPAL-only, +# so the helper also provisions two controller services (Web Client + OAuth2). +ADO_REGISTRY_ENV_VARS = ( + "ADO_TENANT_ID", + "ADO_CLIENT_ID", + "ADO_CLIENT_SECRET", + "ADO_ORG", + "ADO_PROJECT", + "ADO_REPO", +) + + +@pytest.fixture(name='fix_ado_reg_client', scope='function') +def fixture_ado_registry_client(request): + """Create an Azure DevOps registry client for testing with real credentials. + + Requires ADO_TENANT_ID, ADO_CLIENT_ID, ADO_CLIENT_SECRET, ADO_ORG, + ADO_PROJECT and ADO_REPO environment variables (a service principal with + repo write access on an Entra-backed Azure DevOps organization). The helper + provisions the two required controller services in addition to the registry + client; teardown removes all three by name. + """ + missing = [v for v in ADO_REGISTRY_ENV_VARS if not os.environ.get(v)] + if missing: + pytest.skip( + "Azure DevOps env not set (" + ", ".join(missing) + ") - skipping ADO registry tests" + ) + + class Dummy: + def __init__(self): + self._client = None + + def generate(self, suffix=''): + client_name = test_git_registry_client_name + '_ado' + suffix + # Clean up any prior client + its controller services + existing = nipyapi.versioning.list_registry_clients().registries + for client in existing: + if client.component and client_name in client.component.name: + nipyapi.versioning.delete_registry_client(client) + + self._client = nipyapi.versioning.ensure_azuredevops_registry( + name=client_name, + organization=os.environ['ADO_ORG'], + project=os.environ['ADO_PROJECT'], + repository=os.environ['ADO_REPO'], + tenant_id=os.environ['ADO_TENANT_ID'], + client_id=os.environ['ADO_CLIENT_ID'], + client_secret=os.environ['ADO_CLIENT_SECRET'], + default_branch=os.environ.get('ADO_BRANCH', 'main'), + repository_path=os.environ.get('ADO_REPO_PATH') or None, + ) + return self._client + + request.addfinalizer(remove_test_ado_registry_artifacts) + return Dummy() + + @pytest.fixture(name='fix_deployed_git_flow_shared', scope='module') def fixture_deployed_git_flow_shared(request): """Module-scoped fixture that deploys a flow once for all tests. diff --git a/tests/test_canvas.py b/tests/test_canvas.py index 0d64f953..55008a6d 100644 --- a/tests/test_canvas.py +++ b/tests/test_canvas.py @@ -886,6 +886,91 @@ def test_create_controller(fix_cont): canvas.delete_controller(r1, force=True) +# Management (controller-level) controller service type available on stock NiFi +_MGMT_CS_TYPE = ( + "org.apache.nifi.web.client.provider.service.StandardWebClientServiceProvider" +) + + +def test_create_management_controller(): + cs_type = canvas.get_controller_type( + _MGMT_CS_TYPE, identifier_type="name", greedy=False + ) + assert cs_type is not None + r1 = canvas.create_management_controller(cs_type, name="test-mgmt-webclient") + try: + assert isinstance(r1, nifi.ControllerServiceEntity) + assert r1.component.name == "test-mgmt-webclient" + assert r1.component.type == _MGMT_CS_TYPE + # Controller-level service is not scoped to a process group + assert r1.component.parent_group_id is None + # Bad argument types raise, consistent with create_controller + with pytest.raises(AssertionError): + _ = canvas.create_management_controller("not-a-type") + with pytest.raises(AssertionError): + _ = canvas.create_management_controller(cs_type, name=123) + finally: + canvas.delete_controller(r1, force=True) + + +def test_ensure_management_controller(): + name = "test-ensure-mgmt-webclient" + props = {"Connect Timeout": "20 secs"} + r1 = canvas.ensure_management_controller(name, _MGMT_CS_TYPE, properties=props) + try: + assert isinstance(r1, nifi.ControllerServiceEntity) + assert r1.component.name == name + assert r1.component.properties["Connect Timeout"] == "20 secs" + assert r1.component.state == "ENABLED" + # Idempotent by name: a second call reuses the same service and can + # re-apply properties even though it is now ENABLED (auto_disable path). + r2 = canvas.ensure_management_controller(name, _MGMT_CS_TYPE, properties=props) + assert r2.id == r1.id + assert r2.component.state == "ENABLED" + # Unknown type raises ValueError + with pytest.raises(ValueError, match="not available"): + _ = canvas.ensure_management_controller( + "does-not-exist", "org.apache.nifi.NoSuchControllerService" + ) + finally: + canvas.delete_controller(r1.id, force=True) + + +def test_management_controller_crud(): + """Full CRUD via the shared canvas controller functions on a controller-level + (management) controller service. This proves get/update/schedule/delete + operate correctly on management services (created via ControllerApi), not + only on PG-scoped controller services created via ProcessGroupsApi. + """ + cs_type = canvas.get_controller_type( + _MGMT_CS_TYPE, identifier_type="name", greedy=False + ) + cs = canvas.create_management_controller(cs_type, name="test-mgmt-crud") + try: + # READ: resolve the management service by id and by name + by_id = canvas.get_controller(cs.id, "id") + assert by_id is not None and by_id.id == cs.id + by_name = canvas.get_controller( + "test-mgmt-crud", identifier_type="name", greedy=False + ) + assert by_name is not None and by_name.id == cs.id + # UPDATE: apply a non-sensitive property to the management service + config = canvas.prepare_controller_config(cs, {"Connect Timeout": "20 secs"}) + updated = canvas.update_controller(cs, update=config) + assert updated.component.properties["Connect Timeout"] == "20 secs" + # SCHEDULE: enable then disable the management service + enabled = canvas.schedule_controller(updated, True) + assert enabled.component.state == "ENABLED" + disabled = canvas.schedule_controller(enabled, False) + assert disabled.component.state == "DISABLED" + finally: + # DELETE: remove the management service and confirm it is gone + canvas.delete_controller(cs.id, force=True) + assert canvas.get_controller( + "test-mgmt-crud", identifier_type="name", greedy=False + ) is None + + def test_get_controller(fix_pg, fix_cont): f_pg = fix_pg.generate() f_c1 = fix_cont(parent_pg=f_pg) diff --git a/tests/test_ci.py b/tests/test_ci.py index a3753e93..a64d497b 100644 --- a/tests/test_ci.py +++ b/tests/test_ci.py @@ -135,6 +135,176 @@ def test_github_uses_gh_token_first(self, mock_ensure): os.environ.pop("GL_REGISTRY_TOKEN", None) +ADO_ENV_VARS = ( + "NIFI_ADO_TENANT_ID", + "NIFI_ADO_CLIENT_ID", + "NIFI_ADO_CLIENT_SECRET", + "NIFI_ADO_ORG", + "NIFI_ADO_PROJECT", + "NIFI_ADO_REPO", +) + + +class TestEnsureRegistryAzureDevOps: + """Test the Azure DevOps provider branch of ensure_registry (no NiFi).""" + + def _clear_ado_env(self): + return {k: os.environ.pop(k, None) for k in ADO_ENV_VARS} + + def _restore_ado_env(self, saved): + for key, value in saved.items(): + if value is not None: + os.environ[key] = value + else: + os.environ.pop(key, None) + + def test_azuredevops_missing_creds_raises(self): + """ADO provider without any creds raises ValueError listing all missing.""" + saved = self._clear_ado_env() + try: + with pytest.raises(ValueError, match="Azure DevOps provider requires"): + ci.ensure_registry(provider="azuredevops") + finally: + self._restore_ado_env(saved) + + def test_azuredevops_partial_creds_lists_missing(self): + """Only the missing ADO args are reported, not the supplied ones.""" + saved = self._clear_ado_env() + try: + with pytest.raises(ValueError) as excinfo: + ci.ensure_registry( + provider="azuredevops", + tenant_id="t", + client_id="c", + client_secret="s", + organization="org", + # project + repository intentionally omitted + ) + message = str(excinfo.value) + assert "project" in message + assert "repository" in message + assert "tenant_id" not in message + finally: + self._restore_ado_env(saved) + + @patch("nipyapi.versioning.ensure_azuredevops_registry") + def test_azuredevops_reads_env_and_delegates(self, mock_ensure): + """ADO provider resolves NIFI_ADO_* env vars and delegates to versioning.""" + mock_client = MagicMock() + mock_client.id = "ado-client-id" + mock_client.component.name = "AzureDevOps-FlowRegistry" + mock_ensure.return_value = mock_client + + saved = self._clear_ado_env() + old_branch = os.environ.pop("NIFI_REGISTRY_BRANCH", None) + try: + os.environ["NIFI_ADO_TENANT_ID"] = "tenant-123" + os.environ["NIFI_ADO_CLIENT_ID"] = "client-456" + os.environ["NIFI_ADO_CLIENT_SECRET"] = "secret-789" + os.environ["NIFI_ADO_ORG"] = "my-org" + os.environ["NIFI_ADO_PROJECT"] = "my-project" + os.environ["NIFI_ADO_REPO"] = "my-repo" + + result = ci.ensure_registry(provider="azuredevops") + + assert result["registry_client_id"] == "ado-client-id" + assert result["registry_client_name"] == "AzureDevOps-FlowRegistry" + + call_kwargs = mock_ensure.call_args[1] + assert call_kwargs["organization"] == "my-org" + assert call_kwargs["project"] == "my-project" + assert call_kwargs["repository"] == "my-repo" + assert call_kwargs["tenant_id"] == "tenant-123" + assert call_kwargs["client_id"] == "client-456" + assert call_kwargs["client_secret"] == "secret-789" + # Default branch applied when neither arg nor env override present + assert call_kwargs["default_branch"] == "main" + finally: + self._restore_ado_env(saved) + if old_branch: + os.environ["NIFI_REGISTRY_BRANCH"] = old_branch + + @patch("nipyapi.versioning.ensure_azuredevops_registry") + def test_azuredevops_args_override_env(self, mock_ensure): + """Explicit args take precedence over NIFI_ADO_* env vars.""" + mock_client = MagicMock() + mock_client.id = "id" + mock_client.component.name = "custom-name" + mock_ensure.return_value = mock_client + + saved = self._clear_ado_env() + old_branch = os.environ.pop("NIFI_REGISTRY_BRANCH", None) + try: + os.environ["NIFI_ADO_ORG"] = "env-org" + ci.ensure_registry( + provider="azuredevops", + client_name="custom-name", + tenant_id="t", + client_id="c", + client_secret="s", + organization="arg-org", + project="p", + repository="r", + default_branch="develop", + ) + call_kwargs = mock_ensure.call_args[1] + assert call_kwargs["organization"] == "arg-org" + assert call_kwargs["name"] == "custom-name" + assert call_kwargs["default_branch"] == "develop" + finally: + self._restore_ado_env(saved) + if old_branch: + os.environ["NIFI_REGISTRY_BRANCH"] = old_branch + + @patch("nipyapi.versioning.ensure_azuredevops_registry") + def test_azuredevops_provider_aliases(self, mock_ensure): + """'azure-devops' and 'ado' aliases route to the ADO branch.""" + mock_client = MagicMock() + mock_client.id = "id" + mock_client.component.name = "n" + mock_ensure.return_value = mock_client + + saved = self._clear_ado_env() + try: + for alias in ("azure-devops", "ado"): + mock_ensure.reset_mock() + ci.ensure_registry( + provider=alias, + tenant_id="t", + client_id="c", + client_secret="s", + organization="o", + project="p", + repository="r", + ) + assert mock_ensure.called + finally: + self._restore_ado_env(saved) + + +class TestEnsureAzureDevOpsRegistryValidation: + """Test versioning.ensure_azuredevops_registry validation (no NiFi).""" + + def test_missing_required_args_raises(self): + """Direct call with missing required args raises listing them.""" + import nipyapi + + with pytest.raises(ValueError) as excinfo: + nipyapi.versioning.ensure_azuredevops_registry( + name="client", + organization="org", + project="proj", + repository="repo", + tenant_id=None, + client_id=None, + client_secret=None, + ) + message = str(excinfo.value) + assert "tenant_id" in message + assert "client_id" in message + assert "client_secret" in message + + class TestDeployFlowValidation: """Test deploy_flow validation logic (no NiFi required).""" @@ -2319,3 +2489,82 @@ def test_export_import_roundtrip_with_inheritance(fix_inherited_context_hierarch if p.parameter.name == fixture.child_param_name ) assert child_param.parameter.value == "child_value" + + +# ============================================================================= +# Azure DevOps Registry Integration Tests (require ADO_* service principal env) +# ============================================================================= + + +def test_ado_registry_client_type_available(): + """The Azure DevOps registry client type must ship with the running NiFi. + + Proves the nifi-azure NAR is present (needs NiFi connection, not ADO creds). + """ + import nipyapi + + types = nipyapi.versioning.list_registry_client_types() + type_names = {t.type for t in types} + assert nipyapi.versioning.AZURE_DEVOPS_REGISTRY_TYPE in type_names, ( + f"AzureDevOpsFlowRegistryClient not available; present types: {sorted(type_names)}" + ) + + +def test_ado_ensure_registry_live(fix_ado_reg_client): + """Live: ensure_azuredevops_registry creates a valid client + controller services.""" + import nipyapi + + client = fix_ado_reg_client.generate() + + # Registry client is the ADO type + assert client.component.type == ( + "org.apache.nifi.azure.devops.AzureDevOpsFlowRegistryClient" + ) + props = client.component.properties + assert props["Authentication Strategy"] == "SERVICE_PRINCIPAL" + assert props["Organization"] == os.environ["ADO_ORG"] + assert props["Project"] == os.environ["ADO_PROJECT"] + assert props["Repository Name"] == os.environ["ADO_REPO"] + # Both controller services are wired in by id + assert props["OAuth2 Access Token Provider"] + assert props["Web Client Service"] + + # The referenced controller services exist and are ENABLED + cs_api = nipyapi.nifi.ControllerServicesApi() + oauth_cs = cs_api.get_controller_service(props["OAuth2 Access Token Provider"]) + web_cs = cs_api.get_controller_service(props["Web Client Service"]) + assert oauth_cs.component.state == "ENABLED" + assert web_cs.component.state == "ENABLED" + + # Prove real auth, not just VALID config ("Valid != access"): listing + # buckets forces the SP OAuth2 token fetch + a live Azure DevOps API call, + # so a successful (non-raising) call means the service principal actually + # authenticated against the repository. + buckets = nipyapi.versioning.list_git_registry_buckets(client.id) + assert buckets is not None + + +def test_ado_ensure_registry_idempotent(fix_ado_reg_client): + """Live: calling the helper twice with the same name reuses client + services.""" + import nipyapi + + # First call via the fixture (also registers teardown for these artifacts) + first = fix_ado_reg_client.generate(suffix='_idem') + first_oauth = first.component.properties["OAuth2 Access Token Provider"] + first_web = first.component.properties["Web Client Service"] + + # Second call directly with the SAME name must find-or-update, not duplicate + second = nipyapi.versioning.ensure_azuredevops_registry( + name=first.component.name, + organization=os.environ["ADO_ORG"], + project=os.environ["ADO_PROJECT"], + repository=os.environ["ADO_REPO"], + tenant_id=os.environ["ADO_TENANT_ID"], + client_id=os.environ["ADO_CLIENT_ID"], + client_secret=os.environ["ADO_CLIENT_SECRET"], + default_branch=os.environ.get("ADO_BRANCH", "main"), + ) + + assert second.id == first.id + assert second.component.properties["OAuth2 Access Token Provider"] == first_oauth + assert second.component.properties["Web Client Service"] == first_web diff --git a/tests/test_cli.py b/tests/test_cli.py index 235d74c7..6a3e877f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -98,6 +98,67 @@ def test_serialize_result_dict_dotenv_special_chars(): assert 'WITH_QUOTES="value with \\"quotes\\""' in result +def test_serialize_result_dict_azurepipelines(): + """Test Azure Pipelines setvariable output format.""" + from nipyapi.cli import _serialize_result + result = _serialize_result({"process_group_id": "abc123", "state": "RUNNING"}, + "azurepipelines") + # One ##vso[task.setvariable] logging command per output, isOutput=true + assert ( + "##vso[task.setvariable variable=process_group_id;isOutput=true]abc123" + in result + ) + assert "##vso[task.setvariable variable=state;isOutput=true]RUNNING" in result + + +def test_serialize_result_dict_azure_alias(): + """Test the 'azure' alias resolves to the Azure Pipelines format.""" + from nipyapi.cli import _serialize_result + result = _serialize_result({"state": "RUNNING"}, "azure") + assert "##vso[task.setvariable variable=state;isOutput=true]RUNNING" in result + + +def test_serialize_result_dict_azurepipelines_special_chars(): + """Test Azure Pipelines percent-encoding of newlines, CR, and percent signs.""" + from nipyapi.cli import _serialize_result + result = _serialize_result({"note": "100%\nline2\r"}, "azurepipelines") + # % must be encoded first (%AZP25), then CR (%0D) and LF (%0A), so the value + # round-trips instead of truncating at the newline. + assert "]100%AZP25%0Aline2%0D" in result + # Raw newline/CR must NOT appear in the emitted command value + assert "\n" not in result + assert "\r" not in result + + +def test_serialize_result_dict_azurepipelines_nested_list(): + """Test Azure Pipelines format JSON-serializes nested lists on a single line.""" + from nipyapi.cli import _serialize_result + data = { + "flow_count": "2", + "flows": [{"name": "flow1", "id": "abc"}, {"name": "flow2", "id": "def"}], + } + result = _serialize_result(data, "azurepipelines") + lines = result.strip().split("\n") + # One logging command per line (no accidental multiline from the JSON value) + assert len(lines) == 2 + assert "variable=flow_count;isOutput=true]2" in result + flows_line = [ln for ln in lines if "variable=flows;" in ln][0] + flows_json = flows_line.split("isOutput=true]", 1)[1] + parsed = json.loads(flows_json) + assert len(parsed) == 2 + assert parsed[0]["name"] == "flow1" + + +def test_serialize_result_dict_azurepipelines_nested_dict(): + """Test Azure Pipelines format flattens nested dicts (existing flatten behavior).""" + from nipyapi.cli import _serialize_result + data = {"name": "test", "metadata": {"key1": "value1", "key2": "value2"}} + result = _serialize_result(data, "azurepipelines") + assert "variable=name;isOutput=true]test" in result + assert "variable=metadata_key1;isOutput=true]value1" in result + assert "variable=metadata_key2;isOutput=true]value2" in result + + def test_serialize_result_list_json(): """Test JSON serialization of list.""" from nipyapi.cli import _serialize_result @@ -106,7 +167,6 @@ def test_serialize_result_list_json(): assert len(parsed) == 2 assert parsed[0]["a"] == 1 - def test_serialize_result_list_non_json(): """Test list serialization with non-JSON format returns JSONL.""" from nipyapi.cli import _serialize_result @@ -536,6 +596,49 @@ def test_detect_output_format_gitlab(): os.environ["GITHUB_ACTIONS"] = old_github +def test_detect_output_format_azure(): + """Test Azure Pipelines auto-detection via TF_BUILD.""" + from nipyapi.cli import _detect_output_format + old_format = os.environ.pop("NIFI_OUTPUT_FORMAT", None) + old_github = os.environ.pop("GITHUB_ACTIONS", None) + old_gitlab = os.environ.pop("GITLAB_CI", None) + old_tf = os.environ.get("TF_BUILD") + try: + os.environ["TF_BUILD"] = "True" + assert _detect_output_format() == "azurepipelines" + finally: + if old_format: + os.environ["NIFI_OUTPUT_FORMAT"] = old_format + if old_github: + os.environ["GITHUB_ACTIONS"] = old_github + if old_gitlab: + os.environ["GITLAB_CI"] = old_gitlab + if old_tf: + os.environ["TF_BUILD"] = old_tf + else: + os.environ.pop("TF_BUILD", None) + + +def test_detect_output_format_explicit_over_tf_build(): + """Test explicit NIFI_OUTPUT_FORMAT beats TF_BUILD auto-detection.""" + from nipyapi.cli import _detect_output_format + old_format = os.environ.get("NIFI_OUTPUT_FORMAT") + old_tf = os.environ.get("TF_BUILD") + try: + os.environ["TF_BUILD"] = "True" + os.environ["NIFI_OUTPUT_FORMAT"] = "json" + assert _detect_output_format() == "json" + finally: + if old_format: + os.environ["NIFI_OUTPUT_FORMAT"] = old_format + else: + os.environ.pop("NIFI_OUTPUT_FORMAT", None) + if old_tf: + os.environ["TF_BUILD"] = old_tf + else: + os.environ.pop("TF_BUILD", None) + + def test_get_log_level_default(): """Test default log level is None (no logs).""" from nipyapi.cli import _get_log_level