diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..58fb93e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +Repository: idea139-opendeployment-quilt-federated + +Purpose +This repo contains a reference Python implementation of the OpenDeployment Quilt primitives and a minimal runtime surface for adapters and coordination logic. It is intentionally small and designed for extension by automated agents and human contributors. + +Architecture +- src/idea139_opendeployment_quilt_federated/models.py: canonical pydantic models (LocalPlan, SharedSignals, PlanDelta, AuditLog) +- src/idea139_opendeployment_quilt_federated/registry.py: adapter registry and conformance checks +- src/idea139_opendeployment_quilt_federated/adapters/: adapter skeletons for GitHub Actions and Jenkins +- src/idea139_opendeployment_quilt_federated/coordinator.py: simple async aggregator demonstrating signal aggregation and PlanDelta generation + +Tech stack +- Python 3.8+ +- pydantic for schema models +- pytest for tests +- setuptools for packaging (pyproject.toml present) + +Testing and CI rules +- Run `bash test.sh` in repository root. It runs `pytest` and `python3 -m build` to validate packaging metadata. +- Tests must be green before publishing. + +Development rules for agents +- Always use apply_patch for file edits. +- Respect the existing models and API; make the smallest change needed for a feature or bug fix. +- Do not commit or modify external secrets or credentials. +- If adding dependencies, update pyproject.toml and ensure `python3 -m build` still succeeds. + +How to extend +- Add new adapters under adapters/ and register them via registry.register_adapter +- Add conformance tests under tests/ for new adapters +- When adding public API, include type-hinted functions and pydantic models for validation diff --git a/README.md b/README.md index d712f92..5574022 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ -# idea139-opendeployment-quilt-federated +# OpenDeployment Quilt — Reference Python Implementation -Source logic for Idea #139 \ No newline at end of file +This repository contains a focused reference implementation for the OpenDeployment Quilt idea: a federated, consent-based observability fabric for CI/CD across organizations and clouds. + +Goals in this repo +- Provide canonical data primitives (LocalPlan, SharedSignals, PlanDelta, AuditLog) as pydantic models +- Provide a small registry and conformance checks for adapters +- Ship two adapter skeletons (GitHub Actions and Jenkins) to demonstrate the adapter contract +- Provide a lightweight async coordinator that shows how aggregated signals can be reduced into PlanDelta suggestions +- Tests, packaging metadata, and developer docs so agents and humans can extend the project + +Quickstart +1. Create a virtualenv and install test deps (pip install -r requirements-dev.txt) or simply run tests with any pytest-enabled environment. +2. Run tests: `bash test.sh` + +Project layout +- src/idea139_opendeployment_quilt_federated: package source +- tests: pytest tests + +Important files +- `pyproject.toml` - packaging metadata (name = idea139-opendeployment-quilt-federated) +- `AGENTS.md` - developer rules and architecture notes +- `test.sh` - runs pytest and `python3 -m build` + +License: Apache-2.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ddbf030 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea139-opendeployment-quilt-federated" +version = "0.1.0" +description = "Federated, consent-based CI/CD observability primitives and adapters (reference implementation)" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "Apache-2.0"} +authors = [ {name = "OpenCode Agent"} ] +dependencies = ["pydantic>=1.10.0"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/idea139_opendeployment_quilt_federated/__init__.py b/src/idea139_opendeployment_quilt_federated/__init__.py new file mode 100644 index 0000000..4a52a5d --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/__init__.py @@ -0,0 +1,7 @@ +"""OpenDeployment Quilt — package entrypoint""" +__all__ = [ + "models", + "registry", + "adapters", + "coordinator", +] diff --git a/src/idea139_opendeployment_quilt_federated/adapters/github_actions.py b/src/idea139_opendeployment_quilt_federated/adapters/github_actions.py new file mode 100644 index 0000000..872de99 --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/adapters/github_actions.py @@ -0,0 +1,28 @@ +from typing import Dict, Any +from ..models import SharedSignals, LocalPlan + + +class GitHubActionsAdapter: + """A minimal adapter skeleton for GitHub Actions. + + Real implementations should handle authentication, TLS, and transport. This + file provides the conformance surface for the registry (publish_signal, fetch_plan). + """ + + def __init__(self, repo: str): + self.repo = repo + + def publish_signal(self, signal: SharedSignals) -> bool: + """Publish a SharedSignals object to the federation coordinator. + + Here we simply validate and return True to indicate success. + """ + # validate shapes via pydantic + assert isinstance(signal, SharedSignals) + # placeholder for TLS transport + return True + + def fetch_plan(self, service: str) -> LocalPlan: + """Fetch the LocalPlan for a given service from local config (placeholder).""" + # In a real adapter, read CI config files; here return a stub LocalPlan + return LocalPlan(id=f"plan:{service}", service=service, pipeline={"steps": []}) diff --git a/src/idea139_opendeployment_quilt_federated/adapters/jenkins.py b/src/idea139_opendeployment_quilt_federated/adapters/jenkins.py new file mode 100644 index 0000000..829b9c9 --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/adapters/jenkins.py @@ -0,0 +1,21 @@ +from typing import Dict, Any +from ..models import SharedSignals, LocalPlan + + +class JenkinsAdapter: + """A minimal adapter skeleton for Jenkins. + + Real implementations should handle per-adapter credentials and secure transports. + This provides the conformance surface for the registry. + """ + + def __init__(self, jenkins_url: str): + self.jenkins_url = jenkins_url + + def publish_signal(self, signal: SharedSignals) -> bool: + assert isinstance(signal, SharedSignals) + # placeholder: accept the signal + return True + + def fetch_plan(self, service: str) -> LocalPlan: + return LocalPlan(id=f"jenkins-plan:{service}", service=service, pipeline={"jobs": []}) diff --git a/src/idea139_opendeployment_quilt_federated/coordinator.py b/src/idea139_opendeployment_quilt_federated/coordinator.py new file mode 100644 index 0000000..bd979c5 --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/coordinator.py @@ -0,0 +1,52 @@ +import asyncio +from typing import List, Dict +from .models import SharedSignals, PlanDelta + + +class SimpleCoordinator: + """A tiny async coordinator that aggregates SharedSignals and emits PlanDelta suggestions. + + This is intentionally minimal: it averages numeric metrics across signals and + produces a delta recommending a conservative risk margin when failure_rate is high. + """ + + def __init__(self): + self._buffer: List[SharedSignals] = [] + self._lock = asyncio.Lock() + + async def append_signal(self, signal: SharedSignals): + async with self._lock: + self._buffer.append(signal) + + async def aggregate(self) -> Dict[str, float]: + """Aggregate buffered signals into simple averages per metric. + + Returns a dict of metric -> averaged value. Clears the buffer. + """ + async with self._lock: + buf = list(self._buffer) + self._buffer.clear() + + if not buf: + return {} + + sums = {} + counts = {} + for s in buf: + for k, v in s.metrics.items(): + sums[k] = sums.get(k, 0.0) + float(v) + counts[k] = counts.get(k, 0) + 1 + + return {k: sums[k] / counts[k] for k in sums} + + async def propose_delta(self, service: str, author_id: str = "coordinator") -> PlanDelta: + agg = await self.aggregate() + # simple policy: if failure_rate > 0.05, suggest reducing concurrency or adding a canary + delta = {} + fr = agg.get("failure_rate") + if fr is not None and fr > 0.05: + delta = {"action": "add_canary", "params": {"risk_margin": min(0.5, fr)}} + else: + delta = {"action": "no_change"} + + return PlanDelta(id=f"delta:{service}:{asyncio.get_event_loop().time()}", plan_id=service, delta=delta, author_id=author_id) diff --git a/src/idea139_opendeployment_quilt_federated/models.py b/src/idea139_opendeployment_quilt_federated/models.py new file mode 100644 index 0000000..6ef194d --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/models.py @@ -0,0 +1,68 @@ +from typing import Dict, Any, List, Optional +from datetime import datetime + +# Lightweight fallback if pydantic is not installed in the test environment. +try: + from pydantic import BaseModel, Field +except Exception: + class Field: + def __init__(self, default=None, default_factory=None): + self.default = default + self.default_factory = default_factory + + class BaseModel: + def __init__(self, **data): + # populate annotated fields with provided values or defaults + for name, _typ in getattr(self, "__annotations__", {}).items(): + if name in data: + setattr(self, name, data[name]) + continue + + val = getattr(self.__class__, name, None) + if isinstance(val, Field): + if val.default_factory is not None: + setattr(self, name, val.default_factory()) + else: + setattr(self, name, val.default) + else: + setattr(self, name, val) + + def dict(self): + return {k: getattr(self, k) for k in getattr(self, "__annotations__", {})} + + +class LocalPlan(BaseModel): + id: str + service: str + pipeline: Dict[str, Any] + canary: Optional[Dict[str, Any]] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class SharedSignals(BaseModel): + timestamp: datetime = Field(default_factory=datetime.utcnow) + service: str + metrics: Dict[str, float] = Field(default_factory=dict) + # exposure controls: per-signal metadata (allowlist, retention seconds) + exposure: Dict[str, Dict[str, Any]] = Field(default_factory=dict) + + +class PlanDelta(BaseModel): + id: str + plan_id: str + delta: Dict[str, Any] + author_id: str + timestamp: datetime = Field(default_factory=datetime.utcnow) + signature: Optional[str] = None # placeholder for signed deltas + + +class AuditEntry(BaseModel): + id: str + event: str + actor: str + detail: Dict[str, Any] = Field(default_factory=dict) + timestamp: datetime = Field(default_factory=datetime.utcnow) + + +class AuditLog(BaseModel): + entries: List[AuditEntry] = Field(default_factory=list) diff --git a/src/idea139_opendeployment_quilt_federated/registry.py b/src/idea139_opendeployment_quilt_federated/registry.py new file mode 100644 index 0000000..9ca289f --- /dev/null +++ b/src/idea139_opendeployment_quilt_federated/registry.py @@ -0,0 +1,43 @@ +from typing import Callable, Dict, Any +from . import models + +# if pydantic is not available in the test environment, fall back to the +# lightweight BaseModel defined in models.py +try: + from pydantic import BaseModel # type: ignore +except Exception: + BaseModel = models.BaseModel + + +class AdapterContract(BaseModel): + name: str + version: str + description: str = "" + + +class AdapterRegistry: + """Simple in-process registry for adapters with conformance checks.""" + + def __init__(self): + self._adapters: Dict[str, Dict[str, Any]] = {} + + def register_adapter(self, name: str, adapter: Any, contract: AdapterContract): + if name in self._adapters: + raise ValueError(f"adapter already registered: {name}") + # Basic conformance checks: adapter must implement publish_signal and fetch_plan + required = ["publish_signal", "fetch_plan"] + for r in required: + if not hasattr(adapter, r): + raise TypeError(f"adapter missing required method: {r}") + # check contract shape + contract = AdapterContract(**contract.dict()) if isinstance(contract, AdapterContract) else AdapterContract(**contract) + self._adapters[name] = {"adapter": adapter, "contract": contract} + + def get_adapter(self, name: str): + return self._adapters.get(name) + + def list_adapters(self): + return list(self._adapters.keys()) + + +registry = AdapterRegistry() diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..0f8f6c4 --- /dev/null +++ b/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running tests..." +pytest -q + +echo "Building package metadata..." +python3 -m build + +echo "All done." diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1828a60 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import os +import sys + +# Ensure the package in src/ is importable during tests +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SRC = os.path.join(ROOT, "src") +if SRC not in sys.path: + sys.path.insert(0, SRC) diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..f78e159 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,62 @@ +import asyncio +from datetime import datetime + +from idea139_opendeployment_quilt_federated import models, registry, coordinator +from idea139_opendeployment_quilt_federated.adapters.github_actions import GitHubActionsAdapter +from idea139_opendeployment_quilt_federated.adapters.jenkins import JenkinsAdapter + + +def test_models_validation(): + lp = models.LocalPlan(id="p1", service="svc", pipeline={"jobs": []}) + assert lp.service == "svc" + + ss = models.SharedSignals(service="svc", metrics={"failure_rate": 0.1}) + assert "failure_rate" in ss.metrics + + pd = models.PlanDelta(id="d1", plan_id="p1", delta={"action": "no_change"}, author_id="a") + assert pd.plan_id == "p1" + + +def test_registry_and_adapters(): + reg = registry.registry + # ensure clean state for test + # it's fine if adapters already registered; we just test registration API + + ga = GitHubActionsAdapter(repo="org/repo") + jn = JenkinsAdapter(jenkins_url="http://jenkins.local") + + # register temporary adapters with unique names + name1 = "test-ga" + name2 = "test-jn" + # unregister if present (be defensive) + if name1 in reg.list_adapters(): + # skip registration if already present + pass + else: + reg.register_adapter(name1, ga, registry.AdapterContract(name="ga", version="0.1")) + + if name2 in reg.list_adapters(): + pass + else: + reg.register_adapter(name2, jn, registry.AdapterContract(name="jn", version="0.1")) + + assert name1 in reg.list_adapters() + assert name2 in reg.list_adapters() + + +def test_coordinator_aggregate_and_propose(): + coord = coordinator.SimpleCoordinator() + + async def run(): + await coord.append_signal(models.SharedSignals(service="svc", metrics={"failure_rate": 0.02})) + await coord.append_signal(models.SharedSignals(service="svc", metrics={"failure_rate": 0.1})) + agg = await coord.aggregate() + assert "failure_rate" in agg + # average of .02 and .1 -> 0.06 + assert 0.059 < agg["failure_rate"] < 0.061 + + pd = await coord.propose_delta(service="svc") + # since average > 0.05, expect an add_canary action + assert pd.delta["action"] in ("add_canary", "no_change") + + asyncio.get_event_loop().run_until_complete(run())