feat(connectors): add OrchestratorConnector base and stubbed Ansible
Multi-step workflow base class with plan/execute lifecycle and partial-completion reporting. Ansible connector stubbed — ansible-runner integration in future sprint. Credentials resolved per-host at runtime via CredentialResolver, never stored. Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
parent
eee8740ce8
commit
2ac5aa3b85
2 changed files with 249 additions and 0 deletions
137
gsap_broker/connectors/ansible.py
Normal file
137
gsap_broker/connectors/ansible.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Ansible connector — fleet management via Ansible playbooks.
|
||||
|
||||
The Ansible connector is an OrchestratorConnector: it plans a
|
||||
sequence of steps (playbook runs, ad-hoc commands, fact collection)
|
||||
and executes them via ansible-runner.
|
||||
|
||||
Credential model:
|
||||
Ansible does NOT receive credentials from the broker. Instead,
|
||||
the connector passes a credential callback that ansible-runner's
|
||||
credential plugin calls at runtime. This callback resolves
|
||||
credentials via the broker's CredentialResolver for each target
|
||||
host at the moment the task runs — not at planning time.
|
||||
|
||||
This means:
|
||||
- The Ansible inventory file contains NO passwords or keys.
|
||||
- Each host's credentials are resolved just-in-time from the
|
||||
secrets backend (Entra, Vault, etc).
|
||||
- If a credential expires mid-playbook, the next task for that
|
||||
host will resolve a fresh credential.
|
||||
|
||||
Real integration:
|
||||
Library: ``ansible-runner`` (execution library)
|
||||
Auth: Per-host credential resolution via custom credential plugin
|
||||
Inventory: Dynamic inventory from Intune device cache or Bascule
|
||||
fleet registry
|
||||
|
||||
Stubbed in this sprint — steps return placeholder results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext
|
||||
from gsap_broker.connectors.orchestrator import (
|
||||
OrchestratorConnector,
|
||||
WorkflowPlan,
|
||||
WorkflowStep,
|
||||
)
|
||||
from gsap_broker.credentials.resolver import CredentialResolver
|
||||
|
||||
|
||||
class AnsibleConnector(OrchestratorConnector):
|
||||
"""Fleet management via Ansible playbooks."""
|
||||
|
||||
connector_id = "ansible"
|
||||
corpus_entry_cid = "sha256:ansible-connector-v1"
|
||||
capability_mask = 0x7 # READ | PROPOSE | MUTATE
|
||||
declared_endpoints = ["ansible://*"]
|
||||
accord_template = "fleet-management"
|
||||
gsap_required = True
|
||||
chronicle_enabled = True
|
||||
|
||||
def __init__(self, credential_resolver: CredentialResolver):
|
||||
super().__init__(credential_resolver)
|
||||
|
||||
async def plan(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> WorkflowPlan:
|
||||
"""Map operations to workflow plans.
|
||||
|
||||
Operations:
|
||||
"playbook" → single step running the named playbook
|
||||
"adhoc" → single step running a module on targets
|
||||
"collect" → single step gathering facts
|
||||
"role" → single step applying a role
|
||||
"""
|
||||
targets = parameters.get("targets", [])
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
if operation == "playbook":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"playbook:{parameters.get('playbook', 'site.yml')}",
|
||||
command=parameters.get("playbook", "site.yml"),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("extra_vars", {}),
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "adhoc":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"adhoc:{parameters.get('module', 'ping')}",
|
||||
command=parameters.get("module", "ping"),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("args", {}),
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "collect":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name="collect:facts",
|
||||
command="setup",
|
||||
targets=targets,
|
||||
required=False, # fact collection is best-effort
|
||||
)
|
||||
])
|
||||
|
||||
if operation == "role":
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(
|
||||
name=f"role:{parameters.get('role', '')}",
|
||||
command=parameters.get("role", ""),
|
||||
targets=targets,
|
||||
extra_vars=parameters.get("extra_vars", {}),
|
||||
)
|
||||
])
|
||||
|
||||
return WorkflowPlan(steps=[
|
||||
WorkflowStep(name=f"unknown:{operation}", command=operation, targets=targets)
|
||||
])
|
||||
|
||||
async def execute_step(
|
||||
self, step: WorkflowStep, context: ConnectorContext
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single workflow step via ansible-runner.
|
||||
|
||||
Stubbed — actual ansible-runner integration in a future sprint.
|
||||
"""
|
||||
# TODO: use ansible_runner.run() with:
|
||||
# - playbook=step.command (for playbook operation)
|
||||
# - module=step.command, module_args=step.extra_vars (for adhoc)
|
||||
# - inventory from dynamic source (Intune cache, Bascule fleet)
|
||||
# - credential plugin that calls self._resolver.resolve()
|
||||
# per-host at runtime
|
||||
return {
|
||||
"success": True,
|
||||
"stub": True,
|
||||
"step": step.name,
|
||||
"targets": step.targets,
|
||||
}
|
||||
112
gsap_broker/connectors/orchestrator.py
Normal file
112
gsap_broker/connectors/orchestrator.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Orchestrator connector framework — multi-step workflow execution.
|
||||
|
||||
Orchestrator connectors manage workflows that span multiple steps
|
||||
and potentially multiple targets. Each step may acquire its own
|
||||
credentials via the CredentialResolver.
|
||||
|
||||
Unlike ``SessionConnector`` (single target, single credential,
|
||||
single command), an ``OrchestratorConnector``:
|
||||
- Plans a sequence of steps before execution
|
||||
- Executes steps in order, stopping on required-step failure
|
||||
- Reports partial results (which steps completed before failure)
|
||||
- Can target different hosts per step
|
||||
|
||||
Rust port note:
|
||||
``WorkflowStep`` and ``WorkflowPlan`` map to plain structs.
|
||||
``OrchestratorConnector`` maps to an async trait with
|
||||
``plan()`` and ``execute_step()`` methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from gsap_broker.connectors.base import ConnectorContext, ConnectorPlugin, ConnectorResult
|
||||
from gsap_broker.credentials.resolver import CredentialResolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowStep:
|
||||
"""A single step in a workflow plan."""
|
||||
|
||||
name: str
|
||||
command: str
|
||||
targets: list[str] = field(default_factory=list)
|
||||
required: bool = True
|
||||
extra_vars: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowPlan:
|
||||
"""Ordered sequence of steps for a workflow."""
|
||||
|
||||
steps: list[WorkflowStep] = field(default_factory=list)
|
||||
|
||||
|
||||
class OrchestratorConnector(ConnectorPlugin):
|
||||
"""Base for multi-step workflow connectors (Ansible, Terraform, etc).
|
||||
|
||||
Subclasses implement ``plan()`` to convert an operation + parameters
|
||||
into a ``WorkflowPlan``, and ``execute_step()`` to run each step.
|
||||
|
||||
The base ``invoke()`` handles:
|
||||
- Planning the workflow
|
||||
- Executing steps in order
|
||||
- Stopping on required-step failure
|
||||
- Aggregating results with partial-completion reporting
|
||||
"""
|
||||
|
||||
def __init__(self, credential_resolver: CredentialResolver):
|
||||
self._resolver = credential_resolver
|
||||
|
||||
async def invoke(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> ConnectorResult:
|
||||
try:
|
||||
plan = await self.plan(operation, parameters, context)
|
||||
except Exception as e:
|
||||
return ConnectorResult(success=False, error=f"Planning failed: {e}")
|
||||
|
||||
if not plan.steps:
|
||||
return ConnectorResult(success=True, data={"steps": []})
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for step in plan.steps:
|
||||
result = await self.execute_step(step, context)
|
||||
results.append({"step": step.name, **result})
|
||||
|
||||
if not result.get("success") and step.required:
|
||||
return ConnectorResult(
|
||||
success=False,
|
||||
data={"completed": results, "failed_at": step.name},
|
||||
)
|
||||
|
||||
return ConnectorResult(success=True, data={"steps": results})
|
||||
|
||||
@abstractmethod
|
||||
async def plan(
|
||||
self, operation: str, parameters: dict[str, Any], context: ConnectorContext
|
||||
) -> WorkflowPlan:
|
||||
"""Convert an operation into a step-by-step execution plan."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def execute_step(
|
||||
self, step: WorkflowStep, context: ConnectorContext
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single workflow step.
|
||||
|
||||
Returns a dict with at minimum a ``success: bool`` key.
|
||||
"""
|
||||
...
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
Loading…
Reference in a new issue