diff --git a/README.md b/README.md index 250950c1..242cbb62 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ Each profile requires `instance_type`: `design`, `automation`, `deployer`, `gove `agent-management`. Existing stdio profiles missing this field are automatically assigned `design` and rewritten on disk before loading. This temporary migration is scheduled for deprecation by 0.9.0; existing values are preserved and validated. -The type metadata does not change client selection or tool availability. -Selecting Govern records its type; Govern-specific API tools are not yet implemented. +The type metadata does not change tool availability. Only the `govern` tool reads +it: that tool runs only when the active instance has type `govern`. **Environment override.** To select an explicit target, including inside a Code Studio, set these three variables in your environment or copy diff --git a/dataiku_mcp/__init__.py b/dataiku_mcp/__init__.py index bd19b45b..c412fecb 100644 --- a/dataiku_mcp/__init__.py +++ b/dataiku_mcp/__init__.py @@ -35,6 +35,7 @@ evaluation_stores, flow, general_settings, + govern, groups, insights, instances, diff --git a/dataiku_mcp/auth.py b/dataiku_mcp/auth.py index 58419cb8..d53424ef 100644 --- a/dataiku_mcp/auth.py +++ b/dataiku_mcp/auth.py @@ -57,6 +57,36 @@ def get_dss_client() -> dataikuapi.DSSClient: return client +def get_govern_client() -> dataikuapi.GovernClient: + """Get a Govern API client for the active instance of type `govern`.""" + if request.is_http_request(): + raise ValueError( + "The govern tool is available only in local stdio mode. A Govern " + "node accepts only API keys, not the delegated tokens that HTTP mode " + "uses." + ) + current_instance = request.get_pinned_instance() + if current_instance.instance_type != "govern": + raise ValueError( + f"The active instance '{current_instance.name}' has type " + f"'{current_instance.instance_type}'. The govern tool needs an instance " + "of type 'govern': run switch_instance to one, or configure_instance " + "to add one." + ) + client = dataikuapi.GovernClient( + current_instance.url, + api_key=current_instance.api_key, + internal_ticket=current_instance.api_ticket, + extra_headers={"X-DKU-Client-Application": "dataiku-headless"}, + ) + client._session.verify = ( + False + if current_instance.no_check_certificate + else current_instance.encrypted_rpc_cert_path or True + ) + return client + + def get_dataiku_version(client: dataikuapi.DSSClient) -> str: """Return the instance's Dataiku version, or an empty string if unavailable.""" try: diff --git a/dataiku_mcp/tools/govern.py b/dataiku_mcp/tools/govern.py new file mode 100644 index 00000000..25b27fc3 --- /dev/null +++ b/dataiku_mcp/tools/govern.py @@ -0,0 +1,2011 @@ +# Copyright 2026 Dataiku SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Single entry point to Dataiku Govern through the public Python SDK. + +One tool, ``govern``, exposes a fixed catalog of operations. Each operation +maps by hand to one ``dataikuapi.GovernClient`` call chain, so the agent never +guesses SDK methods, REST paths, or payload envelopes. An empty ``operation`` +returns the catalog itself. +""" + +from __future__ import annotations + +import hashlib +import os +import re +import tempfile +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from dataikuapi.govern.artifact_search import ( + GovernArtifactFilterArchivedStatus, + GovernArtifactFilterArtifacts, + GovernArtifactFilterBlueprints, + GovernArtifactFilterBlueprintVersions, + GovernArtifactFilterFieldValue, + GovernArtifactSearchQuery, + GovernArtifactSearchSortField, + GovernArtifactSearchSortFieldDefinition, + GovernArtifactSearchSortName, + GovernArtifactSearchSortWorkflow, +) +from dataikuapi.utils import DataikuException +from fastmcp import Context + +from ..server import mcp +from ..auth import get_govern_client +from ..executors import run_blocking +from .utils.serialization import compact_json +from .utils.validation import require_non_empty_string + +DOMAINS = ( + "artifacts", + "signoffs", + "blueprints", + "roles", + "custom_pages", + "time_series", + "files", + "users", + "instance", +) + +_CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True) +class GovernParam: + name: str + kind: str # string, boolean, integer, object, list, any + description: str + required: bool = False + path: bool = False # the SDK puts the value into the URL path + + +@dataclass(frozen=True) +class GovernOperation: + domain: str + kind: str # read, write, delete + description: str + sdk: str + params: tuple[GovernParam, ...] + run: Callable[[Any, dict[str, Any]], Any] + + +GOVERN_OPERATIONS: dict[str, GovernOperation] = {} + + +def _register( + operation_id: str, + *, + domain: str, + kind: str, + description: str, + sdk: str, + params: tuple[GovernParam, ...] = (), +): + def decorator(func): + GOVERN_OPERATIONS[operation_id] = GovernOperation( + domain, kind, description, sdk, tuple(params), func + ) + return func + + return decorator + + +def _p( + name: str, kind: str, description: str, required: bool = False, path: bool = False +) -> GovernParam: + return GovernParam(name, kind, description, required, path) + + +ARTIFACT_ID = _p( + "artifact_id", "string", "Artifact ID, for example ar.42.", True, path=True +) +STEP_ID = _p( + "step_id", "string", "Workflow step ID that carries the signoff.", True, path=True +) +BLUEPRINT_ID = _p( + "blueprint_id", + "string", + "Blueprint ID, for example bp.system.govern_project.", + True, + path=True, +) +VERSION_ID = _p( + "version_id", + "string", + "Blueprint version ID, for example bv.system.default.", + True, + path=True, +) +ROLE_ID = _p("role_id", "string", "Role ID, for example ro.reviewer.", True, path=True) +CUSTOM_PAGE_ID = _p( + "custom_page_id", "string", "Custom page ID, for example cp.1.", True, path=True +) +TIME_SERIES_ID = _p( + "time_series_id", "string", "Time series ID, for example ts.7.", True, path=True +) +UPLOADED_FILE_ID = _p( + "uploaded_file_id", "string", "Uploaded file ID, for example uf.3.", True, path=True +) +LOGIN = _p("login", "string", "User login.", True, path=True) +GROUP_NAME = _p("name", "string", "Group name.", True, path=True) +NEW_IDENTIFIER = _p( + "new_identifier", + "string", + "Bare identifier for the new object; the server adds the type prefix.", + True, + path=True, +) +USERS_CONTAINER = _p( + "users_container", + "object", + "Delegate: {type: user, login}. Delegation accepts a single user only.", + True, +) +COMMENT = _p("comment", "string", "Optional comment recorded with the decision.") + + +def _users_container(value: Any) -> dict[str, Any]: + """Validate a raw users-container dict; the SDK sends it as the request body.""" + if ( + not isinstance(value, dict) + or value.get("type") != "user" + or not value.get("login") + ): + raise ValueError( + "users_container must be {type: 'user', login: }; delegation " + "accepts a single user only" + ) + return value + + +def _replace_raw(definition, new_raw: dict[str, Any]): + """Replace a mutable SDK definition in place so ``save()`` sends ``new_raw``.""" + raw = definition.get_raw() + raw.clear() + raw.update(new_raw) + return definition + + +def _with_id(body: dict[str, Any], object_id: str) -> dict[str, Any]: + """Add the ID the operation already names to a small metadata body. + + Govern refuses a blueprint, role or custom page body without its ID. Only + these metadata bodies get it: for larger definitions the missing ID makes + Govern refuse a wrong payload instead of saving it. + """ + return {"id": object_id, **body} + + +def _version_state(version) -> dict[str, Any]: + return { + "definition": version.get_definition().get_raw(), + "trace": version.get_trace().get_raw(), + } + + +def _signoff(client, params: dict[str, Any]): + return client.get_artifact(params["artifact_id"]).get_signoff(params["step_id"]) + + +def _admin_blueprint(client, params: dict[str, Any]): + return client.get_blueprint_designer().get_blueprint(params["blueprint_id"]) + + +def _admin_version(client, params: dict[str, Any]): + return _admin_blueprint(client, params).get_version(params["version_id"]) + + +def _roles(client): + return client.get_roles_permissions_handler() + + +def _pages(client): + return client.get_custom_pages_handler() + + +# --------------------------------------------------------------------------- +# artifacts +# --------------------------------------------------------------------------- + + +_SEARCH_ID_FILTERS = ("blueprint_ids", "blueprint_version_ids", "artifact_ids") +_FIELD_FILTER_KEYS = { + "condition_type", + "condition", + "field_id", + "negate", + "case_sensitive", +} +_SORT_KEYS = {"type", "direction", "fields"} +_SORT_FIELD_KEYS = {"blueprint_id", "field_id"} + + +def _require_known_keys(value: Any, allowed: set[str], label: str) -> None: + if not isinstance(value, dict): + raise ValueError(f"Each {label} must be an object") + unknown = sorted(set(value) - allowed) + if unknown: + raise ValueError( + f"Unknown keys in {label}: {unknown}. Allowed: {sorted(allowed)}" + ) + + +@_register( + "search_artifacts", + domain="artifacts", + kind="read", + description="Search artifacts with optional blueprint, version, artifact, field and archived filters. Each hit holds id, name, blueprintVersionId and status; get_artifact returns fields and workflow.", + sdk="GovernClient.new_artifact_search_request", + params=( + _p("blueprint_ids", "list", "Blueprint IDs to keep."), + _p( + "blueprint_version_ids", + "list", + "Blueprint version IDs to keep, each {blueprintId, versionId}.", + ), + _p("artifact_ids", "list", "Artifact IDs to keep."), + _p( + "field_filters", + "list", + "Field conditions, each {condition_type: EQUALS|CONTAINS|START_WITH|END_WITH, condition, field_id, negate, case_sensitive}; no field_id filters on the name.", + ), + _p("archived", "boolean", "true includes archived artifacts."), + _p( + "sort", + "object", + "Sort: {type: name|workflow|field, direction: ASC|DESC, fields: [{blueprint_id, field_id}]}.", + ), + _p("page_size", "integer", "Batch size per request (default 20)."), + _p("max_results", "integer", "Maximum hits to return (default 100)."), + ), +) +def _search_artifacts(client, p): + # An empty ID list matches nothing; dropping the filter would match everything. + if any(p.get(key) == [] for key in _SEARCH_ID_FILTERS): + return {"count": 0, "has_more": False, "hits": []} + filters = [] + if p.get("blueprint_ids"): + filters.append(GovernArtifactFilterBlueprints(list(p["blueprint_ids"]))) + if p.get("blueprint_version_ids"): + filters.append( + GovernArtifactFilterBlueprintVersions(list(p["blueprint_version_ids"])) + ) + if p.get("artifact_ids"): + filters.append(GovernArtifactFilterArtifacts(list(p["artifact_ids"]))) + for field_filter in p.get("field_filters") or []: + if not isinstance(field_filter, dict) or not field_filter.get("condition_type"): + raise ValueError("Each field_filters entry needs a condition_type") + _require_known_keys(field_filter, _FIELD_FILTER_KEYS, "field_filters entry") + filters.append( + GovernArtifactFilterFieldValue( + field_filter["condition_type"], + condition=field_filter.get("condition"), + field_id=field_filter.get("field_id"), + negate_condition=field_filter.get("negate"), + case_sensitive=field_filter.get("case_sensitive"), + ) + ) + if p.get("archived") is not None: + filters.append(GovernArtifactFilterArchivedStatus(bool(p["archived"]))) + + sort = None + sort_spec = p.get("sort") + if sort_spec: + _require_known_keys(sort_spec, _SORT_KEYS, "sort") + sort_type = sort_spec.get("type", "name") + direction = sort_spec.get("direction", "ASC") + if sort_type == "name": + sort = GovernArtifactSearchSortName(direction) + elif sort_type == "workflow": + sort = GovernArtifactSearchSortWorkflow(direction) + elif sort_type == "field": + for field in sort_spec.get("fields") or []: + _require_known_keys(field, _SORT_FIELD_KEYS, "sort.fields entry") + # The SDK puts these into the request body as given, so build them. + fields = [ + GovernArtifactSearchSortFieldDefinition( + field["blueprint_id"], field["field_id"] + ).build() + for field in sort_spec.get("fields") or [] + ] + if not fields: + raise ValueError("sort.fields is required when sort.type is field") + sort = GovernArtifactSearchSortField(fields=fields, direction=direction) + else: + raise ValueError("sort.type must be name, workflow or field") + + page_size = int(p["page_size"]) if p.get("page_size") is not None else 20 + max_results = int(p["max_results"]) if p.get("max_results") is not None else 100 + if page_size <= 0 or max_results <= 0: + raise ValueError("page_size and max_results must be positive") + + request = client.new_artifact_search_request( + GovernArtifactSearchQuery(artifact_filters=filters, artifact_search_sort=sort) + ) + hits: list[dict[str, Any]] = [] + has_more = False + while True: + batch = request.fetch_next_batch(page_size=page_size).get_response_hits() + if not batch: + break + for hit in batch: + if len(hits) >= max_results: + has_more = True + break + # A raw hit embeds the whole blueprint version (about 20 KB). + artifact = hit.get_raw().get("artifact", {}) + hits.append( + { + key: artifact[key] + for key in ("id", "name", "blueprintVersionId", "status") + if key in artifact + } + ) + if has_more or len(batch) < page_size: + break + return {"count": len(hits), "has_more": has_more, "hits": hits} + + +@_register( + "get_artifact", + domain="artifacts", + kind="read", + description="Get an artifact definition: name, fields, workflow state and blueprint version.", + sdk="GovernArtifact.get_definition", + params=(ARTIFACT_ID,), +) +def _get_artifact(client, p): + return client.get_artifact(p["artifact_id"]).get_definition().get_raw() + + +@_register( + "create_artifact", + domain="artifacts", + kind="write", + description="Create an artifact on an ACTIVE blueprint version.", + sdk="GovernClient.create_artifact", + params=( + _p( + "blueprint_id", + "string", + "Blueprint ID. Required unless definition is given.", + ), + _p( + "version_id", + "string", + "ACTIVE blueprint version ID. Required unless definition is given.", + ), + _p("name", "string", "Artifact name. Required unless definition is given."), + _p( + "fields", + "object", + "Field values keyed by field ID; list fields take arrays.", + ), + _p( + "definition", + "object", + "Complete artifact payload; overrides the other params.", + ), + ), +) +def _create_artifact(client, p): + definition = p.get("definition") + if definition is None: + for key in ("blueprint_id", "version_id", "name"): + if not p.get(key): + raise ValueError(f"'{key}' is required when 'definition' is not given") + definition = { + "blueprintVersionId": { + "blueprintId": p["blueprint_id"], + "versionId": p["version_id"], + }, + "name": p["name"], + "fields": p.get("fields") or {}, + } + return client.create_artifact(definition).get_definition().get_raw() + + +@_register( + "update_artifact", + domain="artifacts", + kind="write", + description="Replace the complete artifact definition; read it first and send everything back.", + sdk="GovernArtifactDefinition.save", + params=( + ARTIFACT_ID, + _p( + "definition", + "object", + "Complete artifact definition from get_artifact.", + True, + ), + ), +) +def _update_artifact(client, p): + artifact = client.get_artifact(p["artifact_id"]) + _replace_raw(artifact.get_definition(), p["definition"]).save() + return artifact.get_definition().get_raw() + + +@_register( + "update_artifact_fields", + domain="artifacts", + kind="write", + description="Merge field values (and optionally the name) into an artifact, preserving the rest.", + sdk="GovernArtifactDefinition.save", + params=( + ARTIFACT_ID, + _p("fields", "object", "Field values to set, keyed by field ID.", True), + _p("name", "string", "New artifact name."), + ), +) +def _update_artifact_fields(client, p): + artifact = client.get_artifact(p["artifact_id"]) + definition = artifact.get_definition() + raw = definition.get_raw() + raw.setdefault("fields", {}).update(p["fields"]) + if p.get("name"): + raw["name"] = p["name"] + definition.save() + return artifact.get_definition().get_raw() + + +@_register( + "delete_artifact", + domain="artifacts", + kind="delete", + description="Delete an artifact.", + sdk="GovernArtifact.delete", + params=(ARTIFACT_ID,), +) +def _delete_artifact(client, p): + client.get_artifact(p["artifact_id"]).delete() + return {"deleted": p["artifact_id"]} + + +# --------------------------------------------------------------------------- +# signoffs +# --------------------------------------------------------------------------- + + +@_register( + "list_signoffs", + domain="signoffs", + kind="read", + description="List the signoffs of an artifact with their step and status.", + sdk="GovernArtifact.list_signoffs", + params=(ARTIFACT_ID,), +) +def _list_signoffs(client, p): + return [ + item.get_raw() for item in client.get_artifact(p["artifact_id"]).list_signoffs() + ] + + +@_register( + "get_signoff", + domain="signoffs", + kind="read", + description="Get a signoff definition: status, feedback responses and approver response.", + sdk="GovernArtifactSignoff.get_definition", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff(client, p): + return _signoff(client, p).get_definition().get_raw() + + +@_register( + "get_signoff_details", + domain="signoffs", + kind="read", + description="Get a signoff with reviewer and approver membership resolved to users.", + sdk="GovernArtifactSignoff.get_details", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_details(client, p): + return _signoff(client, p).get_details().get_raw() + + +@_register( + "create_signoff", + domain="signoffs", + kind="write", + description="Create the signoff of an ONGOING step whose blueprint version has a signoff configuration.", + sdk="GovernArtifact.create_signoff", + params=(ARTIFACT_ID, STEP_ID), +) +def _create_signoff(client, p): + artifact = client.get_artifact(p["artifact_id"]) + return artifact.create_signoff(p["step_id"]).get_definition().get_raw() + + +@_register( + "update_signoff_status", + domain="signoffs", + kind="write", + description="Move a signoff to NOT_STARTED, WAITING_FOR_FEEDBACK, WAITING_FOR_APPROVAL, APPROVED, REJECTED or ABANDONED.", + sdk="GovernArtifactSignoff.update_status", + params=( + ARTIFACT_ID, + STEP_ID, + _p("status", "string", "Target signoff status.", True), + _p( + "users_to_notify", + "list", + "Users to email, each {userLogin, groupId}; omit to notify every configured reviewer or approver; pass [] to notify nobody.", + ), + _p( + "reload_conf_for_reset", + "boolean", + "On NOT_STARTED, reload the configuration from the blueprint version and drop delegations.", + ), + ), +) +def _update_signoff_status(client, p): + signoff = _signoff(client, p) + signoff.update_status( + p["status"], + users_to_notify=p.get("users_to_notify"), + reload_conf_for_reset=bool(p.get("reload_conf_for_reset", False)), + ) + return signoff.get_definition().get_raw() + + +@_register( + "list_signoff_feedbacks", + domain="signoffs", + kind="read", + description="List the feedback responses recorded on a signoff.", + sdk="GovernArtifactSignoff.list_feedbacks", + params=(ARTIFACT_ID, STEP_ID), +) +def _list_signoff_feedbacks(client, p): + return [item.get_raw() for item in _signoff(client, p).list_feedbacks()] + + +FEEDBACK_ID = _p("feedback_id", "string", "Feedback response ID.", True, path=True) +FEEDBACK_STATUS = _p( + "status", "string", "Feedback status: APPROVED, MINOR_ISSUE or MAJOR_ISSUE.", True +) +APPROVAL_STATUS = _p( + "status", "string", "Approval status: APPROVED, REJECTED or ABANDONED.", True +) + + +@_register( + "get_signoff_feedback", + domain="signoffs", + kind="read", + description="Get one feedback response of a signoff.", + sdk="GovernArtifactSignoffFeedback.get_definition", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID), +) +def _get_signoff_feedback(client, p): + return _signoff(client, p).get_feedback(p["feedback_id"]).get_definition().get_raw() + + +@_register( + "add_signoff_feedback", + domain="signoffs", + kind="write", + description="Record a feedback response as the authenticated user, in a feedback group.", + sdk="GovernArtifactSignoff.add_feedback", + params=( + ARTIFACT_ID, + STEP_ID, + _p( + "group_id", + "string", + "Feedback group ID from the signoff configuration.", + True, + ), + FEEDBACK_STATUS, + COMMENT, + ), +) +def _add_signoff_feedback(client, p): + feedback = _signoff(client, p).add_feedback( + p["group_id"], p["status"], comment=p.get("comment") + ) + return feedback.get_definition().get_raw() + + +@_register( + "update_signoff_feedback", + domain="signoffs", + kind="write", + description="Change the status or comment of an existing feedback response.", + sdk="GovernArtifactSignoffFeedbackDefinition.save", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID, FEEDBACK_STATUS, COMMENT), +) +def _update_signoff_feedback(client, p): + definition = _signoff(client, p).get_feedback(p["feedback_id"]).get_definition() + raw = definition.get_raw() + raw["status"] = p["status"] + if p.get("comment") is not None: + raw["comment"] = p["comment"] + definition.save() + return definition.get_raw() + + +@_register( + "delete_signoff_feedback", + domain="signoffs", + kind="delete", + description="Delete a feedback response; the signoff must be WAITING_FOR_FEEDBACK.", + sdk="GovernArtifactSignoffFeedback.delete", + params=(ARTIFACT_ID, STEP_ID, FEEDBACK_ID), +) +def _delete_signoff_feedback(client, p): + _signoff(client, p).get_feedback(p["feedback_id"]).delete() + return {"deleted": p["feedback_id"]} + + +@_register( + "delegate_signoff_feedback", + domain="signoffs", + kind="write", + description="Add a delegated reviewer (one user) to a feedback group of a signoff.", + sdk="GovernArtifactSignoff.delegate_feedback", + params=( + ARTIFACT_ID, + STEP_ID, + _p("group_id", "string", "Feedback group ID.", True), + USERS_CONTAINER, + ), +) +def _delegate_signoff_feedback(client, p): + signoff = _signoff(client, p) + signoff.delegate_feedback(p["group_id"], _users_container(p["users_container"])) + return signoff.get_details().get_raw() + + +@_register( + "get_signoff_approval", + domain="signoffs", + kind="read", + description="Get the approver response of a signoff.", + sdk="GovernArtifactSignoffApproval.get_definition", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_approval(client, p): + return _signoff(client, p).get_approval().get_definition().get_raw() + + +@_register( + "add_signoff_approval", + domain="signoffs", + kind="write", + description="Record the final approval decision as the authenticated user.", + sdk="GovernArtifactSignoff.add_approval", + params=(ARTIFACT_ID, STEP_ID, APPROVAL_STATUS, COMMENT), +) +def _add_signoff_approval(client, p): + signoff = _signoff(client, p) + signoff.add_approval(p["status"], comment=p.get("comment")) + return signoff.get_approval().get_definition().get_raw() + + +@_register( + "update_signoff_approval", + domain="signoffs", + kind="write", + description="Change the status or comment of the recorded approval.", + sdk="GovernArtifactSignoffApprovalDefinition.save", + params=(ARTIFACT_ID, STEP_ID, APPROVAL_STATUS, COMMENT), +) +def _update_signoff_approval(client, p): + definition = _signoff(client, p).get_approval().get_definition() + raw = definition.get_raw() + raw["status"] = p["status"] + if p.get("comment") is not None: + raw["comment"] = p["comment"] + definition.save() + return definition.get_raw() + + +@_register( + "delete_signoff_approval", + domain="signoffs", + kind="delete", + description="Delete the recorded approval; the signoff must be APPROVED, REJECTED or ABANDONED.", + sdk="GovernArtifactSignoffApproval.delete", + params=(ARTIFACT_ID, STEP_ID), +) +def _delete_signoff_approval(client, p): + _signoff(client, p).get_approval().delete() + return { + "deleted": "approval", + "artifact_id": p["artifact_id"], + "step_id": p["step_id"], + } + + +@_register( + "delegate_signoff_approval", + domain="signoffs", + kind="write", + description="Add a delegated approver (one user) to a signoff.", + sdk="GovernArtifactSignoff.delegate_approval", + params=(ARTIFACT_ID, STEP_ID, USERS_CONTAINER), +) +def _delegate_signoff_approval(client, p): + signoff = _signoff(client, p) + signoff.delegate_approval(_users_container(p["users_container"])) + return signoff.get_details().get_raw() + + +@_register( + "get_signoff_recurrence", + domain="signoffs", + kind="read", + description="Get the recurrence (scheduled reset) configuration of a signoff.", + sdk="GovernArtifactSignoff.get_recurrence_configuration", + params=(ARTIFACT_ID, STEP_ID), +) +def _get_signoff_recurrence(client, p): + return _signoff(client, p).get_recurrence_configuration().get_raw() + + +@_register( + "update_signoff_recurrence", + domain="signoffs", + kind="write", + description="Replace the recurrence configuration of a signoff: {activated, days, weeks, months, years, reloadConf}.", + sdk="GovernArtifactSignoffRecurrenceConfiguration.save", + params=( + ARTIFACT_ID, + STEP_ID, + _p("configuration", "object", "Complete recurrence configuration.", True), + ), +) +def _update_signoff_recurrence(client, p): + recurrence = _signoff(client, p).get_recurrence_configuration() + _replace_raw(recurrence, p["configuration"]).save() + return recurrence.get_raw() + + +# --------------------------------------------------------------------------- +# blueprints +# --------------------------------------------------------------------------- + + +@_register( + "list_blueprints", + domain="blueprints", + kind="read", + description="List readable blueprints; each item nests the blueprint under 'blueprint'.", + sdk="GovernClient.list_blueprints", +) +def _list_blueprints(client, p): + return [item.get_raw() for item in client.list_blueprints()] + + +@_register( + "get_blueprint", + domain="blueprints", + kind="read", + description="Get a blueprint: name, icon and colors.", + sdk="GovernBlueprint.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_blueprint(client, p): + return client.get_blueprint(p["blueprint_id"]).get_definition().get_raw() + + +@_register( + "list_blueprint_versions", + domain="blueprints", + kind="read", + description="List the versions of a blueprint with their status.", + sdk="GovernBlueprint.list_versions", + params=(BLUEPRINT_ID,), +) +def _list_blueprint_versions(client, p): + return [ + item.get_raw() + for item in client.get_blueprint(p["blueprint_id"]).list_versions() + ] + + +@_register( + "get_blueprint_version", + domain="blueprints", + kind="read", + description="Get a blueprint version definition (fields, workflow, views, hooks) and its trace (status, origin).", + sdk="GovernBlueprintVersion.get_definition", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _get_blueprint_version(client, p): + version = client.get_blueprint(p["blueprint_id"]).get_version(p["version_id"]) + return _version_state(version) + + +@_register( + "create_blueprint", + domain="blueprints", + kind="write", + description="Create a blueprint (designer). The body holds name, icon, color and backgroundColor, no id.", + sdk="GovernAdminBlueprintDesigner.create_blueprint", + params=(NEW_IDENTIFIER, _p("blueprint", "object", "Blueprint metadata.", True)), +) +def _create_blueprint(client, p): + designer = client.get_blueprint_designer() + blueprint = designer.create_blueprint(p["new_identifier"], p["blueprint"]) + return blueprint.get_definition().get_raw() + + +@_register( + "update_blueprint", + domain="blueprints", + kind="write", + description="Replace the blueprint metadata (name, icon, colors); versions are edited separately.", + sdk="GovernAdminBlueprintDefinition.save", + params=( + BLUEPRINT_ID, + _p("blueprint", "object", "Complete blueprint metadata.", True), + ), +) +def _update_blueprint(client, p): + blueprint = _admin_blueprint(client, p) + _replace_raw( + blueprint.get_definition(), _with_id(p["blueprint"], p["blueprint_id"]) + ).save() + return blueprint.get_definition().get_raw() + + +@_register( + "delete_blueprint", + domain="blueprints", + kind="delete", + description="Delete a blueprint; its versions must hold no artifacts.", + sdk="GovernAdminBlueprint.delete", + params=(BLUEPRINT_ID,), +) +def _delete_blueprint(client, p): + _admin_blueprint(client, p).delete() + return {"deleted": p["blueprint_id"]} + + +@_register( + "create_blueprint_version", + domain="blueprints", + kind="write", + description="Create a DRAFT blueprint version, optionally forked from an origin version.", + sdk="GovernAdminBlueprint.create_version", + params=( + BLUEPRINT_ID, + NEW_IDENTIFIER, + _p("name", "string", "Display name of the version."), + _p( + "origin_version_id", + "string", + "Version to copy fields, workflow and views from.", + ), + ), +) +def _create_blueprint_version(client, p): + version = _admin_blueprint(client, p).create_version( + p["new_identifier"], + name=p.get("name"), + origin_version_id=p.get("origin_version_id"), + ) + return _version_state(version) + + +@_register( + "update_blueprint_version", + domain="blueprints", + kind="write", + description="Replace a blueprint version definition; danger_zone_accepted lets a field removal or type change discard artifact data.", + sdk="GovernAdminBlueprintVersionDefinition.save", + params=( + BLUEPRINT_ID, + VERSION_ID, + _p( + "definition", + "object", + "The definition object of get_blueprint_version (not the whole result), edited.", + True, + ), + _p( + "danger_zone_accepted", + "boolean", + "Accept data loss on artifacts of this version.", + ), + ), +) +def _update_blueprint_version(client, p): + if "definition" in p["definition"] and "trace" in p["definition"]: + raise ValueError( + "Pass the 'definition' object of get_blueprint_version, not the whole " + "result: saving the result would erase the version's fields and workflow." + ) + version = _admin_version(client, p) + definition = _replace_raw(version.get_definition(), p["definition"]) + definition.save(danger_zone_accepted=p.get("danger_zone_accepted")) + return _version_state(version) + + +@_register( + "set_blueprint_version_status", + domain="blueprints", + kind="write", + description="Set a blueprint version status: DRAFT, ACTIVE or ARCHIVED.", + sdk="GovernAdminBlueprintVersionTrace.set_status", + params=(BLUEPRINT_ID, VERSION_ID, _p("status", "string", "Target status.", True)), +) +def _set_blueprint_version_status(client, p): + version = _admin_version(client, p) + version.get_trace().set_status(p["status"]) + return version.get_trace().get_raw() + + +@_register( + "delete_blueprint_version", + domain="blueprints", + kind="delete", + description="Delete a blueprint version; it must hold no artifacts.", + sdk="GovernAdminBlueprintVersion.delete", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _delete_blueprint_version(client, p): + _admin_version(client, p).delete() + return {"deleted": p["version_id"], "blueprint_id": p["blueprint_id"]} + + +@_register( + "list_signoff_configurations", + domain="blueprints", + kind="read", + description="List the signoff configurations (review gates) of a blueprint version.", + sdk="GovernAdminBlueprintVersion.list_signoff_configurations", + params=(BLUEPRINT_ID, VERSION_ID), +) +def _list_signoff_configurations(client, p): + return [ + item.get_raw() + for item in _admin_version(client, p).list_signoff_configurations() + ] + + +@_register( + "get_signoff_configuration", + domain="blueprints", + kind="read", + description="Get the signoff configuration of one workflow step.", + sdk="GovernAdminSignoffConfiguration.get_definition", + params=(BLUEPRINT_ID, VERSION_ID, STEP_ID), +) +def _get_signoff_configuration(client, p): + configuration = _admin_version(client, p).get_signoff_configuration(p["step_id"]) + return configuration.get_definition().get_raw() + + +@_register( + "create_signoff_configuration", + domain="blueprints", + kind="write", + description="Create a review gate on a workflow step: {title, mandatory, feedbackUsersGroups, approvers, recurrenceConfiguration}, no id.", + sdk="GovernAdminBlueprintVersion.create_signoff_configuration", + params=( + BLUEPRINT_ID, + VERSION_ID, + STEP_ID, + _p("configuration", "object", "Signoff configuration without an id.", True), + ), +) +def _create_signoff_configuration(client, p): + configuration = _admin_version(client, p).create_signoff_configuration( + p["step_id"], p["configuration"] + ) + return configuration.get_definition().get_raw() + + +@_register( + "update_signoff_configuration", + domain="blueprints", + kind="write", + description="Replace the signoff configuration of a workflow step.", + sdk="GovernAdminSignoffConfigurationDefinition.save", + params=( + BLUEPRINT_ID, + VERSION_ID, + STEP_ID, + _p("configuration", "object", "Complete signoff configuration.", True), + ), +) +def _update_signoff_configuration(client, p): + configuration = _admin_version(client, p).get_signoff_configuration(p["step_id"]) + _replace_raw(configuration.get_definition(), p["configuration"]).save() + return configuration.get_definition().get_raw() + + +@_register( + "delete_signoff_configuration", + domain="blueprints", + kind="delete", + description="Delete the signoff configuration of a workflow step.", + sdk="GovernAdminSignoffConfiguration.delete", + params=(BLUEPRINT_ID, VERSION_ID, STEP_ID), +) +def _delete_signoff_configuration(client, p): + _admin_version(client, p).get_signoff_configuration(p["step_id"]).delete() + return { + "deleted": p["step_id"], + "blueprint_id": p["blueprint_id"], + "version_id": p["version_id"], + } + + +# --------------------------------------------------------------------------- +# roles +# --------------------------------------------------------------------------- + + +@_register( + "list_roles", + domain="roles", + kind="read", + description="List roles.", + sdk="GovernAdminRolesPermissionsHandler.list_roles", +) +def _list_roles(client, p): + return [item.get_raw() for item in _roles(client).list_roles()] + + +@_register( + "get_role", + domain="roles", + kind="read", + description="Get a role definition.", + sdk="GovernAdminRole.get_definition", + params=(ROLE_ID,), +) +def _get_role(client, p): + return _roles(client).get_role(p["role_id"]).get_definition().get_raw() + + +@_register( + "create_role", + domain="roles", + kind="write", + description="Create a role: {label, description}.", + sdk="GovernAdminRolesPermissionsHandler.create_role", + params=( + NEW_IDENTIFIER, + _p("role", "object", "Role definition without an id.", True), + ), +) +def _create_role(client, p): + return ( + _roles(client) + .create_role(p["new_identifier"], p["role"]) + .get_definition() + .get_raw() + ) + + +@_register( + "update_role", + domain="roles", + kind="write", + description="Replace a role definition.", + sdk="GovernAdminRoleDefinition.save", + params=(ROLE_ID, _p("role", "object", "Complete role definition.", True)), +) +def _update_role(client, p): + role = _roles(client).get_role(p["role_id"]) + _replace_raw(role.get_definition(), _with_id(p["role"], p["role_id"])).save() + return role.get_definition().get_raw() + + +@_register( + "delete_role", + domain="roles", + kind="delete", + description="Delete a role.", + sdk="GovernAdminRole.delete", + params=(ROLE_ID,), +) +def _delete_role(client, p): + _roles(client).get_role(p["role_id"]).delete() + return {"deleted": p["role_id"]} + + +@_register( + "list_role_assignments", + domain="roles", + kind="read", + description="List the role assignments of every blueprint.", + sdk="GovernAdminRolesPermissionsHandler.list_role_assignments", +) +def _list_role_assignments(client, p): + return [item.get_raw() for item in _roles(client).list_role_assignments()] + + +@_register( + "get_role_assignments", + domain="roles", + kind="read", + description="Get the role assignments of a blueprint: roleAssignmentsRules keyed by role ID.", + sdk="GovernAdminBlueprintRoleAssignments.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_role_assignments(client, p): + return ( + _roles(client) + .get_role_assignments(p["blueprint_id"]) + .get_definition() + .get_raw() + ) + + +@_register( + "create_role_assignments", + domain="roles", + kind="write", + description="Create the role assignments of a blueprint: {blueprintId, roleAssignmentsRules}.", + sdk="GovernAdminRolesPermissionsHandler.create_role_assignments", + params=( + _p( + "role_assignments", + "object", + "Role assignments including blueprintId.", + True, + ), + ), +) +def _create_role_assignments(client, p): + assignments = _roles(client).create_role_assignments(p["role_assignments"]) + return assignments.get_definition().get_raw() + + +@_register( + "update_role_assignments", + domain="roles", + kind="write", + description="Replace the role assignments of a blueprint; preserve the other roles' rules.", + sdk="GovernAdminBlueprintRoleAssignmentsDefinition.save", + params=( + BLUEPRINT_ID, + _p("role_assignments", "object", "Complete role assignments.", True), + ), +) +def _update_role_assignments(client, p): + assignments = _roles(client).get_role_assignments(p["blueprint_id"]) + _replace_raw(assignments.get_definition(), p["role_assignments"]).save() + return assignments.get_definition().get_raw() + + +@_register( + "delete_role_assignments", + domain="roles", + kind="delete", + description="Delete every role assignment of a blueprint.", + sdk="GovernAdminBlueprintRoleAssignments.delete", + params=(BLUEPRINT_ID,), +) +def _delete_role_assignments(client, p): + _roles(client).get_role_assignments(p["blueprint_id"]).delete() + return {"deleted": "role_assignments", "blueprint_id": p["blueprint_id"]} + + +@_register( + "get_default_blueprint_permissions", + domain="roles", + kind="read", + description="Get the default permissions applied to blueprints without their own.", + sdk="GovernAdminRolesPermissionsHandler.get_default_permissions_definition", +) +def _get_default_blueprint_permissions(client, p): + return _roles(client).get_default_permissions_definition().get_raw() + + +@_register( + "update_default_blueprint_permissions", + domain="roles", + kind="write", + description="Replace the default blueprint permissions.", + sdk="GovernAdminDefaultPermissionsDefinition.save", + params=(_p("permissions", "object", "Complete default permissions.", True),), +) +def _update_default_blueprint_permissions(client, p): + definition = _roles(client).get_default_permissions_definition() + _replace_raw(definition, p["permissions"]).save() + return _roles(client).get_default_permissions_definition().get_raw() + + +@_register( + "list_blueprint_permissions", + domain="roles", + kind="read", + description="List the blueprints that carry their own permissions.", + sdk="GovernAdminRolesPermissionsHandler.list_blueprint_permissions", +) +def _list_blueprint_permissions(client, p): + return [item.get_raw() for item in _roles(client).list_blueprint_permissions()] + + +@_register( + "get_blueprint_permissions", + domain="roles", + kind="read", + description="Get the permissions of a blueprint.", + sdk="GovernAdminBlueprintPermissions.get_definition", + params=(BLUEPRINT_ID,), +) +def _get_blueprint_permissions(client, p): + return ( + _roles(client) + .get_blueprint_permissions(p["blueprint_id"]) + .get_definition() + .get_raw() + ) + + +@_register( + "create_blueprint_permissions", + domain="roles", + kind="write", + description="Create the permissions of a blueprint: {blueprintId, permissions}.", + sdk="GovernAdminRolesPermissionsHandler.create_blueprint_permissions", + params=( + _p( + "blueprint_permissions", + "object", + "Permissions including blueprintId.", + True, + ), + ), +) +def _create_blueprint_permissions(client, p): + permissions = _roles(client).create_blueprint_permissions( + p["blueprint_permissions"] + ) + return permissions.get_definition().get_raw() + + +@_register( + "update_blueprint_permissions", + domain="roles", + kind="write", + description="Replace the permissions of a blueprint.", + sdk="GovernAdminBlueprintPermissionsDefinition.save", + params=( + BLUEPRINT_ID, + _p("blueprint_permissions", "object", "Complete permissions.", True), + ), +) +def _update_blueprint_permissions(client, p): + permissions = _roles(client).get_blueprint_permissions(p["blueprint_id"]) + _replace_raw(permissions.get_definition(), p["blueprint_permissions"]).save() + return permissions.get_definition().get_raw() + + +@_register( + "delete_blueprint_permissions", + domain="roles", + kind="delete", + description="Delete the permissions of a blueprint so the defaults apply again.", + sdk="GovernAdminBlueprintPermissions.delete", + params=(BLUEPRINT_ID,), +) +def _delete_blueprint_permissions(client, p): + _roles(client).get_blueprint_permissions(p["blueprint_id"]).delete() + return {"deleted": "blueprint_permissions", "blueprint_id": p["blueprint_id"]} + + +# --------------------------------------------------------------------------- +# custom pages +# --------------------------------------------------------------------------- + + +@_register( + "list_custom_pages", + domain="custom_pages", + kind="read", + description="List the custom pages visible to the authenticated user.", + sdk="GovernClient.list_custom_pages", +) +def _list_custom_pages(client, p): + return [item.get_raw() for item in client.list_custom_pages()] + + +@_register( + "get_custom_page", + domain="custom_pages", + kind="read", + description="Get a custom page as the authenticated user sees it.", + sdk="GovernCustomPage.get_definition", + params=(CUSTOM_PAGE_ID,), +) +def _get_custom_page(client, p): + return client.get_custom_page(p["custom_page_id"]).get_definition().get_raw() + + +@_register( + "get_custom_page_definition", + domain="custom_pages", + kind="read", + description="Get the editable definition of a custom page (designer).", + sdk="GovernAdminCustomPage.get_definition", + params=(CUSTOM_PAGE_ID,), +) +def _get_custom_page_definition(client, p): + return ( + _pages(client).get_custom_page(p["custom_page_id"]).get_definition().get_raw() + ) + + +@_register( + "create_custom_page", + domain="custom_pages", + kind="write", + description="Create a custom page; copy the definition shape from an existing page of the same type.", + sdk="GovernAdminCustomPagesHandler.create_custom_page", + params=( + NEW_IDENTIFIER, + _p("custom_page", "object", "Custom page definition without an id.", True), + ), +) +def _create_custom_page(client, p): + page = _pages(client).create_custom_page(p["new_identifier"], p["custom_page"]) + return page.get_definition().get_raw() + + +@_register( + "update_custom_page", + domain="custom_pages", + kind="write", + description="Replace a custom page definition.", + sdk="GovernAdminCustomPageDefinition.save", + params=( + CUSTOM_PAGE_ID, + _p("custom_page", "object", "Complete custom page definition.", True), + ), +) +def _update_custom_page(client, p): + page = _pages(client).get_custom_page(p["custom_page_id"]) + _replace_raw( + page.get_definition(), _with_id(p["custom_page"], p["custom_page_id"]) + ).save() + return page.get_definition().get_raw() + + +@_register( + "delete_custom_page", + domain="custom_pages", + kind="delete", + description="Delete a custom page.", + sdk="GovernAdminCustomPage.delete", + params=(CUSTOM_PAGE_ID,), +) +def _delete_custom_page(client, p): + _pages(client).get_custom_page(p["custom_page_id"]).delete() + return {"deleted": p["custom_page_id"]} + + +@_register( + "get_custom_pages_order", + domain="custom_pages", + kind="read", + description="Get the display order of the custom pages as a list of IDs.", + sdk="GovernAdminCustomPagesHandler.get_custom_pages_order", +) +def _get_custom_pages_order(client, p): + return _pages(client).get_custom_pages_order() + + +@_register( + "update_custom_pages_order", + domain="custom_pages", + kind="write", + description="Set the display order of the custom pages; the list must contain every page ID.", + sdk="GovernAdminCustomPagesHandler.save_custom_pages_order", + params=(_p("order", "list", "Custom page IDs in display order.", True),), +) +def _update_custom_pages_order(client, p): + return _pages(client).save_custom_pages_order(list(p["order"])) + + +# --------------------------------------------------------------------------- +# time series +# --------------------------------------------------------------------------- + +DATAPOINTS = _p( + "datapoints", + "list", + "Datapoints, each {timestamp: epoch milliseconds, value: number}.", +) +MIN_TIMESTAMP = _p("min_timestamp", "integer", "Lower bound, epoch milliseconds.") +MAX_TIMESTAMP = _p("max_timestamp", "integer", "Upper bound, epoch milliseconds.") + + +@_register( + "create_time_series", + domain="time_series", + kind="write", + description="Create a time series, optionally with initial datapoints; returns its ID.", + sdk="GovernClient.create_time_series", + params=(DATAPOINTS,), +) +def _create_time_series(client, p): + datapoints = list(p.get("datapoints") or []) + time_series = client.create_time_series(datapoints=datapoints) + return {"time_series_id": time_series.time_series_id, "datapoints": len(datapoints)} + + +@_register( + "get_time_series_values", + domain="time_series", + kind="read", + description="Get the datapoints of a time series, optionally within a timestamp window.", + sdk="GovernTimeSeries.get_values", + params=(TIME_SERIES_ID, MIN_TIMESTAMP, MAX_TIMESTAMP), +) +def _get_time_series_values(client, p): + return client.get_time_series(p["time_series_id"]).get_values( + min_timestamp=p.get("min_timestamp"), max_timestamp=p.get("max_timestamp") + ) + + +@_register( + "push_time_series_values", + domain="time_series", + kind="write", + description="Push datapoints into a time series; upsert overwrites existing timestamps.", + sdk="GovernTimeSeries.push_values", + params=( + TIME_SERIES_ID, + _p("datapoints", "list", "Datapoints, each {timestamp, value}.", True), + _p("upsert", "boolean", "Overwrite existing timestamps (default true)."), + ), +) +def _push_time_series_values(client, p): + datapoints = list(p["datapoints"]) + client.get_time_series(p["time_series_id"]).push_values( + datapoints, upsert=p.get("upsert") is not False + ) + return {"time_series_id": p["time_series_id"], "pushed": len(datapoints)} + + +@_register( + "delete_time_series_values", + domain="time_series", + kind="delete", + description="Delete datapoints of a time series within a window, or all of them without bounds.", + sdk="GovernTimeSeries.delete", + params=(TIME_SERIES_ID, MIN_TIMESTAMP, MAX_TIMESTAMP), +) +def _delete_time_series_values(client, p): + client.get_time_series(p["time_series_id"]).delete( + min_timestamp=p.get("min_timestamp"), max_timestamp=p.get("max_timestamp") + ) + return { + "time_series_id": p["time_series_id"], + "deleted": True, + "min_timestamp": p.get("min_timestamp"), + "max_timestamp": p.get("max_timestamp"), + } + + +# --------------------------------------------------------------------------- +# files +# --------------------------------------------------------------------------- + + +@_register( + "upload_file", + domain="files", + kind="write", + description="Upload a local file; attach the returned ID to an UPLOADED_FILE field afterwards.", + sdk="GovernClient.upload_file", + params=( + _p("file_path", "string", "Local path of the file to upload.", True), + _p( + "file_name", + "string", + "Name stored in Govern (default: the local file name).", + ), + ), +) +def _upload_file(client, p): + path = os.path.realpath( + os.path.expanduser(require_non_empty_string(p["file_path"], "file_path")) + ) + if not os.path.isfile(path): + raise FileNotFoundError(f"File not found: {path}") + file_name = p.get("file_name") or os.path.basename(path) + with open(path, "rb") as handle: + uploaded = client.upload_file(file_name, handle) + return uploaded.get_description() + + +@_register( + "get_uploaded_file", + domain="files", + kind="read", + description="Get the description (name, size, type) of an uploaded file.", + sdk="GovernUploadedFile.get_description", + params=(UPLOADED_FILE_ID,), +) +def _get_uploaded_file(client, p): + return client.get_uploaded_file(p["uploaded_file_id"]).get_description() + + +@_register( + "download_uploaded_file", + domain="files", + kind="write", + description="Download an uploaded file to a local path; never overwrites unless overwrite is true.", + sdk="GovernUploadedFile.download", + params=( + UPLOADED_FILE_ID, + _p("output_path", "string", "Local destination path.", True), + _p("overwrite", "boolean", "Replace an existing destination file."), + ), +) +def _download_uploaded_file(client, p): + output_path = require_non_empty_string(p["output_path"], "output_path") + overwrite = bool(p.get("overwrite", False)) + absolute_path = os.path.realpath(os.path.expanduser(output_path)) + parent = os.path.dirname(absolute_path) + if not os.path.isdir(parent): + raise FileNotFoundError(f"Destination directory does not exist: {parent}") + if os.path.lexists(absolute_path) and not overwrite: + raise FileExistsError( + f"Destination already exists: {absolute_path}. Set overwrite=true to replace it." + ) + uploaded = client.get_uploaded_file(p["uploaded_file_id"]) + description = uploaded.get_description() + stream = uploaded.download() + temporary_path = None + digest = hashlib.sha256() + size = 0 + try: + with tempfile.NamedTemporaryFile( + prefix=".govern-file-", suffix=".tmp", dir=parent, delete=False + ) as handle: + temporary_path = handle.name + while True: + # The raw stream keeps any gzip encoding unless asked to decode. + chunk = stream.read(_CHUNK_SIZE, decode_content=True) + if not chunk: + break + handle.write(chunk) + digest.update(chunk) + size += len(chunk) + if os.path.lexists(absolute_path) and not overwrite: + raise FileExistsError( + f"Destination already exists: {absolute_path}. Set overwrite=true to replace it." + ) + os.replace(temporary_path, absolute_path) + temporary_path = None + finally: + close = getattr(stream, "close", None) + if close is not None: + close() + if temporary_path is not None: + try: + os.unlink(temporary_path) + except FileNotFoundError: + pass + return { + "uploaded_file_id": p["uploaded_file_id"], + "output_path": absolute_path, + "size_bytes": size, + "sha256": digest.hexdigest(), + "description": description, + } + + +@_register( + "delete_uploaded_file", + domain="files", + kind="delete", + description="Delete an uploaded file.", + sdk="GovernUploadedFile.delete", + params=(UPLOADED_FILE_ID,), +) +def _delete_uploaded_file(client, p): + client.get_uploaded_file(p["uploaded_file_id"]).delete() + return {"deleted": p["uploaded_file_id"]} + + +# --------------------------------------------------------------------------- +# users and groups +# --------------------------------------------------------------------------- + + +@_register( + "list_users", + domain="users", + kind="read", + description="List Govern users; include_settings adds each user's full settings.", + sdk="GovernClient.list_users", + params=(_p("include_settings", "boolean", "Include full settings per user."),), +) +def _list_users(client, p): + return client.list_users( + as_objects=False, include_settings=bool(p.get("include_settings", False)) + ) + + +@_register( + "get_user", + domain="users", + kind="read", + description="Get a user's settings (admin).", + sdk="GovernUser.get_settings", + params=(LOGIN,), +) +def _get_user(client, p): + return client.get_user(p["login"]).get_settings().get_raw() + + +@_register( + "create_user", + domain="users", + kind="write", + description="Create a Govern user (admin).", + sdk="GovernClient.create_user", + params=( + LOGIN, + _p("password", "string", "Password; required for LOCAL users."), + _p("display_name", "string", "Display name."), + _p( + "source_type", + "string", + "LOCAL (default), LDAP, or another identity source.", + ), + _p("groups", "list", "Group names."), + _p( + "profile", + "string", + "User profile. When omitted, the SDK sends DATA_SCIENTIST; the tool does " + "not check it against the license.", + ), + _p("email", "string", "Email address."), + ), +) +def _create_user(client, p): + kwargs: dict[str, Any] = {} + for key in ("display_name", "source_type", "groups", "profile", "email"): + if p.get(key) is not None: + kwargs[key] = p[key] + user = client.create_user(p["login"], p.get("password"), **kwargs) + return user.get_settings().get_raw() + + +@_register( + "update_user", + domain="users", + kind="write", + description="Merge top-level keys (displayName, email, groups, enabled, userProfile, ...) into a user's settings.", + sdk="GovernUserSettings.save", + params=(LOGIN, _p("settings", "object", "Settings keys to set.", True)), +) +def _update_user(client, p): + user = client.get_user(p["login"]) + settings = user.get_settings() + settings.get_raw().update(p["settings"]) + settings.save() + return user.get_settings().get_raw() + + +@_register( + "delete_user", + domain="users", + kind="delete", + description="Delete a Govern user (admin).", + sdk="GovernUser.delete", + params=( + LOGIN, + _p("allow_self_deletion", "boolean", "Allow deleting the authenticated user."), + ), +) +def _delete_user(client, p): + client.get_user(p["login"]).delete( + allow_self_deletion=bool(p.get("allow_self_deletion", False)) + ) + return {"deleted": p["login"]} + + +@_register( + "get_own_user", + domain="users", + kind="read", + description="Get the settings of the authenticated user.", + sdk="GovernOwnUser.get_settings", +) +def _get_own_user(client, p): + return client.get_own_user().get_settings().get_raw() + + +@_register( + "list_groups", + domain="users", + kind="read", + description="List Govern groups (admin).", + sdk="GovernClient.list_groups", +) +def _list_groups(client, p): + return client.list_groups() + + +@_register( + "get_group", + domain="users", + kind="read", + description="Get a group definition (admin).", + sdk="GovernGroup.get_definition", + params=(GROUP_NAME,), +) +def _get_group(client, p): + return client.get_group(p["name"]).get_definition() + + +@_register( + "create_group", + domain="users", + kind="write", + description="Create a Govern group (admin).", + sdk="GovernClient.create_group", + params=( + GROUP_NAME, + _p("description", "string", "Group description."), + _p("source_type", "string", "LOCAL (default) or LDAP."), + ), +) +def _create_group(client, p): + kwargs: dict[str, Any] = {} + for key in ("description", "source_type"): + if p.get(key) is not None: + kwargs[key] = p[key] + return client.create_group(p["name"], **kwargs).get_definition() + + +@_register( + "update_group", + domain="users", + kind="write", + description="Merge top-level keys (description, isGovernArchitect, ...) into a group definition.", + sdk="GovernGroup.set_definition", + params=(GROUP_NAME, _p("definition", "object", "Definition keys to set.", True)), +) +def _update_group(client, p): + group = client.get_group(p["name"]) + definition = group.get_definition() + definition.update(p["definition"]) + group.set_definition(definition) + return group.get_definition() + + +@_register( + "delete_group", + domain="users", + kind="delete", + description="Delete a Govern group (admin).", + sdk="GovernGroup.delete", + params=(GROUP_NAME,), +) +def _delete_group(client, p): + client.get_group(p["name"]).delete() + return {"deleted": p["name"]} + + +# --------------------------------------------------------------------------- +# instance +# --------------------------------------------------------------------------- + + +@_register( + "get_instance_info", + domain="instance", + kind="read", + description="Get the Govern node identity and version; confirms the URL targets a GOVERN node.", + sdk="GovernClient.get_instance_info", +) +def _get_instance_info(client, p): + return client.get_instance_info().raw + + +@_register( + "get_auth_info", + domain="instance", + kind="read", + description="Get the identity behind the configured API key.", + sdk="GovernClient.get_auth_info", +) +def _get_auth_info(client, p): + return client.get_auth_info() + + +# --------------------------------------------------------------------------- +# dispatcher +# --------------------------------------------------------------------------- + +_KIND_CHECKS: dict[str, Callable[[Any], bool]] = { + "string": lambda value: isinstance(value, str), + "boolean": lambda value: isinstance(value, bool), + "integer": lambda value: isinstance(value, int) and not isinstance(value, bool), + "object": lambda value: isinstance(value, dict), + "list": lambda value: isinstance(value, list), + "any": lambda value: True, +} + + +def _validate_params( + operation_id: str, operation: GovernOperation, params: dict[str, Any] +) -> None: + declared = {param.name: param for param in operation.params} + unexpected = sorted(set(params) - set(declared)) + if unexpected: + raise ValueError( + f"Unexpected params for '{operation_id}': {unexpected}. " + f"Allowed: {list(declared)}" + ) + missing = [ + param.name + for param in operation.params + if param.required and params.get(param.name) in (None, "") + ] + if missing: + raise ValueError(f"Missing required params for '{operation_id}': {missing}") + for name, value in params.items(): + if value is None: + continue + param = declared[name] + if not _KIND_CHECKS[param.kind](value): + raise ValueError(f"Param '{name}' must be a {param.kind}") + if param.kind == "string" and param.required: + value = require_non_empty_string(value, name) + if param.path: + if value in (".", "..") or any(char in value for char in "/\\?#%"): + raise ValueError( + f"Param '{name}' must be a plain identifier, without '/', '\\', " + "'?', '#' or '%'" + ) + params[name] = value + + +def _operation_summary(operation_id: str, operation: GovernOperation) -> dict[str, Any]: + return { + "operation": operation_id, + "domain": operation.domain, + "kind": operation.kind, + "description": operation.description, + "sdk": operation.sdk, + "params": [ + { + "name": param.name, + "kind": param.kind, + "required": param.required, + "description": param.description, + } + for param in operation.params + ], + } + + +def _catalog(domain: str) -> str: + if domain and domain not in DOMAINS: + raise ValueError( + f"Unknown Govern domain '{domain}'. Available: {list(DOMAINS)}" + ) + operations = [ + _operation_summary(operation_id, operation) + for operation_id, operation in GOVERN_OPERATIONS.items() + if not domain or operation.domain == domain + ] + return compact_json( + {"count": len(operations), "domains": list(DOMAINS), "operations": operations} + ) + + +_verified_hosts: set[str] = set() + + +def _require_govern_node(client) -> None: + """Check once per host that the URL targets a GOVERN node. + + Only a confirmed GOVERN answer is cached. When `/instance-info` fails, the + check runs again on the next call and the operation reports its own error. + """ + host = getattr(client, "host", "") + if host in _verified_hosts: + return + try: + node_type = client.get_instance_info().node_type + except Exception: + return + if node_type != "GOVERN": + raise ValueError( + f"The active instance URL {host} points to a {node_type} node, not a " + "Govern node. Check the URL and instance_type of the active instance." + ) + _verified_hosts.add(host) + + +@mcp.tool( + title="Govern", + description="Single entry point to Dataiku Govern. Leave operation empty to list the catalog; otherwise run one catalog operation through the Govern Python SDK.", + annotations={ + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, +) +async def govern( + ctx: Context, + operation: str = "", + params: dict[str, Any] | None = None, + domain: str = "", +) -> str: + """Run one Govern operation, or list the catalog when ``operation`` is empty. + + Args: + operation: Operation id from the catalog, for example ``get_artifact``. + Leave empty to get the catalog. + params: Flat JSON object with the operation's parameters: identifiers, + payloads and options, as listed in the catalog. + domain: Optional catalog filter: artifacts, signoffs, blueprints, roles, + custom_pages, time_series, files, users or instance. + """ + operation = (operation or "").strip() + domain = (domain or "").strip() + params = dict(params or {}) + if not operation: + if params: + raise ValueError("params are only valid with an operation") + return _catalog(domain) + if domain: + raise ValueError("domain is only valid when listing the catalog") + try: + spec = GOVERN_OPERATIONS[operation] + except KeyError: + raise ValueError( + f"Unknown Govern operation '{operation}'. Call govern with an empty " + "operation to list the catalog." + ) from None + _validate_params(operation, spec, params) + await ctx.info(f"Running Govern operation '{operation}'...") + + def _run(): + client = get_govern_client() + _require_govern_node(client) + try: + return spec.run(client, params) + except DataikuException as err: + # A proxy answers with an HTML page; its title is the only status left. + message = str(err) + if "(.*?)", message, re.I | re.S) + title = " ".join(title.group(1).split()) if title else "no title" + raise ValueError( + f"Govern answered '{operation}' with an HTML error page " + f"('{title}'), not an API error. On Dataiku Cloud, 'Dataiku " + "instance not found' means the object does not exist or this " + "API key cannot see it; another title is a proxy or server error." + ) from None + raise + + return compact_json(await run_blocking(_run)) diff --git a/docs/capabilities.md b/docs/capabilities.md index 1e79feb5..3a518d9b 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -24,13 +24,13 @@ instance the local client targets. ## Surface -**129 tools** · 93 read · 22 direct Dataiku write · 6 Cobuild · 4 execute · 3 local +**130 tools** · 93 read · 23 direct Dataiku write · 6 Cobuild · 4 execute · 3 local profile · 1 connection test | Bucket | # | Scope | |---|---|---| | Read / inspect | 93 | Never mutates | -| Direct Dataiku write | 22 | Bootstrap, project configuration, cross-project, admin | +| Direct Dataiku write | 23 | Bootstrap, project configuration, cross-project, admin, Govern | | Cobuild conversation | 6 | All flow and analytic building | | Execute | 4 | `build_datasets`, `run_recipe`, `run_scenario`, `abort_job` | | Local profile action | 3 | `configure_instance`, `switch_instance`, `delete_instance` | @@ -95,6 +95,7 @@ No Cobuild involved. Scope says what kind of access, and where a write lands. | Connections | `list_connections`, `get_connection_info`, `test_connection` | — | Read-only | | Instance settings | `list_container_exec_configs`, `list_spark_configs`, `get_licensing_status` | — | Read-only | | Data collections & sharing | `list_data_collections`, `list_data_collection_objects`, `list_shared_objects` | — | Read-only | +| Govern | `govern` (catalog and read operations) | `govern` (write and delete operations) | Instance of type `govern` — stdio only | The **in-project** writes above configure or supply a project; they do not build its analytic logic. None of them build recipe logic, a model, or an agent. diff --git a/skills/dataiku-headless/SKILL.md b/skills/dataiku-headless/SKILL.md index 93367986..bc32d025 100644 --- a/skills/dataiku-headless/SKILL.md +++ b/skills/dataiku-headless/SKILL.md @@ -1,16 +1,18 @@ --- name: dataiku-headless -description: Use for Dataiku tasks including projects, flows, datasets, recipes, jobs, machine learning, agents, and Cobuild conversations. Choose the right reference guide, inspect state first, route project changes through Cobuild by default, and validate results after execution. +description: Use for Dataiku tasks including projects, flows, datasets, recipes, jobs, machine learning, agents, Cobuild conversations, and Govern. Choose the right reference guide, inspect state first, route project changes through Cobuild by default, use the govern tool for Govern, and validate results after execution. --- # Dataiku Headless Use this for any Dataiku task. Choose the right reference guide first, inspect the current state before acting, route project changes through Cobuild by default, and validate by re-reading the resulting state. +For **Dataiku Govern**, start with [Govern](./references/govern.md). Govern runs on its own node and is reached through the single `govern` tool, which uses an instance of type `govern`. + ## Shared Operating Rules 1. If the user asks to install, set up, connect, or repair Dataiku Headless, or its MCP tools are unavailable just after installation, read `../dataiku-headless-setup/SKILL.md`. Choose either local stdio or customer-managed HTTP setup and never enable both. -2. Ensure an instance is configured before any Dataiku work. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. The required `instance_type` is configured metadata, not API verification or a guarantee of client support. +2. Ensure an instance is configured before any Dataiku work. In local stdio mode, if `get_current_instance` errors or `list_instances` is empty, run `configure_instance` first. In HTTP mode, use `list_instances` then `switch_instance`; the instance catalog is platform-managed. Keep the reported `dataiku_version` in context for version-sensitive requests. The required `instance_type` is configured metadata, not API verification or a guarantee of client support. Govern work also needs an instance of type `govern`; its guide says how. 3. Discover project keys and object identifiers through tools; do not invent them. 4. Read before write. Inspect the current object, flow context, jobs, or run history before changing anything. 5. Treat the matching reference guide as the source of truth for object-specific concepts, inspection steps, and required references. @@ -36,6 +38,7 @@ Use this for any Dataiku task. Choose the right reference guide first, inspect t | User intent | Guide to read next | | --- | --- | +| Work with Dataiku Govern artifacts, blueprints, signoffs, roles, custom pages, users, files, or time series | `./references/govern.md` | | Discover projects, inspect project metadata or variables, orient in a flow, or create a new project | `./references/projects.md` | | Inspect or update project settings, including Flow display, pipelines, default code envs, project-level container execution defaults, or Cobuild project instructions | `./references/projects/settings.md` | | Inspect the instance project-folder hierarchy or organize projects into project folders | `./references/project-folders.md` | diff --git a/skills/dataiku-headless/references/govern.md b/skills/dataiku-headless/references/govern.md new file mode 100644 index 00000000..1f110323 --- /dev/null +++ b/skills/dataiku-headless/references/govern.md @@ -0,0 +1,153 @@ +--- +name: govern +description: Operate Dataiku Govern through the single govern tool, with task guides for artifacts, blueprints, signoffs, and supporting objects. +--- + +# Govern + +Govern runs on a separate node. Its records are artifacts described by blueprint +versions, with workflow steps and signoffs. A governed project is an artifact; +it is not a DSS project containing datasets and recipes. + +Use the `govern` tool for every Govern read and write. It wraps the public Govern +Python SDK behind a fixed catalog of 86 operations. Do not fall back to Python, +`dataikuapi`, or REST calls; if an operation is missing, report the gap. +DSS project work still uses the other MCP tools and Cobuild. + +## The tool + +`govern(operation, params, domain)`: + +- Empty `operation` returns the catalog: every operation with its domain, kind + (`read`, `write`, `delete`), parameters, and the SDK method it wraps. Set + `domain` to filter: `artifacts`, `signoffs`, `blueprints`, `roles`, + `custom_pages`, `time_series`, `files`, `users`, `instance`. +- `operation` plus `params` runs one operation. `params` is one flat JSON object + holding identifiers, payloads, and options exactly as the catalog names them. + Unknown keys and missing required keys are rejected before any call. +- Results are the SDK raw dictionaries: definitions, list items, or a small + status object for deletions. + +## Connect + +The Govern node is a configured instance of type `govern`, with its own URL and +API key. The tool uses the active instance and refuses an instance of another +type. `list_instances` shows the type of each instance; when none has type +`govern`, run `configure_instance` and choose the Govern type. + +The tool works only in local stdio mode. A Govern node accepts API keys, not the +delegated tokens that the HTTP mode uses, so in HTTP mode the tool returns an +error; report it and do not look for another way in. + +Start a Govern task with `get_instance_info`: it confirms the node type is +`GOVERN` and reports the server version. Then `get_auth_info` tells which identity +signs the work. A global API key has no user behind it: `get_own_user` fails and +decisions are recorded under the key identity; a personal API key records them +under that user. If the tool reports a missing Govern configuration or a +permission error, report that specific blocker; do not repurpose DSS credentials. + +Blueprint design and custom page administration need the Govern Blueprint Designer +license option. Without it the server answers "Your license does not allow you to +use the Govern Blueprint Designer" for `create_blueprint`, version and signoff +configuration writes, and custom page writes and ordering. Report that as a +licensing blocker, not as a tool gap. + +## Workflow + +1. Call `govern` with an empty `operation` for the relevant `domain` when the + parameters of an operation are not in context. Never guess an operation id. +2. Discover identifiers through `list_blueprints`, `list_blueprint_versions`, + `search_artifacts`, `list_roles`, `list_custom_pages`, `list_users`, and + `list_groups`. Do not invent IDs. +3. Read the full target before changing it: `get_artifact`, + `get_blueprint_version`, `get_signoff`, `get_role`, `get_custom_page_definition`. +4. Run the write with the exact `operation` and `params`. Update operations + replace the complete definition unless the guide says merge. +5. Re-read after every write. The tool returns the persisted state, but compare it + with the intended change; a successful save does not prove the values held. +6. For multi-step work, verify each unit and retain the created IDs. After an + uncertain write, inspect before retrying a create. + +## Parameter rules + +- `update_artifact`, `update_blueprint`, `update_blueprint_version`, + `update_signoff_configuration`, `update_role`, `update_role_assignments`, + `update_blueprint_permissions`, `update_default_blueprint_permissions`, + `update_custom_page`, and `update_signoff_recurrence` replace the whole + definition. Send the full object read from the matching get operation with only + the requested keys changed. +- `update_artifact_fields`, `update_user`, and `update_group` merge the given + keys into the current definition and preserve the rest. +- `create_*` payloads never contain an `id`; the server derives it from + `new_identifier` or the parent. +- Users containers in signoff configurations and role rules are + `{"type": "user", "login": ...}`, `{"type": "group", "groupName": ...}`, + `{"type": "role", "roleId": ...}`, or `{"type": "global-api-key", "globalAPIKeyId": ...}`. + The type is lowercase. The `users_container` param of the two delegation + operations accepts the `user` form only. +- `update_signoff_status` notifies every configured reviewer or approver when + `users_to_notify` is omitted. Pass `[]` unless the task authorizes notifications. +- `update_blueprint_version` refuses a save that removes a field or changes its + type on a version with artifacts. `danger_zone_accepted: true` discards that + data everywhere; use it only after an explicit user decision. +- `search_artifacts` returns at most `max_results` hits (default 100) and + `has_more`. Raise `max_results` or narrow the filters for a complete inventory, + and say so when a result is a sample. + +## Operations + +| Domain | Operations | +| --- | --- | +| artifacts | `search_artifacts`, `get_artifact`, `create_artifact`, `update_artifact`, `update_artifact_fields`, `delete_artifact` | +| signoffs | `list_signoffs`, `get_signoff`, `get_signoff_details`, `create_signoff`, `update_signoff_status`, `list_signoff_feedbacks`, `get_signoff_feedback`, `add_signoff_feedback`, `update_signoff_feedback`, `delete_signoff_feedback`, `delegate_signoff_feedback`, `get_signoff_approval`, `add_signoff_approval`, `update_signoff_approval`, `delete_signoff_approval`, `delegate_signoff_approval`, `get_signoff_recurrence`, `update_signoff_recurrence` | +| blueprints | `list_blueprints`, `get_blueprint`, `list_blueprint_versions`, `get_blueprint_version`, `create_blueprint`, `update_blueprint`, `delete_blueprint`, `create_blueprint_version`, `update_blueprint_version`, `set_blueprint_version_status`, `delete_blueprint_version`, `list_signoff_configurations`, `get_signoff_configuration`, `create_signoff_configuration`, `update_signoff_configuration`, `delete_signoff_configuration` | +| roles | `list_roles`, `get_role`, `create_role`, `update_role`, `delete_role`, `list_role_assignments`, `get_role_assignments`, `create_role_assignments`, `update_role_assignments`, `delete_role_assignments`, `get_default_blueprint_permissions`, `update_default_blueprint_permissions`, `list_blueprint_permissions`, `get_blueprint_permissions`, `create_blueprint_permissions`, `update_blueprint_permissions`, `delete_blueprint_permissions` | +| custom_pages | `list_custom_pages`, `get_custom_page`, `get_custom_page_definition`, `create_custom_page`, `update_custom_page`, `delete_custom_page`, `get_custom_pages_order`, `update_custom_pages_order` | +| time_series | `create_time_series`, `get_time_series_values`, `push_time_series_values`, `delete_time_series_values` | +| files | `upload_file`, `get_uploaded_file`, `download_uploaded_file`, `delete_uploaded_file` | +| users | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user`, `get_own_user`, `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` | +| instance | `get_instance_info`, `get_auth_info` | + +Blueprint version import, bulk user provisioning, API keys, licensing, logs, and +identity-provider settings are not in the catalog. Report them as gaps and hand off +to the Govern UI. + +## Choose the task guide + +| Task | Read next | +| --- | --- | +| Discover, create, or update governed records; inspect field schemas | [Artifacts](./govern/artifacts.md) | +| Design blueprint versions, fields, workflow, views, or hooks | [Blueprints](./govern/blueprints.md) | +| Configure review gates or work with feedback, approvals, and workflow state | [Signoffs](./govern/signoffs.md) | +| Manage roles, permissions, custom pages, users, groups, files, or time series | [Supporting objects](./govern/supporting-objects.md) | + +## Design the governance process + +For process design, identify the item being governed, its parent, required evidence, +responsible roles, and the decision that each review gate records. Reuse a suitable +standard blueprint before designing a custom item type. Derive risk criteria and +approval rules from the user's policy; a plausible template is not an established policy. + +Synced Dataiku assets and their governance layer are distinct. Discover an asset's +existing Govern record before creating another; a Govern project may also exist +before it is linked to a Dataiku project. A parent must be governed before its +children can be governed. Hiding a parent hides its children in Govern; it does +not delete them. See [Govern items](https://doc.dataiku.com/dss/latest/governance/types-govern-items.html) +and [governance actions](https://doc.dataiku.com/dss/latest/governance/governance-features.html). + +Workflow progress, signoff approval, and deployment authorization are separate. +Use the configured signoff to record a decision. For deployment eligibility, inspect +the [Deployer infrastructure policy](https://doc.dataiku.com/dss/latest/governance/deployment-policies.html); +an `approved` field or a finished workflow step is not a deployment gate. + +## Safety + +- Every `write` and `delete` operation is a mutation. Let the harness obtain user + confirmation before running one. +- Deleting a blueprint version requires deleting its artifacts first. Do not + cascade-delete artifacts to unblock a schema change. +- Decisions (`add_signoff_feedback`, `add_signoff_approval`) are recorded under the + authenticated identity. Do not alter reviewer membership to bypass a permission + error; delegation changes who reviews and is not impersonation. +- Keep local paths explicit for `upload_file` and `download_uploaded_file`. Never + expose API keys in payloads or responses. diff --git a/skills/dataiku-headless/references/govern/artifacts.md b/skills/dataiku-headless/references/govern/artifacts.md new file mode 100644 index 00000000..91d86422 --- /dev/null +++ b/skills/dataiku-headless/references/govern/artifacts.md @@ -0,0 +1,75 @@ +# Govern artifacts + +Use the `govern` tool described in [Govern](../govern.md). Identifiers such as +`blueprint_id`, `version_id`, and `artifact_id` come from discovery or the user. + +## Discover and inspect + +Blueprints and versions: + +- `list_blueprints` returns envelopes; the blueprint is nested under `blueprint`. +- `list_blueprint_versions` with `blueprint_id` lists versions and their status. +- `get_blueprint_version` with `blueprint_id` and `version_id` returns + `definition` (the schema, with `fieldDefinitions`) and `trace` (status, origin). + +Artifacts: + +- `search_artifacts` with `blueprint_ids`, `blueprint_version_ids`, `artifact_ids`, + `field_filters`, `archived`, and `sort`. Each hit holds the artifact `id`, + `name`, `blueprintVersionId`, and `status`; call `get_artifact` for its fields + and workflow. The result carries `count` and `has_more`; when `has_more` is true, + raise `max_results` or narrow the filters before calling the inventory complete. + A field filter is `{"condition_type": "CONTAINS", "condition": "churn"}` for the + name, or adds `field_id` for one field. +- `get_artifact` with `artifact_id` returns the full definition: `name`, `fields`, + `workflow`, and `blueprintVersionId`. + +For an existing artifact, inspect its own blueprint version; another version of +the same blueprint can have different fields. + +Govern 15 also exposes creation and last-modification metadata, including the actor +and timestamp. Inspect `creationRevision` and `lastModificationRevision` when the +task needs provenance; preserve server-managed metadata when editing other fields. +These values describe creation and the latest change, not a complete audit history. + +## Create or update + +Create on an ACTIVE version after reading its `fieldDefinitions`: + +```json +{"operation": "create_artifact", + "params": {"blueprint_id": "bp.system.govern_project", "version_id": "bv.system.default", + "name": "Churn model", "fields": {"description": "Quarterly churn scoring"}}} +``` + +Retain the returned `id` and re-read with `get_artifact`. + +For a partial edit, use `update_artifact_fields` with `artifact_id` and `fields` +(and `name` when it changes). It merges the given fields and preserves the rest. +Compare the returned `fields` with the request; a field that did not persist means +the value was rejected silently, for example a scalar sent to a list field. + +For a structural edit (workflow state, archived flag, blueprint reference), use +`update_artifact` with the complete `definition` read from `get_artifact`, changed +only where requested. A partial definition loses unrelated state. + +`delete_artifact` removes the record; confirm the ID first. + +## Field rules + +- Fields are keyed by ID in `fieldDefinitions`; the type key is `fieldType`. + Use the actual field ID, not its display label. User-entered values belong in + `sourceType: STORE` fields; preserve computed fields. +- `listConfig` makes a field a list, even if the configuration is empty. Send an + array for one value as well. CATEGORY values must match the defined categories, + case-sensitively. +- REFERENCE values are artifact IDs, including references to user and group + artifacts; they are not logins or group names. Inspect `allowedBlueprints` + before selecting them. +- DATE values use an ISO-8601 datetime with timezone, for example + `2026-06-01T00:00:00.000Z`. Uploaded files and time series use IDs from + `upload_file` and `create_time_series`; see [Supporting objects](./supporting-objects.md). +- `required` is enforced on every artifact whatever the workflow step or the view. + +For workflow transitions or approvals, follow [Signoffs](./signoffs.md). An artifact +field edit is not a substitute for submitting feedback or approval. diff --git a/skills/dataiku-headless/references/govern/blueprints.md b/skills/dataiku-headless/references/govern/blueprints.md new file mode 100644 index 00000000..c6565d86 --- /dev/null +++ b/skills/dataiku-headless/references/govern/blueprints.md @@ -0,0 +1,99 @@ +# Govern blueprint authoring + +Use the `govern` tool described in [Govern](../govern.md). A blueprint stores identity +and presentation metadata. Its versions hold fields, workflow, views, hooks, and +actions. Blueprint design operations need blueprint designer rights on the API key. + +## Inspect, fork, edit, publish + +Inspect with `get_blueprint`, `list_blueprint_versions`, `get_blueprint_version` +(definition plus trace), and `list_signoff_configurations`. + +Prefer forking a suitable existing version when changing the structure, so the +required system fields and behavior survive: + +1. `create_blueprint_version` with `blueprint_id`, `new_identifier`, `name`, and + `origin_version_id`. The new version is DRAFT and invisible to users. +2. `get_blueprint_version` and edit the returned `definition` locally, preserving + everything not requested. +3. `update_blueprint_version` with the complete `definition`. Leave + `danger_zone_accepted` unset. +4. `create_signoff_configuration` per gated step; see [Signoffs](./signoffs.md). +5. `set_blueprint_version_status` to `ACTIVE` when activation is part of the task. + Check `trace.status` in the result. + +For a new blueprint, `create_blueprint` with `new_identifier` and a `blueprint` +body holding `name`, `icon`, `color`, and `backgroundColor`, without an `id`. Do +not assume a system version exists on a new blueprint or invent an origin ID. +`update_blueprint` replaces that metadata only. + +On a version used by artifacts, removing fields or changing types blocks the save. +A blocked save calls for a new version or an explicit decision covering that data +loss; do not retry with `danger_zone_accepted: true` on your own. Deleting a +version (`delete_blueprint_version`) requires removing its artifacts first; do not +cascade-delete them just to unblock a schema operation. + +## Definition structure + +| Key | Purpose and checks | +| --- | --- | +| `id` | Preserve the `{blueprintId, versionId}` of the target version. | +| `fieldDefinitions` | Dictionary keyed by field ID; inspect `fieldType`, `sourceType`, and list or required constraints. See [Artifacts](./artifacts.md). | +| `workflowDefinition` | Contains ordered `stepDefinitions`; preserve stable step IDs referenced by signoffs and UI definitions. | +| `uiDefinition` | Layout structure varies by release: inspect `views`, `uiStepDefinitions`, and the artifact page or tab configuration. Workflow and UI step IDs must match. | +| `logicalHookList` | Pre-phase hooks for validation and computed fields before commit. | +| `postLogicalHookList` | Govern 15 post-phase hooks for work after commit. Preserve both hook lists when editing unrelated settings. | +| `actions` | Action definitions keyed by action ID; a UI action component must reference the action for it to be visible. | + +An accepted definition can still render an empty artifact page. Check that the +configured artifact tabs or page and workflow steps resolve to nonempty views, and +that editable fields appear in the intended views. Older definitions may use +`artifactPageViewId`; Govern 15 definitions can use `tabs` and +`artifactStructureTabIds` instead. The optional `customRightPanel` and +`rightPanelTabIds` configure the selection panel independently. A custom tab can +be shared by the artifact page and right panel; its ID and view remain shared. +System tabs such as Workflow, Timeline, and Role assignments belong only on the +artifact page. Preserve stable tab IDs used in navigation, and check both orders +and separators. See [page structure](https://doc.dataiku.com/dss/latest/governance/blueprint-designer/blueprint-version-design.html#design-the-item-page-structure). + +Reuse components from an inspected definition rather than inventing UI keys. +Govern 15 text components have separate display settings (Markdown or plain text) +and editor settings (rich text, single-line, or multiline); configure these on the +view component without changing the field's `TEXT` type. State separately whether +layout was inspected in the UI or only checked structurally. + +Required field constraints apply globally; hiding a required field does not make +it optional. A hidden workflow step can bypass its mandatory signoff. Review view +and step visibility conditions when they affect the requested approval behavior. + +Field and workflow saves alone do not create review gates; use the signoff +configuration operations in [Signoffs](./signoffs.md). + +## Choose the hook phase + +Hooks select lifecycle events (`CREATE`, `UPDATE`, `DELETE`) and when to run: + +- Pre-phase hooks run before commit. Use them to validate changes or populate + computed fields. The operation can still fail afterward; keep external side + effects and API writes to other items out of these hooks. To schedule related + items' UPDATE hooks after commit, use `handler.artifactIdsToUpdate`. +- Govern 15 post-phase hooks run after commit. Use them for work that requires + persisted state, such as an authorized notification or external synchronization. + They cannot validate or veto an action that has already committed. Check the + saved item and any downstream effect separately before retrying. + +Use the target release's [hook documentation and editor samples](https://doc.dataiku.com/dss/latest/governance/blueprint-designer/blueprint-version-design.html#set-rules-with-hooks) +for the handler context. Older examples may emulate post-create work with threads +and polling; use native post-phase hooks on Govern 15 when that is the intent. +Treat scripts as executable changes, and verify execution as well as saved configuration. + +## Export and import boundary + +A local JSON snapshot can hold the full version definition, trace, and signoff +configurations read above. Keep it outside the plugin and protect sensitive content. +A definition alone is not a complete transferable blueprint package. + +The catalog has no blueprint version import operation, because the public SDK has +none. For a cross-instance import or artifact-version migration, report the missing +operation and use the Govern UI as the handoff. Do not claim that a JSON snapshot +supplies import parity. diff --git a/skills/dataiku-headless/references/govern/signoffs.md b/skills/dataiku-headless/references/govern/signoffs.md new file mode 100644 index 00000000..3fb1b1fa --- /dev/null +++ b/skills/dataiku-headless/references/govern/signoffs.md @@ -0,0 +1,95 @@ +# Govern workflow and signoffs + +Use the `govern` tool described in [Govern](../govern.md). A blueprint version's +signoff configuration defines a review gate. An artifact's signoff is the runtime +review, including feedback and approval. Inspect both before changing either. + +## Workflow state + +Read `get_artifact` and its blueprint version. On Govern 13.5+, use +`workflow.steps[step_id]` for state and visibility; order comes from the version's +`workflowDefinition.stepDefinitions`. `status.stepId` is deprecated and may name a +step that is not actually ongoing. See the +[official workflow reference](https://developer.dataiku.com/latest/concepts-and-examples/govern/govern-artifacts/govern-artifact-workflow.html). + +When asked to advance the workflow, read the current steps and required signoffs, +change only the intended step states in the full definition, save with +`update_artifact`, and re-read. Preserve visibility and unrelated steps. Do not skip +a required review or rewrite the legacy status field to make signoff creation succeed. + +## Configure a review gate + +- `list_signoff_configurations` with `blueprint_id` and `version_id` lists gates. +- `get_signoff_configuration` with `step_id` returns one. +- `create_signoff_configuration` with `step_id` and a `configuration` without a + top-level `id`; the server derives it from the step and version. +- `update_signoff_configuration` replaces the complete `configuration`. +- `delete_signoff_configuration` removes the gate. + +Configuration keys: + +- `title`, `mandatory`, `feedbackUsersGroups`, `approvers`, and + `recurrenceConfiguration`. Preserve unrelated settings when updating. +- Each feedback group has an `id`, a non-empty `title`, and a `users` list. The + group's ID is used by `add_signoff_feedback` and `delegate_signoff_feedback`; + it is not necessarily a Govern role ID. +- Reviewers and approvers are `{"usersContainer": {...}}` entries. The container + type is lowercase: `user` (`login`), `group` (`groupName`), `role` (`roleId`), + or `global-api-key` (`globalAPIKeyId`). Discover the user, group, or role + first. Approvers form a flat list. +- `recurrenceConfiguration` is `{activated, days, weeks, months, years, reloadConf}`; + activation needs a positive interval. + +Example: + +```json +{"title": "Review gate", "mandatory": true, + "feedbackUsersGroups": [{"id": "reviewers", "title": "Reviewers", + "users": [{"usersContainer": {"type": "user", "login": "alice"}}]}], + "approvers": [{"usersContainer": {"type": "user", "login": "bob"}}], + "recurrenceConfiguration": {"activated": false, "days": 0, "weeks": 0, "months": 0, "years": 0, "reloadConf": false}} +``` + +Re-read the configuration and check reviewers, approvers, `mandatory`, and the step. +Existing runtime signoffs keep their previous configuration until reset; a +configuration edit alone does not update every review. + +## Run a signoff + +- `list_signoffs` with `artifact_id` lists the runtime signoffs. +- `get_signoff` returns status, feedback responses, and the approver response. +- `get_signoff_details` resolves reviewer and approver membership to users. +- A signoff exists only for an ONGOING step. Setting `workflow.steps..status` + to `ONGOING` through `update_artifact` creates the signoff when the step has a + configuration; `create_signoff` covers the case where it is missing. Both fail + on a step that is not ONGOING. + +| Intent | Operation | +| --- | --- | +| Start feedback or approval collection | `update_signoff_status` with `status: WAITING_FOR_FEEDBACK` or `WAITING_FOR_APPROVAL` and `users_to_notify: []` | +| Read feedback | `list_signoff_feedbacks`, `get_signoff_feedback` | +| Record requested feedback | `add_signoff_feedback` with `group_id`, `status`, `comment` | +| Change or remove a feedback | `update_signoff_feedback`, `delete_signoff_feedback` | +| Read approval | `get_signoff_approval` | +| Record requested approval decision | `add_signoff_approval` with `status`, `comment` | +| Change or remove the approval | `update_signoff_approval`, `delete_signoff_approval` | +| Delegate reviewers | `delegate_signoff_feedback` with `group_id` and `users_container`, or `delegate_signoff_approval` with `users_container`; both take one `{"type": "user", "login": ...}` | +| Scheduled reset | `get_signoff_recurrence`, `update_signoff_recurrence` | + +Feedback statuses are `APPROVED`, `MINOR_ISSUE`, and `MAJOR_ISSUE`; approval statuses +are `APPROVED`, `REJECTED`, and `ABANDONED`. Signoff statuses move +`NOT_STARTED → WAITING_FOR_FEEDBACK → WAITING_FOR_APPROVAL → APPROVED | REJECTED | ABANDONED`. +Submit decisions only when requested, under the authenticated identity. +Administrative API access does not make that identity a reviewer or approver; check +the configured membership, and do not alter it to bypass a permission error. +Delegation changes who reviews and is not impersonation. + +Omitting `users_to_notify` from `update_signoff_status` notifies every configured +reviewer or approver of the target stage. Pass `[]` unless the task authorizes +notifications; when notifying, list `{userLogin, groupId}` entries for the feedback +stage and `{userLogin}` entries for the approval stage. Resetting an in-progress +review requires `ABANDONED` before `NOT_STARTED`; setting `APPROVED` through +`update_signoff_status` is not a substitute for recording an approval. +`reload_conf_for_reset: true` also clears delegations; do not set it incidentally. +A feedback can be deleted only while the signoff is `WAITING_FOR_FEEDBACK`, and +the approval only once the signoff is `APPROVED`, `REJECTED`, or `ABANDONED`. diff --git a/skills/dataiku-headless/references/govern/supporting-objects.md b/skills/dataiku-headless/references/govern/supporting-objects.md new file mode 100644 index 00000000..d5b79484 --- /dev/null +++ b/skills/dataiku-headless/references/govern/supporting-objects.md @@ -0,0 +1,91 @@ +# Govern supporting objects + +Use the `govern` tool described in [Govern](../govern.md). These operations use the +Govern credentials and permissions. The identically named DSS user and group MCP +tools target DSS and must not be used here. + +## Roles, permissions, and identities + +| Task | Operations | +| --- | --- | +| Roles | `list_roles`, `get_role`, `create_role`, `update_role`, `delete_role` | +| Role assignments per blueprint | `list_role_assignments`, `get_role_assignments`, `create_role_assignments`, `update_role_assignments`, `delete_role_assignments` | +| Blueprint permissions | `get_default_blueprint_permissions`, `update_default_blueprint_permissions`, `list_blueprint_permissions`, `get_blueprint_permissions`, `create_blueprint_permissions`, `update_blueprint_permissions`, `delete_blueprint_permissions` | +| Users | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user`, `get_own_user` | +| Groups | `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` | + +Read the existing definition before saving and re-read afterward. Role definitions +and blueprint role assignments are separate resources. Updating one role's binding +must preserve the other roles; `delete_role_assignments` removes every binding of +the blueprint. For global permissions on Govern 15, use `isGovernArchitect`; the +deprecated `mayManageGovern` key was removed. Discover existing permissions before +changing them. + +Role bindings live in `roleAssignmentsRules[role_id]`, a list of rules with +`criteria`, `userContainers`, and `fieldIds`. An unconditional binding has empty +`criteria` and `fieldIds` lists and the selected user or group containers in +`userContainers`. Inspect a matching rule and verify the saved membership; change +one role's rules inside the full assignment definition, never replace the whole +definition to change one role. + +`update_user` and `update_group` merge the given keys into the current settings or +definition. `create_user` needs a `password` for LOCAL users; other keys are optional. + +## Custom pages + +- `list_custom_pages` and `get_custom_page` show pages as the authenticated user + sees them. +- `get_custom_page_definition`, `create_custom_page`, `update_custom_page`, + `delete_custom_page`, `get_custom_pages_order`, and `update_custom_pages_order` + are designer operations. + +Custom pages can display artifact views or custom HTML. Discover the target +release's page types from an existing page; built-in `standard-page` entries are not +creatable custom pages. Preserve the page type and definition, and treat HTML and +scripts as executable content. An external embed needs a browser-accessible URL and +compatible authentication; a saved definition does not prove that the embedded page +renders. + +### Govern 15 grid pages and charts + +Prefer native grid pages for dashboards over Govern items. A `grid` page can contain +multiple tabs with table, chart, HTML, and nested subgrid tiles. Copy the payload +structure from a matching page read with `get_custom_page_definition` instead of +guessing nested keys. + +Build the chart's data settings before its appearance: + +1. Filter the intended items and add projections for the needed fields. By default, + only item IDs are projected. Table tiles fetch their own columns, so a working + table does not prove that a chart has the data it needs. +2. Define breakdown dimensions and measures, then select that breakdown and measure + in the chart. A subgrid inherits its parent's data settings unless overridden. +3. Use standard charts when they fit. Chart selections filter tables and charts + sharing the same data settings. Converting to a custom JavaScript or ECharts + chart cannot be reverted to the standard chart configuration. + +Re-read the saved definition, then check the page with real data: displayed counts +and measures, drill-down targets, and selection-panel behavior. A preview using +sample data does not validate the real query. Create pages hidden while validating +them, and make them visible when publication is part of the task. See +[custom page design](https://doc.dataiku.com/dss/latest/governance/custom-pages.html) +for the current tile, projection, aggregation, and chart options. + +## Files and time series + +- `upload_file` with a local `file_path` returns the file description; retain its + `id`. `get_uploaded_file` reads the description, `download_uploaded_file` writes + the content to an explicit `output_path` and never overwrites unless `overwrite` + is true, `delete_uploaded_file` removes it. +- Attach the returned file ID to the appropriate artifact field through + `update_artifact_fields`, then re-read the field. Uploading alone does not attach it. +- `create_time_series` returns `time_series_id`; retain it. Use + `get_time_series_values`, `push_time_series_values`, and + `delete_time_series_values` afterward. +- Datapoints contain `timestamp` in epoch **milliseconds** and `value`. + `push_time_series_values` with `upsert: true` (the default) overwrites existing + timestamps; choose the mode deliberately. Read the affected interval after a + write. `delete_time_series_values` removes values in its timestamp window, or all + values when neither bound is supplied; it does not delete the time-series object. + Verify boundary behavior before a range deletion: Govern 15.0.1 excludes both + endpoint timestamps from reads and deletes. diff --git a/tests/test_auth.py b/tests/test_auth.py index 6bbf3342..179393e0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -163,3 +163,64 @@ def log_message(self, *args): server.shutdown() server.server_close() thread.join(timeout=3) + + +@pytest.mark.parametrize("no_check_certificate", [False, True]) +@pytest.mark.parametrize("credential", ["api_key", "api_ticket"]) +def test_govern_client_uses_active_govern_instance( + monkeypatch, credential, no_check_certificate +): + instance = DSSInstance( + name="govern", + url="https://govern.example.com", + api_key="api-key" if credential == "api_key" else None, + api_ticket="api-ticket" if credential == "api_ticket" else None, + no_check_certificate=no_check_certificate, + source="config", + instance_type="govern", + ) + monkeypatch.setattr(request, "get_pinned_instance", lambda: instance) + monkeypatch.setattr(request, "is_http_request", lambda: False) + captured = {} + client = SimpleNamespace(_session=SimpleNamespace(verify=True)) + + def create_client(url, **kwargs): + captured.update(url=url, **kwargs) + return client + + monkeypatch.setattr(auth.dataikuapi, "GovernClient", create_client) + + assert auth.get_govern_client() is client + assert captured == { + "url": instance.url, + "api_key": instance.api_key, + "internal_ticket": instance.api_ticket, + "extra_headers": {"X-DKU-Client-Application": "dataiku-headless"}, + } + assert client._session.verify is not no_check_certificate + + +@pytest.mark.parametrize( + "instance_type", ["design", "automation", "deployer", "agent-management"] +) +def test_govern_client_rejects_other_instance_types(monkeypatch, instance_type): + instance = DSSInstance( + name="dev", + url="https://dev.example.com", + api_key="api-key", + no_check_certificate=False, + source="config", + instance_type=instance_type, + ) + monkeypatch.setattr(request, "get_pinned_instance", lambda: instance) + monkeypatch.setattr(request, "is_http_request", lambda: False) + + with pytest.raises(ValueError, match="switch_instance"): + auth.get_govern_client() + + +def test_govern_client_is_stdio_only(monkeypatch): + monkeypatch.setattr(request, "is_http_request", lambda: True) + + with pytest.raises(ValueError, match="local stdio mode"): + auth.get_govern_client() diff --git a/tests/test_tool_surface.py b/tests/test_tool_surface.py index 317244c3..da2a354f 100644 --- a/tests/test_tool_surface.py +++ b/tests/test_tool_surface.py @@ -102,6 +102,7 @@ "general_settings": frozenset( {"list_container_exec_configs", "list_spark_configs"} ), + "govern": frozenset({"govern"}), "groups": frozenset( {"create_group", "delete_group", "list_groups", "update_group"} ),