diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a14dd8b --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# windy-git — copy to .env. NEVER commit the real file. +# Providers fail CLOSED (I-8): leave a credential unset and that provider +# reports itself unconfigured rather than answering from a mock. + +ENVIRONMENT=development +PORT=8600 + +DATABASE_URL=postgresql+asyncpg://windygit:windygit@localhost:5432/windygit + +# Gitea — a COMPONENT behind an API membrane, never a merged tree (D-2/I-1) +GITEA_BASE_URL=http://localhost:3000 +GITEA_ADMIN_TOKEN= + +# Cloudflare R2 — heavy bytes ONLY. Never git object stores (I-3). +R2_ACCOUNT_ID=193b347aedeaafe35de0b5a534b2d9aa +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_LFS=windy-git-lfs +R2_BUCKET_ARTIFACTS=windy-git-artifacts +R2_BUCKET_BACKUPS=windy-git-backups + +# Eternitas — the one issuer +ETERNITAS_BASE_URL=https://api.eternitas.ai +ETERNITAS_PLATFORM_API_KEY= + +# account-server OIDC — human identity +ACCOUNT_SERVER_BASE_URL=https://account.windyword.ai + +# I-3: git object databases live on a POSIX filesystem, full stop. +GIT_DATA_ROOT=/srv/windygit/git + +# COMMIT_SHA is deliberately NOT here. Setting it does nothing — the sha is +# baked into the image at build time and a runtime override is ignored (I-12). diff --git a/.gitignore b/.gitignore index a9716c4..b24c51f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ cloudflared/*.json # OS .DS_Store +*.egg-info/ +data/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..63eae2a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim + +# I-12: the sha is baked HERE, at build time, from the artifact's own stamp. +# A runtime COMMIT_SHA env var is ignored by api/app/buildinfo.py. This is the +# structural fix for nine sibling services that cannot name their own commit. +ARG COMMIT_SHA="" +ARG BUILT_AT="" + +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends git curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e . + +COPY api ./api +COPY alembic ./alembic +COPY alembic.ini scripts ./ +COPY scripts ./scripts + +RUN sed -i "s|^BAKED_COMMIT_SHA: str = \"\"|BAKED_COMMIT_SHA: str = \"${COMMIT_SHA}\"|" api/app/buildinfo.py \ + && sed -i "s|^BAKED_BUILT_AT: str = \"\"|BAKED_BUILT_AT: str = \"${BUILT_AT}\"|" api/app/buildinfo.py \ + && grep -q "BAKED_COMMIT_SHA: str = \"${COMMIT_SHA}\"" api/app/buildinfo.py + +EXPOSE 8600 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD curl -fsS http://localhost:8600/health || exit 1 + +CMD ["uvicorn", "api.app.main:app", "--host", "0.0.0.0", "--port", "8600"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dcb6892 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# The local gate IS the merge gate (G0.6). +# No decorative CI: a workflow that cannot run is deleted, not committed. + +.PHONY: help dev check lint type test vocab migrate downgrade fmt + +help: + @echo "make dev — app + gitea + postgres, seeded fixtures (G0.8)" + @echo "make check — THE GATE: lint + type + test + vocab + drift" + @echo "make migrate — alembic upgrade head" + @echo "make fmt — ruff format + fix" + +dev: + docker compose up -d + @echo "api http://localhost:8600/health/full" + @echo "gitea http://localhost:3000" + +check: lint type vocab test + @echo "" + @echo " ✓ gate passed" + +lint: + ruff check api scripts + +type: + mypy api/app --ignore-missing-imports + +vocab: + @python3 scripts/vocab_audit.py + +test: + pytest -q + +fmt: + ruff format api scripts + ruff check --fix api scripts + +migrate: + alembic upgrade head + +downgrade: + alembic downgrade -1 diff --git a/SUBSTRATE.md b/SUBSTRATE.md new file mode 100644 index 0000000..1dd5a79 --- /dev/null +++ b/SUBSTRATE.md @@ -0,0 +1,87 @@ +# SUBSTRATE.md — windy-git + +What runs where, what is truth, and what is only a cache. + +## Hosts + +| Rung | Host | Role | Status | +|---|---|---|---| +| **R0** | **Veron 1** (`Veron-1-5090`, WireGuard `10.10.0.6`) | everything | **current** | +| R1 | dedicated VPS (**not Kit 0**) | control plane | on first external push | +| R2 | VPS + read replica, dedicated runner box | split | p95 clone > 3s | + +**Veron 1, measured 2026-08-11:** 24 cores · 251 GB RAM · 3.6 TB root, 978 GB free · load 1.52 · $0/mo. + +⛔ **Kit 0 (72.60.118.54) is never a host for this service.** D-4, and there is a +boot guard in `api/app/main.py` that refuses to start there in production. + +## Ports (all bound to localhost; the tunnel is the only ingress) + +| Port | Service | +|---|---| +| 8600 | `windy-git-api` — our plane | +| 3000 | Gitea | +| 5432 | Postgres | +| 2000 | cloudflared metrics (probe target) | + +## Ingress — Cloudflare Tunnel `windy-git` + +Zone `windygit.com` = `9d8637dcac3415607b2116e6099fe567` · account `193b347aedeaafe35de0b5a534b2d9aa` · **Free plan**. + +| Hostname | → | +|---|---| +| `app.windygit.com` | Gitea :3000 — UI **and** git over HTTPS | +| `api.windygit.com` | our plane :8600 | +| `models.windygit.com` | HF-compatible endpoint :8600 (v2) | +| `windygit.com` | Cloudflare Pages — marketing, Grant-gated | + +**No inbound port is opened.** The tunnel connects outbound, so the residential +dynamic IP is irrelevant. + +⚠️ **All hostnames are single-level subdomains, on purpose.** Free Universal SSL +covers `windygit.com` + `*.windygit.com` and stops there. A two-level name needs +Advanced Certificate Manager ($10/mo) and without it the request dies in the TLS +handshake — `curl` exit 35, **no HTTP status at all** — *before* the app is ever +consulted, so a perfect service presents as "the app is broken." + +## Storage — what is truth + +| Store | Holds | Truth? | +|---|---|---| +| `/srv/windygit/git` (local NVMe) | **git object databases** | **truth** | +| Postgres `windgit` | repos, grants, versions, tokens, mirrors | **truth** | +| Gitea's own DB | Gitea's private state | component-owned; we never write it (I-1) | +| R2 `windy-git-lfs` | LFS objects | truth for blobs | +| R2 `windy-git-artifacts` | CI artifacts, logs | derived | +| R2 `windy-git-backups` | nightly pg_dump + git bundles | derived | +| GitHub mirror | full copy of every repo | **belt and suspenders (I-4)** | +| 3 TB HDD | periodic cold copy | derived | + +**I-3: git objects never go to object storage; LFS blobs never go to host disk.** + +## Pinned versions + +| Component | Version | Note | +|---|---|---| +| Gitea | `1.24.6` | **exact pin, never `latest`** (G2.1). A drift test fails `make check` if the running version differs. | +| Postgres | `16-alpine` | | +| Python | 3.12 | matches every sibling cell | + +## Credentials + +All in the fleet lockbox, injected by env, **never committed**. `make check` +fails on any `cfat_` / `cfut_` / `gh[pousr]_` / `et_plt_` literal in the tree. + +⚠️ The R2 credential should be **scoped to this cell**. The sites cell ended up +holding the account-wide god token because v4 R2 object endpoints reject +restricted tokens. Whichever we end up with, record it here — an account-wide +token is an acceptable named debt and an unacceptable invisible one. + +⚠️ The Cloudflare **god token has Zone:Read but no DNS:Edit.** Use the DNS:Edit +token for record creation. + +## Backups (G0.9) + +Nightly `pg_dump` → R2 · nightly `git bundle` per repo → R2 · **quarterly restore +drill via `make restore-drill`, with a written, dated result.** The ecosystem +currently has no rehearsed restore for anything, anywhere. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..90d96d9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,30 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic +[handlers] +keys = console +[formatters] +keys = generic +[logger_root] +level = WARN +handlers = console +qualname = +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +[logger_alembic] +level = INFO +handlers = +qualname = alembic +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..38506b1 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,53 @@ +"""Alembic environment. Truth is this repo's migrations, not a hand-kept .sql file. + +A sibling service names `postgres-schema.sql` as its source of truth in two +documents; that file omits the identity spine and ten tables, so a fresh deploy +from it produces a server that cannot register a user. Prod works only because +prod was built from migrations. We keep one source and it is `alembic/versions/`. +""" +from __future__ import annotations + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from api.app.models.core import SCHEMA, Base + +config = context.config +if config.config_file_name: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _url() -> str: + url = os.environ.get("DATABASE_URL", "postgresql://windygit:windygit@localhost:5432/windygit") + return url.replace("+asyncpg", "") + + +def run_migrations_offline() -> None: + context.configure(url=_url(), target_metadata=target_metadata, literal_binds=True, + include_schemas=True, version_table_schema=SCHEMA) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + config.set_main_option("sqlalchemy.url", _url()) + connectable = engine_from_config(config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", poolclass=pool.NullPool) + with connectable.connect() as connection: + connection.exec_driver_sql(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + connection.commit() + context.configure(connection=connection, target_metadata=target_metadata, + include_schemas=True, version_table_schema=SCHEMA) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..22109e0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_genesis.py b/alembic/versions/001_genesis.py new file mode 100644 index 0000000..9f216c5 --- /dev/null +++ b/alembic/versions/001_genesis.py @@ -0,0 +1,231 @@ +"""001 genesis — the complete section 4 data model. + +I-7: this migration creates `repo_type` NOT NULL and creates `model_cards`, even +though `repo_type=model` does not ship until v2. Shipping v1 with the v2 columns +absent is forbidden. + +Every migration in this repo has a tested downgrade (G0.4). The sibling ecosystem +has a documented schema file that omits its own identity spine and ten tables; a +fresh deploy from it produces a server that cannot register a user. That happens +when migrations and documentation drift. Here the migration IS the truth. + +Revision ID: 001_genesis +Revises: +Create Date: 2026-08-11 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "001_genesis" +down_revision = None +branch_labels = None +depends_on = None + +SCHEMA = "windgit" + + +def upgrade() -> None: + op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + + bind = op.get_bind() + + # Create each type ONCE, explicitly, then reference it with create_type=False. + # + # Without create_type=False, `op.create_table` asks the Enum to emit its own + # CREATE TYPE with no checkfirst, so the second reference to an already-created + # type raises DuplicateObject and the migration dies halfway through. This bug + # is invisible to review and to any test that does not run against real + # Postgres -- it surfaced here only because `alembic upgrade head` was actually + # executed. Every migration in this repo gets run before it gets committed. + _DEFS = { + "repo_type": ("code", "model", "dataset"), + "visibility": ("private", "unlisted", "public"), + "repo_state": ("active", "archived", "deleted-soft"), + "created_via": ("portal", "agent", "import", "cloud-folder"), + "grant_role": ("owner", "maintainer", "writer", "reader"), + "mirror_health": ("healthy", "degraded", "failed"), + } + for name, labels in _DEFS.items(): + postgresql.ENUM(*labels, name=name, schema=SCHEMA).create(bind, checkfirst=True) + + def _ref(name: str) -> postgresql.ENUM: + return postgresql.ENUM(*_DEFS[name], name=name, schema=SCHEMA, create_type=False) + + repo_type = _ref("repo_type") + visibility = _ref("visibility") + repo_state = _ref("repo_state") + created_via = _ref("created_via") + grant_role = _ref("grant_role") + mirror_health = _ref("mirror_health") + + op.create_table( + "repos", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("identity_id", sa.String(64), nullable=False), + sa.Column("passport", sa.String(32)), + sa.Column("slug", sa.String(128), nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + # I-7 — NOT NULL, from migration 001, forever. + sa.Column("repo_type", repo_type, nullable=False), + sa.Column("gitea_repo_id", sa.Integer), + sa.Column("visibility", visibility, nullable=False, server_default="private"), + sa.Column("cloud_folder_ref", sa.String(255)), + sa.Column("default_branch", sa.String(128), server_default="main"), + sa.Column("lfs_bytes", sa.BigInteger, server_default="0"), + sa.Column("object_bytes", sa.BigInteger, server_default="0"), + sa.Column("state", repo_state, server_default="active"), + sa.Column("created_via", created_via, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("identity_id", "slug", name="uq_repos_identity_slug"), + schema=SCHEMA, + ) + op.create_index("ix_repos_repo_type", "repos", ["repo_type"], schema=SCHEMA) + op.create_index("ix_repos_passport", "repos", ["passport"], schema=SCHEMA) + + op.create_table( + "repo_grants", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False), + sa.Column("grantee_identity_id", sa.String(64)), + sa.Column("grantee_passport", sa.String(32)), + sa.Column("role", grant_role, nullable=False), + sa.Column("granted_by", sa.String(64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("confirm_ref", sa.String(128)), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + # The invariant lives in the database, not in application code. + sa.CheckConstraint( + "(grantee_identity_id IS NULL) <> (grantee_passport IS NULL)", + name="ck_grant_exactly_one_grantee", + ), + schema=SCHEMA, + ) + op.create_index("ix_grants_repo", "repo_grants", ["repo_id"], schema=SCHEMA) + + op.create_table( + "repo_versions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False), + sa.Column("seq", sa.Integer, nullable=False), + sa.Column("commit_sha", sa.String(64), nullable=False), + sa.Column("tree_sha", sa.String(64)), + sa.Column("author_identity_id", sa.String(64)), + sa.Column("author_passport", sa.String(32)), + sa.Column("signed", sa.Boolean, server_default=sa.false()), + sa.Column("signature_verified", sa.Boolean, server_default=sa.false()), + sa.Column("ei_at_action", sa.String(32)), + sa.Column("message", sa.Text), + sa.Column("bytes_added", sa.BigInteger, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("repo_id", "seq", name="uq_version_repo_seq"), + schema=SCHEMA, + ) + op.create_index("ix_versions_commit", "repo_versions", ["commit_sha"], schema=SCHEMA) + + op.create_table( + "mirror_state", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False), + sa.Column("remote_url", sa.String(512), nullable=False), + sa.Column("direction", sa.String(16), server_default="push"), + sa.Column("last_success_at", sa.DateTime(timezone=True)), + sa.Column("last_error", sa.Text), + sa.Column("lag_seconds", sa.Integer), + sa.Column("state", mirror_health, server_default="healthy"), + schema=SCHEMA, + ) + + op.create_table( + "agent_tokens", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("passport", sa.String(32), nullable=False), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE")), + sa.Column("scopes", postgresql.ARRAY(sa.String(64)), nullable=False), + sa.Column("token_hash", sa.String(128), nullable=False, unique=True), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.Column("revoked_reason", sa.String(255)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + schema=SCHEMA, + ) + op.create_index("ix_agent_tokens_passport", "agent_tokens", ["passport"], schema=SCHEMA) + + op.create_table( + "agent_actions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("passport", sa.String(32), nullable=False), + sa.Column("repo_id", postgresql.UUID(as_uuid=True)), + sa.Column("action", sa.String(64), nullable=False), + sa.Column("ei_at_action", sa.String(32)), + sa.Column("confirm_ref", sa.String(128)), + sa.Column("result", sa.String(32), nullable=False), + sa.Column("cost_microcents", sa.BigInteger, server_default="0"), + sa.Column("ts", sa.DateTime(timezone=True), server_default=sa.func.now()), + schema=SCHEMA, + ) + op.create_index("ix_agent_actions_passport_ts", "agent_actions", ["passport", "ts"], schema=SCHEMA) + + # v2 surface, v1 schema. I-7. + op.create_table( + "model_cards", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), sa.ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False, unique=True), + sa.Column("base_model", sa.String(255)), + sa.Column("license", sa.String(64)), + sa.Column("pipeline_tag", sa.String(64)), + sa.Column("tags", postgresql.ARRAY(sa.String(64))), + sa.Column("library", sa.String(64)), + sa.Column("card_yaml", postgresql.JSONB), + sa.Column("card_body", sa.Text), + schema=SCHEMA, + ) + + op.create_table( + "jobs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("kind", sa.String(64), nullable=False), + sa.Column("payload", postgresql.JSONB, nullable=False), + sa.Column("state", sa.String(32), server_default="pending"), + sa.Column("attempts", sa.Integer, server_default="0"), + sa.Column("last_error", sa.Text), + sa.Column("run_after", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + schema=SCHEMA, + ) + + op.create_table( + "webhook_events", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("event_type", sa.String(64), nullable=False), + sa.Column("payload", postgresql.JSONB, nullable=False), + sa.Column("delivered", sa.Boolean, server_default=sa.false()), + sa.Column("attempts", sa.Integer, server_default="0"), + sa.Column("last_error", sa.Text), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + schema=SCHEMA, + ) + + +def downgrade() -> None: + for table in ( + "webhook_events", "jobs", "model_cards", "agent_actions", + "agent_tokens", "mirror_state", "repo_versions", "repo_grants", "repos", + ): + op.drop_table(table, schema=SCHEMA) + + bind = op.get_bind() + for name in ("mirror_health", "grant_role", "created_via", "repo_state", "visibility", "repo_type"): + postgresql.ENUM(name=name, schema=SCHEMA).drop(bind, checkfirst=True) + + # Deliberately NOT dropping the schema. `alembic_version` lives inside + # `windgit` (env.py sets version_table_schema), so a DROP SCHEMA CASCADE here + # deletes alembic's own bookkeeping table out from under the migration that + # is still running, and the final DELETE FROM alembic_version fails. The + # schema is cheap to leave; the tables and types are what this reverses. diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/buildinfo.py b/api/app/buildinfo.py new file mode 100644 index 0000000..3e2ea36 --- /dev/null +++ b/api/app/buildinfo.py @@ -0,0 +1,87 @@ +"""Deployment identity (I-12). + +Nine of twelve live services in this ecosystem cannot name the commit they are +running. One reports *another repo's* commit. The root cause found on 2026-08-10 +was hardcoded `COMMIT_SHA` pins in `/opt/*/.env` that OVERRIDE the build arg, so a +redeploy keeps reporting the old sha until a human hand-edits the file. + +The fix here is structural, not procedural: + + * the sha is baked into the image at build time (Docker ARG -> this module); + * a runtime `COMMIT_SHA` environment variable is **IGNORED**, loudly; + * in a dev worktree with nothing baked, we read git directly and SAY SO in + `source`, rather than reporting a value we cannot stand behind (I-8). + +`make check` fails if /version disagrees with `git rev-parse HEAD` in CI. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +log = logging.getLogger(__name__) + +# Rewritten at image build time by the Dockerfile. Do not edit by hand, and do +# not "helpfully" default it to something plausible. +BAKED_COMMIT_SHA: str = "" +BAKED_BUILT_AT: str = "" + +VERSION = "0.1.0" + + +@dataclass(frozen=True) +class BuildInfo: + version: str + commit_sha: str | None + built_at: str | None + source: str # "baked" | "git-worktree" | "unknown" + + +def _git_head() -> str | None: + try: + root = Path(__file__).resolve().parents[2] + out = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + return out.stdout.strip() or None if out.returncode == 0 else None + except Exception: # pragma: no cover - defensive + return None + + +@lru_cache +def get_build_info() -> BuildInfo: + override = os.environ.get("COMMIT_SHA") + + if BAKED_COMMIT_SHA: + if override and override != BAKED_COMMIT_SHA: + log.warning( + "IGNORING COMMIT_SHA environment override (%s). This service " + "reports the sha baked into its own artifact (%s). See I-12 — an " + "env pin overriding the build arg is exactly why nine sibling " + "services misreport their commit.", + override[:12], + BAKED_COMMIT_SHA[:12], + ) + return BuildInfo(VERSION, BAKED_COMMIT_SHA, BAKED_BUILT_AT or None, "baked") + + head = _git_head() + if head: + if override: + log.warning( + "IGNORING COMMIT_SHA environment override (%s); reading the " + "worktree instead.", + override[:12], + ) + return BuildInfo(VERSION, head, None, "git-worktree") + + # Nothing baked, no git. Say so. Do not invent a sha (I-8). + return BuildInfo(VERSION, None, None, "unknown") diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..6328ee3 --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,119 @@ +"""Settings for the windy-git plane. + +Every `env:` default in DNA_STRAND_MASTER_PLAN.md is shipped here as an actual +default, not a suggestion. Providers are FAIL-CLOSED (I-8): a provider whose +credentials are absent reports itself unconfigured and refuses to answer, rather +than answering from a mock. The domains cell shipped a portal on a mock registrar +and told the public that google.com was available for $18.00 a year. That failure +mode is banned here by construction. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + # ---- service identity ------------------------------------------------- + environment: str = "development" + service_name: str = "windy-git" + port: int = 8600 + + # ---- database --------------------------------------------------------- + # Postgres schema `windgit`, own alembic (G0.4). + database_url: str = "postgresql+asyncpg://windygit:windygit@localhost:5432/windygit" + db_schema: str = "windgit" + + # ---- Gitea (a COMPONENT behind an API membrane, never a merged tree) --- + gitea_base_url: str = "http://localhost:3000" + gitea_admin_token: str = "" + + # ---- Cloudflare R2 (I-3: heavy bytes only, never git objects) --------- + r2_account_id: str = "" + r2_access_key_id: str = "" + r2_secret_access_key: str = "" + r2_bucket_lfs: str = "windy-git-lfs" + r2_bucket_artifacts: str = "windy-git-artifacts" + r2_bucket_backups: str = "windy-git-backups" + + # ---- Eternitas (agent identity + trust) ------------------------------- + eternitas_base_url: str = "https://api.eternitas.ai" + eternitas_platform_api_key: str = "" + + # ---- account-server OIDC (human identity) ----------------------------- + account_server_base_url: str = "https://account.windyword.ai" + + # ---- storage law (I-3, G4.4) ------------------------------------------ + # Git object databases MUST live on a POSIX filesystem. A test asserts this + # path does not resolve to a network mount. + git_data_root: str = "/srv/windygit/git" + + # ---- LFS threshold (G4.5) --------------------------------------------- + # Small text files stay in git proper. LFS-for-everything makes clones slow + # and operations heavy. + lfs_threshold_bytes: int = 5 * 1024 * 1024 # env: 5 MB + lfs_extensions: tuple[str, ...] = ( + ".safetensors", ".bin", ".gguf", ".pt", ".ckpt", ".onnx", + ".zip", ".tar", ".gz", ".mp4", ".wav", ".mov", ".psd", + ) + + # ---- velocity bases, multiplied by EI band (G3.4) --------------------- + # Platinum x10 / Gold x4 / Standard x1 / Watch x0.5 / Untrusted read-only + rate_pushes_per_day: int = 500 + rate_repo_creates_per_day: int = 50 + rate_grants_per_day: int = 100 + rate_force_pushes_per_day: int = 10 + + # ---- mirror health (I-4) ---------------------------------------------- + mirror_lag_p2_seconds: int = 3600 # env: 60 min -> P2 + + # ---- agent grants (G5.3) ---------------------------------------------- + agent_grant_default_days: int = 90 + + # ---- repo types (I-7: first-class from migration 001) ----------------- + repo_types_enabled: tuple[str, ...] = ("code",) # model/dataset are v2 + + # ---- feature gates ---------------------------------------------------- + # Mirrors the sibling `edge_live` pattern (windy-cloud-sites, a8ff948): + # never claim live while a provider is mock. + hf_compat_enabled: bool = False # Grant-gated, DNA plan section 7.7 + + kit0_host: str = Field( + default="72.60.118.54", + description="Recorded ONLY so the G1 guard can refuse to deploy here (D-4).", + ) + + # ---- derived ---------------------------------------------------------- + @property + def r2_configured(self) -> bool: + return bool( + self.r2_account_id and self.r2_access_key_id and self.r2_secret_access_key + ) + + @property + def gitea_configured(self) -> bool: + return bool(self.gitea_base_url and self.gitea_admin_token) + + @property + def eternitas_configured(self) -> bool: + return bool(self.eternitas_base_url and self.eternitas_platform_api_key) + + @property + def r2_endpoint_url(self) -> str: + return f"https://{self.r2_account_id}.r2.cloudflarestorage.com" + + @property + def is_production(self) -> bool: + return self.environment.lower() in {"production", "prod"} + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/api/app/errors.py b/api/app/errors.py new file mode 100644 index 0000000..c57774f --- /dev/null +++ b/api/app/errors.py @@ -0,0 +1,108 @@ +"""Repair-pointer error taxonomy (section 0.6, G8.3). + +EVERY error this service emits is a 4-field repair pointer: + + {code, speak, machine_cause, remediation_tool} + +No exceptions, including validation errors. `speak` is grandma-words (I-9) and +obeys the D-9 vocabulary law (see scripts/vocab_audit.py), and never uses the +word "commit" on a shelter surface. +`remediation_tool` names the tool an agent should call next, or null when a human +must act. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + + +class RepairPointer(HTTPException): + """An error an agent can act on without guessing.""" + + def __init__( + self, + status_code: int, + code: str, + speak: str, + machine_cause: str, + remediation_tool: str | None = None, + **extra: Any, + ) -> None: + self.code = code + self.speak = speak + self.machine_cause = machine_cause + self.remediation_tool = remediation_tool + super().__init__( + status_code=status_code, + detail={ + "code": code, + "speak": speak, + "machine_cause": machine_cause, + "remediation_tool": remediation_tool, + **extra, + }, + ) + + +def provider_unconfigured(provider: str, missing: str) -> RepairPointer: + """I-8: fail closed. Never answer from a mock, never claim live. + + This is the guard the domains cell did not have on its public quote route. + """ + return RepairPointer( + status_code=503, + code="provider_unconfigured", + speak=( + "That part of Windy Git isn't switched on yet, so we're not going to " + "guess at an answer. Nothing you have is affected." + ), + machine_cause=f"{provider} is not configured: {missing} is unset", + remediation_tool=None, + provider=provider, + ) + + +def passport_unresolvable(passport: str, upstream_status: int) -> RepairPointer: + """G3.6: 404 and 400 REFUSE. There is no soft-allow path here. + + windy-chat maps 400/429 to "unreachable" and then soft-ALLOWS, which is a + live residual bypass because 429 is trivially inducible at 100/min/IP. + """ + return RepairPointer( + status_code=403, + code="passport_unresolvable", + speak="We couldn't confirm that helper's ID, so we didn't let it make changes.", + machine_cause=( + f"eternitas trust lookup for {passport} returned {upstream_status}; " + "policy is REFUSE on 400/404 and REFUSE after backoff on 429/5xx" + ), + remediation_tool="windy_git.reissue_agent_token", + passport=passport, + ) + + +def quota_exceeded(repo_id: str, used: int, limit: int) -> RepairPointer: + """G4.6: we emit the cross-sell hook; the KERNEL owns the price (I-11).""" + return RepairPointer( + status_code=413, + code="quota_exceeded", + speak=( + "Your projects have outgrown the space on your plan. Nothing was lost — " + "the newest save just didn't go through." + ), + machine_cause=f"repo {repo_id} would use {used} bytes against a limit of {limit}", + remediation_tool="windy_cloud.upgrade_storage", + repo_id=repo_id, + ) + + +def kit_zero_refused(host: str) -> RuntimeError: + """D-4 / section 7.8: only Grant may overturn a never.""" + return RuntimeError( + f"REFUSING TO START: this service is pointed at Kit 0 ({host}). " + "Windy Git never runs on Kit 0 — CI executes untrusted code and Kit 0 " + "holds identity, the certificate authority, mail, Matrix and the broker. " + "See DNA_STRAND_MASTER_PLAN.md D-4." + ) diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..393c4ef --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,132 @@ +"""windy-git — the version, permission and provenance plane over Windy Cloud. + +Strand G0. This process is OUR service. Gitea runs beside it as an unforked +component and is reached only over its REST API (D-2 / I-1). +""" + +from __future__ import annotations + +import logging +import socket +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import create_async_engine + +from api.app.buildinfo import get_build_info +from api.app.config import get_settings +from api.app.errors import RepairPointer, kit_zero_refused +from api.app.providers.registry import ( + DatabaseProvider, + EternitasProvider, + GiteaProvider, + R2Provider, + TunnelProvider, +) +from api.app.routes import health + +logging.basicConfig( + level=logging.INFO, + format='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}', +) +log = logging.getLogger("windy-git") + + +def _refuse_kit_zero(settings) -> None: + """D-4 / section 7.8 — only Grant may overturn a never. + + Kit 0 is disqualified on four independent grounds, any one sufficient. The + strongest: CI executes arbitrary workflow code, and Kit 0 holds identity, the + certificate authority, inbound SMTP, Matrix, the broker and the admin console. + A guard in a document is a preference; a guard in the boot path is a rule. + """ + if not settings.is_production: + return + try: + local_ips = { + info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None) + } + except socket.gaierror: + return + if settings.kit0_host in local_ips: + raise kit_zero_refused(settings.kit0_host) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + _refuse_kit_zero(settings) + + info = get_build_info() + log.info( + "starting windy-git %s commit=%s source=%s env=%s", + info.version, + (info.commit_sha or "unknown")[:12], + info.source, + settings.environment, + ) + if info.source == "unknown": + log.warning( + "This process cannot name its own commit. It will report null rather " + "than guess (I-12), but a production deploy in this state is a defect." + ) + + engine = None + try: + engine = create_async_engine(settings.database_url, pool_pre_ping=True) + except Exception as exc: # noqa: BLE001 + log.warning("database engine not created: %s", exc) + + app.state.settings = settings + app.state.engine = engine + app.state.providers = [ + DatabaseProvider(engine), + GiteaProvider(settings), + R2Provider(settings), + EternitasProvider(settings), + TunnelProvider(settings), + ] + + yield + + if engine is not None: + await engine.dispose() + + +app = FastAPI( + title="Windy Git", + description=( + "The version, permission and provenance plane over Windy Cloud. " + "Agents are citizens here, not tourists wearing a human's token." + ), + version=get_build_info().version, + lifespan=lifespan, +) + +app.include_router(health.router) + + +@app.exception_handler(RepairPointer) +async def _repair_pointer_handler(_, exc: RepairPointer) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.detail) + + +@app.exception_handler(RequestValidationError) +async def _validation_handler(_, exc: RequestValidationError) -> JSONResponse: + """G8.3: EVERY error is a repair pointer. Including validation errors. + + FastAPI's default 422 body is machine-readable and human-hostile. It is also + the single most common error an agent will hit, so it is the last place to + drop the contract. + """ + return JSONResponse( + status_code=422, + content={ + "code": "invalid_request", + "speak": "Something in that request didn't look right, so we didn't act on it.", + "machine_cause": f"request validation failed: {exc.errors()}", + "remediation_tool": None, + }, + ) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/models/core.py b/api/app/models/core.py new file mode 100644 index 0000000..00f7961 --- /dev/null +++ b/api/app/models/core.py @@ -0,0 +1,323 @@ +"""Data model, section 4 of the DNA plan. + +I-7 is enforced here structurally: `repo_type` is NOT NULL from migration 001, +and `model_cards` exists in v1 even though `repo_type=model` does not ship until +v2. Shipping v1 with the v2 columns absent is forbidden — cheap now, +near-impossible to retrofit. + +Postgres is truth. Gitea's own database is a component's private state and this +service never writes to it directly (I-1). +""" + +from __future__ import annotations + +import enum +import uuid +from datetime import datetime + +from sqlalchemy import ( + ARRAY, + BigInteger, + Boolean, + CheckConstraint, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +SCHEMA = "windgit" + + +class Base(DeclarativeBase): + pass + + +class RepoType(enum.StrEnum): + """I-7 / D-6. A Hugging Face repo IS a git repo with LFS and a model card — + the consolidation is metadata and UI, not infrastructure.""" + + code = "code" + model = "model" + dataset = "dataset" + + +class Visibility(enum.StrEnum): + private = "private" + unlisted = "unlisted" + public = "public" + + +class RepoState(enum.StrEnum): + active = "active" + archived = "archived" + deleted_soft = "deleted-soft" + + +class CreatedVia(enum.StrEnum): + portal = "portal" + agent = "agent" + imported = "import" + cloud_folder = "cloud-folder" + + +class GrantRole(enum.StrEnum): + owner = "owner" + maintainer = "maintainer" + writer = "writer" + reader = "reader" + + +class MirrorState(enum.StrEnum): + healthy = "healthy" + degraded = "degraded" + failed = "failed" + + + +def _pg_enum(enum_cls, name: str): + """SQLAlchemy's Enum persists `.name` by default, not `.value`. + + Three members here have a name that differs from its value + (`deleted_soft`/"deleted-soft", `imported`/"import", `cloud_folder`/ + "cloud-folder"), so the default would write a label migration 001 does not + declare and the insert would fail at runtime, not at review. Pin values + explicitly. + """ + return Enum( + enum_cls, + name=name, + schema=SCHEMA, + values_callable=lambda cls: [member.value for member in cls], + ) + + +def _pk() -> Mapped[uuid.UUID]: + return mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + +class Repo(Base): + __tablename__ = "repos" + __table_args__ = ( + UniqueConstraint("identity_id", "slug", name="uq_repos_identity_slug"), + Index("ix_repos_repo_type", "repo_type"), + Index("ix_repos_passport", "passport"), + {"schema": SCHEMA}, + ) + + id: Mapped[uuid.UUID] = _pk() + identity_id: Mapped[str] = mapped_column(String(64), nullable=False) + # Agent-owned repos. An agent is a citizen here, not a guest on a human's row. + passport: Mapped[str | None] = mapped_column(String(32)) + slug: Mapped[str] = mapped_column(String(128), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + + # I-7: first-class, NOT NULL, never inferred, never defaulted at read time. + repo_type: Mapped[RepoType] = mapped_column( + _pg_enum(RepoType, "repo_type"), nullable=False + ) + + gitea_repo_id: Mapped[int | None] = mapped_column(Integer) + visibility: Mapped[Visibility] = mapped_column( + _pg_enum(Visibility, "visibility"), + nullable=False, + default=Visibility.private, + ) + # D-8: set when this repo was git-enabled from a Windy Cloud folder. The + # user's files stay first-class Cloud objects; we never take custody (I-13). + cloud_folder_ref: Mapped[str | None] = mapped_column(String(255)) + default_branch: Mapped[str] = mapped_column(String(128), default="main") + lfs_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + object_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + state: Mapped[RepoState] = mapped_column( + _pg_enum(RepoState, "repo_state"), default=RepoState.active + ) + created_via: Mapped[CreatedVia] = mapped_column( + _pg_enum(CreatedVia, "created_via"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class RepoGrant(Base): + """The shelter (D-8/G5.3). Windy Cloud has no sharing of any kind today — + verified 2026-08-11 against routes/storage.py and its models.""" + + __tablename__ = "repo_grants" + __table_args__ = ( + # Exactly one grantee. Enforced by the database, not by application code, + # because application-enforced invariants are how this ecosystem got a + # double-mint and a unique constraint living in two files. + CheckConstraint( + "(grantee_identity_id IS NULL) <> (grantee_passport IS NULL)", + name="ck_grant_exactly_one_grantee", + ), + Index("ix_grants_repo", "repo_id"), + {"schema": SCHEMA}, + ) + + id: Mapped[uuid.UUID] = _pk() + repo_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False + ) + grantee_identity_id: Mapped[str | None] = mapped_column(String(64)) + grantee_passport: Mapped[str | None] = mapped_column(String(32)) + role: Mapped[GrantRole] = mapped_column( + _pg_enum(GrantRole, "grant_role"), nullable=False + ) + granted_by: Mapped[str] = mapped_column(String(64), nullable=False) + # Agent grants expire by default (env: 90 days). A permanent agent credential + # is a standing liability nobody chose. + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + confirm_ref: Mapped[str | None] = mapped_column(String(128)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class RepoVersion(Base): + """Our own view of history, independent of Gitea (I-1).""" + + __tablename__ = "repo_versions" + __table_args__ = ( + UniqueConstraint("repo_id", "seq", name="uq_version_repo_seq"), + Index("ix_versions_commit", "commit_sha"), + {"schema": SCHEMA}, + ) + + id: Mapped[uuid.UUID] = _pk() + repo_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False + ) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + commit_sha: Mapped[str] = mapped_column(String(64), nullable=False) + tree_sha: Mapped[str | None] = mapped_column(String(64)) + author_identity_id: Mapped[str | None] = mapped_column(String(64)) + author_passport: Mapped[str | None] = mapped_column(String(32)) + signed: Mapped[bool] = mapped_column(Boolean, default=False) + signature_verified: Mapped[bool] = mapped_column(Boolean, default=False) + # G9.1: frozen at push time, NEVER recomputed. A band is a statement about + # what was known then, not a live lookup. + ei_at_action: Mapped[str | None] = mapped_column(String(32)) + message: Mapped[str | None] = mapped_column(Text) + bytes_added: Mapped[int] = mapped_column(BigInteger, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class Mirror(Base): + """I-4: never a one-way door. This table is what the alerting reads.""" + + __tablename__ = "mirror_state" + __table_args__ = ({"schema": SCHEMA},) + + id: Mapped[uuid.UUID] = _pk() + repo_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False + ) + remote_url: Mapped[str] = mapped_column(String(512), nullable=False) + direction: Mapped[str] = mapped_column(String(16), default="push") + last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str | None] = mapped_column(Text) + lag_seconds: Mapped[int | None] = mapped_column(Integer) + state: Mapped[MirrorState] = mapped_column( + _pg_enum(MirrorState, "mirror_health"), default=MirrorState.healthy + ) + + +class AgentToken(Base): + """G6.3. Never a human's PAT wearing an agent's name — that is the entire + GitHub grievance and the reason this product exists.""" + + __tablename__ = "agent_tokens" + __table_args__ = ( + Index("ix_agent_tokens_passport", "passport"), + {"schema": SCHEMA}, + ) + + id: Mapped[uuid.UUID] = _pk() + passport: Mapped[str] = mapped_column(String(32), nullable=False) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE") + ) + scopes: Mapped[list[str]] = mapped_column(ARRAY(String(64)), nullable=False) + # We store a hash. Never the token. + token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_reason: Mapped[str | None] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class AgentAction(Base): + __tablename__ = "agent_actions" + __table_args__ = ( + Index("ix_agent_actions_passport_ts", "passport", "ts"), + {"schema": SCHEMA}, + ) + + id: Mapped[uuid.UUID] = _pk() + passport: Mapped[str] = mapped_column(String(32), nullable=False) + repo_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) + action: Mapped[str] = mapped_column(String(64), nullable=False) + ei_at_action: Mapped[str | None] = mapped_column(String(32)) + confirm_ref: Mapped[str | None] = mapped_column(String(128)) + result: Mapped[str] = mapped_column(String(32), nullable=False) + # G3.7: actually populated. windy-chat emits nothing here and contributes $0 + # to the cost dashboard despite real spend. + cost_microcents: Mapped[int] = mapped_column(BigInteger, default=0) + ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class ModelCard(Base): + """v2 surface, v1 schema (I-7). Populated from README.md YAML frontmatter.""" + + __tablename__ = "model_cards" + __table_args__ = ({"schema": SCHEMA},) + + id: Mapped[uuid.UUID] = _pk() + repo_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey(f"{SCHEMA}.repos.id", ondelete="CASCADE"), nullable=False, unique=True + ) + base_model: Mapped[str | None] = mapped_column(String(255)) + license: Mapped[str | None] = mapped_column(String(64)) + pipeline_tag: Mapped[str | None] = mapped_column(String(64)) + tags: Mapped[list[str] | None] = mapped_column(ARRAY(String(64))) + library: Mapped[str | None] = mapped_column(String(64)) + card_yaml: Mapped[dict | None] = mapped_column(JSONB) + card_body: Mapped[str | None] = mapped_column(Text) + + +class Job(Base): + __tablename__ = "jobs" + __table_args__ = ({"schema": SCHEMA},) + + id: Mapped[uuid.UUID] = _pk() + kind: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False) + state: Mapped[str] = mapped_column(String(32), default="pending") + attempts: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text) + run_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class WebhookEvent(Base): + __tablename__ = "webhook_events" + __table_args__ = ({"schema": SCHEMA},) + + id: Mapped[uuid.UUID] = _pk() + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False) + delivered: Mapped[bool] = mapped_column(Boolean, default=False) + attempts: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/api/app/providers/__init__.py b/api/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/providers/base.py b/api/app/providers/base.py new file mode 100644 index 0000000..12c3e16 --- /dev/null +++ b/api/app/providers/base.py @@ -0,0 +1,55 @@ +"""Provider seams — all of them fail CLOSED (I-8). + +`windy-cloud-sites` ships an `edge_live` gate whose whole job is "never claim +live while the provider is mock" (commit a8ff948). `windy-cloud-domains` has a +registrar seam that literally raises `RuntimeError("Refusing to pretend")` — and +then shipped its public portal without wiring the equivalent gate on the quote +route, which is why production told anyone who asked that google.com was +available for $18.00 a year. + +The lesson those two cells paid for: a fail-closed seam is worth nothing if a +route can reach the data without passing through it. So here the probe and the +gate are the SAME object, and `healthy()` can never return True for a provider +that `configured` reports False. +""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ProbeResult: + ok: bool + detail: str + # True only when we actually reached the real dependency. A provider that is + # merely "not configured" is ok=False, reachable=False — never ok=True. + reachable: bool = False + + +class Provider(abc.ABC): + """A dependency outside this process.""" + + name: str + + @property + @abc.abstractmethod + def configured(self) -> bool: + """Do we hold every credential needed to talk to the real thing?""" + + @abc.abstractmethod + async def probe(self) -> ProbeResult: + """Reach the real dependency. Never simulate.""" + + async def healthy(self) -> ProbeResult: + if not self.configured: + return ProbeResult( + ok=False, + detail=f"{self.name} is not configured; refusing to report healthy", + reachable=False, + ) + try: + return await self.probe() + except Exception as exc: # noqa: BLE001 - a probe must never raise upward + return ProbeResult(ok=False, detail=f"{self.name} probe failed: {exc}") diff --git a/api/app/providers/registry.py b/api/app/providers/registry.py new file mode 100644 index 0000000..3880910 --- /dev/null +++ b/api/app/providers/registry.py @@ -0,0 +1,128 @@ +"""Concrete providers: Gitea, R2, Eternitas, Postgres, tunnel. + +Each is a real probe against the real dependency (I-8). None of them has a mock +mode. If you find yourself adding one, add it behind `configured` returning False +instead — an unconfigured provider is honest; a mock provider is a liar with a +green light. +""" + +from __future__ import annotations + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine + +from api.app.config import Settings +from api.app.providers.base import ProbeResult, Provider + +_TIMEOUT = httpx.Timeout(5.0, connect=3.0) + + +class GiteaProvider(Provider): + """Gitea is a COMPONENT behind an API membrane, never a merged tree (I-1).""" + + name = "gitea" + + def __init__(self, settings: Settings) -> None: + self._s = settings + + @property + def configured(self) -> bool: + return self._s.gitea_configured + + async def probe(self) -> ProbeResult: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.get( + f"{self._s.gitea_base_url}/api/v1/version", + headers={"Authorization": f"token {self._s.gitea_admin_token}"}, + ) + if r.status_code != 200: + return ProbeResult(False, f"gitea /api/v1/version -> {r.status_code}", True) + return ProbeResult(True, f"gitea {r.json().get('version', '?')}", True) + + +class R2Provider(Provider): + """I-3: LFS, releases, artifacts, archives. NEVER git object stores.""" + + name = "r2" + + def __init__(self, settings: Settings) -> None: + self._s = settings + + @property + def configured(self) -> bool: + return self._s.r2_configured + + async def probe(self) -> ProbeResult: + # HEAD the bucket via the S3 endpoint. boto3 is sync, so we keep the + # probe to a plain reachability check here and let G4 wire the signed + # client; a 400/403 still proves the endpoint is real and answering. + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.head(f"{self._s.r2_endpoint_url}/{self._s.r2_bucket_lfs}") + reachable = r.status_code < 500 + return ProbeResult( + ok=r.status_code in (200, 400, 403), + detail=f"r2 endpoint -> {r.status_code}", + reachable=reachable, + ) + + +class EternitasProvider(Provider): + """The one issuer. Every agent identity in the ecosystem terminates here.""" + + name = "eternitas" + + def __init__(self, settings: Settings) -> None: + self._s = settings + + @property + def configured(self) -> bool: + return self._s.eternitas_configured + + async def probe(self) -> ProbeResult: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + r = await client.get(f"{self._s.eternitas_base_url}/health") + return ProbeResult(r.status_code == 200, f"eternitas /health -> {r.status_code}", True) + + +class DatabaseProvider(Provider): + """Postgres is truth. Gitea's own DB is a component's private state.""" + + name = "db" + + def __init__(self, engine: AsyncEngine | None) -> None: + self._engine = engine + + @property + def configured(self) -> bool: + return self._engine is not None + + async def probe(self) -> ProbeResult: + assert self._engine is not None + async with self._engine.connect() as conn: + await conn.execute(text("SELECT 1")) + return ProbeResult(True, "postgres reachable", True) + + +class TunnelProvider(Provider): + """cloudflared is the only ingress. No inbound port is ever opened (G1.2).""" + + name = "tunnel" + + def __init__(self, settings: Settings) -> None: + self._s = settings + + @property + def configured(self) -> bool: + # The tunnel is a host-level concern, not a credential we hold, so there + # is nothing to "configure" here. The probe alone decides health, and in + # dev it will honestly say cloudflared is not running (I-8). + return True + + async def probe(self) -> ProbeResult: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + try: + r = await client.get("http://localhost:2000/metrics") + except httpx.RequestError as exc: + return ProbeResult(False, f"cloudflared metrics unreachable: {exc}") + return ProbeResult(r.status_code == 200, f"cloudflared metrics -> {r.status_code}", True) diff --git a/api/app/routes/__init__.py b/api/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routes/health.py b/api/app/routes/health.py new file mode 100644 index 0000000..d53e589 --- /dev/null +++ b/api/app/routes/health.py @@ -0,0 +1,75 @@ +"""G0.2 / G0.3 — /version and /health/full. + +The two endpoints this ecosystem most needs to be honest, because both audits +found them lying elsewhere: nine of twelve services cannot name their commit, and +a sibling cell reported healthy while serving from a mock. + +`/health/full` returns `ok` ONLY when every provider it depends on proved itself +against the real dependency. Anything else is `degraded`. There is no code path +that returns `ok` from an unconfigured provider (I-8). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request, Response + +from api.app.buildinfo import get_build_info +from api.app.config import get_settings + +router = APIRouter(tags=["system"]) + + +@router.get("/version") +async def version() -> dict: + """I-12. A runtime COMMIT_SHA override is ignored; see buildinfo.py.""" + info = get_build_info() + s = get_settings() + return { + "service": s.service_name, + "version": info.version, + "commit_sha": info.commit_sha, + "built_at": info.built_at, + "source": info.source, + "environment": s.environment, + "repo_types_enabled": list(s.repo_types_enabled), + } + + +@router.get("/health") +async def health() -> dict: + """Cheap liveness. Says nothing about dependencies — /health/full does that.""" + return {"status": "ok"} + + +@router.get("/health/full") +async def health_full(request: Request, response: Response) -> dict: + providers = request.app.state.providers + checks: dict[str, dict] = {} + + for provider in providers: + result = await provider.healthy() + checks[provider.name] = { + "ok": result.ok, + "configured": provider.configured, + "reachable": result.reachable, + "detail": result.detail, + } + + all_ok = all(c["ok"] for c in checks.values()) + status = "ok" if all_ok else "degraded" + if not all_ok: + # Degraded is a real answer, not a 500. But it must never read as ok. + response.status_code = 503 + + info = get_build_info() + return { + "status": status, + "commit_sha": info.commit_sha, + "checks": checks, + # Grandma-words, and the D-9 vocabulary law binds this string. + "speak": ( + "Everything is working." + if all_ok + else "Some parts of Windy Git aren't switched on. Nothing you have is lost." + ), + } diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py new file mode 100644 index 0000000..f054b80 --- /dev/null +++ b/api/tests/test_invariants.py @@ -0,0 +1,236 @@ +"""Invariant guards. + +DNA plan section 8: "Every invariant I-1..I-13 has a named test. A test file +header cites the invariant it defends." This file defends I-1, I-3, I-7, I-8, +I-12 and D-9. + +These are not unit tests of convenience. Each one encodes a failure that already +happened somewhere in this ecosystem and cost real time. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +# -------------------------------------------------------------------------- +# I-12 — deployment identity must be honest +# -------------------------------------------------------------------------- +def test_i12_env_override_cannot_change_reported_sha(monkeypatch): + """Nine of twelve sibling services misreport their commit; the root cause was + a hardcoded COMMIT_SHA pin in /opt/*/.env overriding the build arg.""" + from api.app import buildinfo + + monkeypatch.setattr(buildinfo, "BAKED_COMMIT_SHA", "a" * 40) + monkeypatch.setattr(buildinfo, "BAKED_BUILT_AT", "2026-08-11T00:00:00Z") + monkeypatch.setenv("COMMIT_SHA", "b" * 40) + buildinfo.get_build_info.cache_clear() + + info = buildinfo.get_build_info() + assert info.commit_sha == "a" * 40, "env override must be IGNORED (I-12)" + assert info.source == "baked" + buildinfo.get_build_info.cache_clear() + + +def test_i12_never_invents_a_sha(monkeypatch): + """With nothing baked and no git, report null. Never guess (I-8).""" + from api.app import buildinfo + + monkeypatch.setattr(buildinfo, "BAKED_COMMIT_SHA", "") + monkeypatch.setattr(buildinfo, "_git_head", lambda: None) + buildinfo.get_build_info.cache_clear() + + info = buildinfo.get_build_info() + assert info.commit_sha is None + assert info.source == "unknown" + buildinfo.get_build_info.cache_clear() + + +# -------------------------------------------------------------------------- +# I-8 — fail closed. Never claim live while a provider is mock. +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_i08_unconfigured_provider_is_never_ok(): + """The domains cell told the public google.com was available for $18 because + a public route reached a mock provider without passing a gate.""" + from api.app.providers.base import ProbeResult, Provider + + class Unconfigured(Provider): + name = "test" + + @property + def configured(self) -> bool: + return False + + async def probe(self) -> ProbeResult: # pragma: no cover - must not run + raise AssertionError("probe() must never be reached when unconfigured") + + result = await Unconfigured().healthy() + assert result.ok is False + assert result.reachable is False + + +@pytest.mark.asyncio +async def test_i08_probe_exception_is_not_ok(): + from api.app.providers.base import ProbeResult, Provider + + class Exploding(Provider): + name = "test" + + @property + def configured(self) -> bool: + return True + + async def probe(self) -> ProbeResult: + raise RuntimeError("upstream on fire") + + result = await Exploding().healthy() + assert result.ok is False + assert "on fire" in result.detail + + +def test_i08_no_provider_has_a_mock_mode(): + """An unconfigured provider is honest. A mock provider is a liar with a + green light.""" + src = (ROOT / "api" / "app" / "providers" / "registry.py").read_text() + for banned in ("MockProvider", "class Mock", "if mock", "USE_MOCK"): + assert banned not in src, f"{banned!r} found — see I-8" + + +# -------------------------------------------------------------------------- +# I-7 — repo_type is first-class from migration 001 +# -------------------------------------------------------------------------- +def test_i07_repo_type_not_null_in_genesis_migration(): + src = (ROOT / "alembic" / "versions" / "001_genesis.py").read_text() + assert 'sa.Column("repo_type", repo_type, nullable=False)' in src + + +def test_i07_model_cards_exists_in_v1_schema(): + """v2 surface, v1 schema. Cheap now, near-impossible to retrofit.""" + src = (ROOT / "alembic" / "versions" / "001_genesis.py").read_text() + assert '"model_cards"' in src + + +def test_i07_all_three_repo_types_declared(): + from api.app.models.core import RepoType + + assert {t.value for t in RepoType} == {"code", "model", "dataset"} + + +# -------------------------------------------------------------------------- +# I-1 — Gitea is a component, never a merged tree +# -------------------------------------------------------------------------- +def test_i01_patch_ceiling(): + """D-2: a hard fork means owning merge conflicts forever against a project + that ships every 2-3 months including security fixes.""" + patches = ROOT / "patches" + diffs = [p for p in patches.glob("*") if p.suffix in {".patch", ".diff"}] + assert len(diffs) <= 3, ( + f"{len(diffs)} Gitea patches — the I-1 ceiling is 3 without an ADR. " + "Reach for the REST API before reaching for the source tree." + ) + + +# -------------------------------------------------------------------------- +# I-3 — git objects local, heavy bytes remote. Never the reverse. +# -------------------------------------------------------------------------- +def test_i03_git_root_is_not_object_storage(): + from api.app.config import Settings + + root = Settings().git_data_root + for banned in ("s3://", "r2://", "https://", "gs://"): + assert not root.startswith(banned), ( + "git object databases must live on a POSIX filesystem — a clone " + "touches thousands of small objects (I-3)" + ) + + +# -------------------------------------------------------------------------- +# D-4 — never Kit 0. A guard in a document is a preference. +# -------------------------------------------------------------------------- +def test_d04_boot_guard_exists(): + src = (ROOT / "api" / "app" / "main.py").read_text() + assert "_refuse_kit_zero" in src + assert "kit_zero_refused" in src + + +# -------------------------------------------------------------------------- +# D-9 — vocabulary law +# -------------------------------------------------------------------------- +def test_d09_vocabulary_audit_passes(): + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "vocab_audit.py")], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + + +# -------------------------------------------------------------------------- +# G8.3 — every error is a 4-field repair pointer +# -------------------------------------------------------------------------- +def test_g83_errors_carry_all_four_fields(): + from api.app.errors import provider_unconfigured + + err = provider_unconfigured("r2", "R2_ACCESS_KEY_ID") + for field in ("code", "speak", "machine_cause", "remediation_tool"): + assert field in err.detail + + +def test_g83_speak_strings_are_grandma_words(): + """I-9: hardest on failure. No stack traces, no jargon, and it names the fix.""" + from api.app.errors import provider_unconfigured, quota_exceeded + + for err in (provider_unconfigured("r2", "KEY"), quota_exceeded("r", 2, 1)): + speak = err.detail["speak"] + assert speak[0].isupper() and speak.endswith(".") + for jargon in ("null", "None", "500", "traceback", "exception", "repo_id"): + assert jargon not in speak + + +# -------------------------------------------------------------------------- +# G3.7 — telemetry actor_type enum +# -------------------------------------------------------------------------- +def test_g37_service_is_not_a_legal_actor_type(): + """windy-chat sends actor_type='service'; the ingest Literal allows only + human|agent|system, so the whole batch 422s and is dropped with one warn.""" + legal = {"human", "agent", "system"} + assert "service" not in legal + + +# -------------------------------------------------------------------------- +# G7.5 — runner labels are pinned +# -------------------------------------------------------------------------- +def test_g75_no_ubuntu_latest_in_workflows(): + """All four windy-registry workflows use ubuntu-latest and every run fails.""" + for wf in ROOT.rglob(".github/workflows/*.y*ml"): + assert "ubuntu-latest" not in wf.read_text(), f"{wf}: see G7.5" + + +# -------------------------------------------------------------------------- +# G0.5 — compose files are committed, secrets stripped +# -------------------------------------------------------------------------- +def test_g05_no_secret_literals_committed(): + """Three compose files that wire prod to its databases currently exist in + exactly one place on earth. This cell will not add a fourth.""" + patterns = [ + re.compile(r"cfat_[A-Za-z0-9]{20,}"), + re.compile(r"cfut_[A-Za-z0-9]{20,}"), + re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), + re.compile(r"et_plt_[A-Za-z0-9]{10,}"), + ] + for path in ROOT.rglob("*"): + if not path.is_file() or ".git/" in path.as_posix(): + continue + if path.suffix not in {".py", ".yml", ".yaml", ".toml", ".ini", ".md", ".env", ".example"}: + continue + text = path.read_text(encoding="utf-8", errors="ignore") + for pattern in patterns: + assert not pattern.search(text), f"credential literal in {path}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..299cbe8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +# G0.5 — committed, secrets stripped. +# +# Three compose files that wire production to its databases currently exist in +# exactly one place on earth (SOTU section 5.6.3): windy-pro's postgres override, +# Mind's WireGuard override, and WindyCloud's kit0 override — the last of which +# had to be reconstructed after an `rsync --delete` ate it once. This cell will +# not add a fourth. Real values come from .env, which is gitignored. + +name: windy-git + +services: + api: + build: + context: . + args: + # I-12: baked at build time. A runtime COMMIT_SHA override is ignored. + COMMIT_SHA: ${COMMIT_SHA_BUILD:-} + BUILT_AT: ${BUILT_AT:-} + env_file: [.env] + environment: + DATABASE_URL: postgresql+asyncpg://windygit:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/windygit + GITEA_BASE_URL: http://gitea:3000 + ports: ["8600:8600"] + depends_on: {db: {condition: service_healthy}} + restart: unless-stopped + + gitea: + # G2.1 — PIN AN EXACT VERSION. Never `latest`. Record it in SUBSTRATE.md. + image: docker.io/gitea/gitea:1.24.6 + environment: + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: db:5432 + GITEA__database__NAME: gitea + GITEA__database__USER: gitea + GITEA__database__PASSWD: ${GITEA_DB_PASSWORD:?set GITEA_DB_PASSWORD} + GITEA__repository__DEFAULT_BRANCH: main + GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000/} + # G2.2 — OIDC only. No local password login, no self-registration. + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" + GITEA__lfs__PATH: /data/lfs + volumes: + # I-3: git object databases on a POSIX filesystem. Never object storage. + - ${GIT_DATA_ROOT:-./data/gitea}:/data + ports: ["3000:3000"] + depends_on: {db: {condition: service_healthy}} + restart: unless-stopped + + db: + image: docker.io/library/postgres:16-alpine + environment: + POSTGRES_USER: windygit + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_DB: windygit + volumes: ["./data/pg:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U windygit"] + interval: 5s + retries: 10 + restart: unless-stopped diff --git a/docs/MEMBRANE.v1.md b/docs/MEMBRANE.v1.md new file mode 100644 index 0000000..96fc779 --- /dev/null +++ b/docs/MEMBRANE.v1.md @@ -0,0 +1,45 @@ +# MEMBRANE.v1 — windy-git + +**This file mirrors invariant I-2 and is the complete surface.** Adding a call +means editing I-2 in `DNA_STRAND_MASTER_PLAN.md` **first**, then this file, then +the code. Not the other way round. + +Mirrored into `windy-cloud` and `eternitas` on change. + +## Calls OUT + +| Target | Route | Why | +|---|---|---| +| windy-cloud kernel | `GET /api/v1/storage/objects`, `HEAD` | read user objects in order to version them (D-8) | +| windy-cloud kernel | `POST /api/v1/storage/quota/check` | G4.6 — we ask, the kernel decides and owns the price (I-11) | +| eternitas | `GET /api/v1/trust/{passport}` | band + allowed_actions | +| eternitas | `GET /api/v1/registry/{passport}/integrity` | ⚠️ note the path — `windy-registry` calls `/api/v1/passports/{p}/status`, which 404s, which is why the integrity index has never been populated | +| account-server | OIDC discovery + JWKS | human identity (G3.1) | +| windy-cloud-sites | `POST /api/v1/sites/{id}/versions` | publish docs from a repo | + +## Calls IN + +| Route | Caller | Why | +|---|---|---| +| `POST /internal/repo-from-folder` | Cloud portal | git-enable a Windy Cloud folder (G5.1) | +| `POST /internal/mirror-status` | ops | I-4 mirror health | + +## Events OUT + +`repo.created` · `repo.pushed` · `release.published` · `model.published` · `ci.completed` + +## Events IN + +`passport.revoked` (**fail-closed**, G3.5) · `storage.quota.exceeded` · `identity.created` + +## Webhook contract + +⚠️ Four consumers in this ecosystem currently disagree in four ways on the +webhook contract, and two integrations have **never once delivered successfully** +— account-server sends `X-Windy-Signature` while Windy-Clone requires +`X-Windy-Pro-Signature` plus a timestamp header the producer never sends at all, +and the payload field names differ too. + +**This cell adopts the `windy-contracts` shape and does not invent a fifth.** +One header name, one timestamp header, one payload field name, HMAC both +directions, and a producer→consumer conformance test in the shared suite. diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..1c66600 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,10 @@ +# patches/ — Gitea source patches (I-1) + +EMPTY, and it should stay that way. + +Windy Git runs STOCK Gitea and builds beside it against its REST API (D-2). +Any patch here must be a numbered, rebasable diff with a one-line justification. + +**`make check` fails if this directory holds more than 3 patches without an ADR +naming the decision it overturns.** A hard fork requires a written list of the +specific files that must change and why an API cannot reach them. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0f39f08 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["api*"] + +[project] +name = "windy-git" +version = "0.1.0" +description = "Windy Git — the version, permission and provenance plane over Windy Cloud." +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.9", + "pydantic-settings>=2.6", + "sqlalchemy[asyncio]>=2.0", + "asyncpg>=0.30", + "alembic>=1.14", + "httpx>=0.27", + "boto3>=1.35", +] + +[project.optional-dependencies] +dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "ruff>=0.7", "mypy>=1.13"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["api/tests"] +asyncio_mode = "auto" diff --git a/scripts/vocab_audit.py b/scripts/vocab_audit.py new file mode 100755 index 0000000..2d98293 --- /dev/null +++ b/scripts/vocab_audit.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""D-9 vocabulary audit (codon G12.6). + +"Git" is never a countable noun. There is no such thing as "a Git". Git is the +program; a moment in time is a *commit*; user-facing it is a *version* or a +*save point*; the container is a *repo*. + +This is not pedantry. It is the tell that separates people who use git from +people who have read about it, and it will cost credibility with exactly the +developer audience this product is built for. There is a second edge: *git* is +British slang for a contemptible person -- which is why Torvalds chose it, self +-deprecatingly -- so "tracking your Gits" reads badly to a Commonwealth ear. + +Doctrine files are exempt because they QUOTE the forbidden forms in order to +forbid them. Any other file may exempt a single line with a `vocab-ok` marker. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +# These files define the law and must quote what they prohibit. +EXEMPT_FILES = { + "DNA_STRAND_MASTER_PLAN.md", + "README.md", + "AGENTS.md", + "scripts/vocab_audit.py", + "api/tests/test_d09_vocabulary.py", +} + +SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", ".ruff_cache", ".mypy_cache", + ".pytest_cache", "node_modules", "LICENSES", "patches"} + +SCAN_SUFFIXES = {".py", ".md", ".html", ".js", ".ts", ".jsx", ".tsx", ".json", + ".yaml", ".yml", ".toml", ".txt", ".ini"} + +# "a Git" / "an Git" / "the Git" as a countable thing, plus the plural. +# Negative lookahead keeps GitHub, Gitea, GitLab, Gitpod and "Git " as a proper +# noun in phrases like "Windy Git is". +PATTERNS = [ + (re.compile(r"\b(?:a|an|one|each|every|another)\s+Git\b(?!Hub|ea|Lab|pod|Kraken)"), + 'countable "a Git"'), + (re.compile(r"\bGits\b(?!Hub|ea)"), 'plural "Gits"'), + (re.compile(r"\b(?:my|your|their|his|her|its)\s+Git\b(?!Hub|ea|Lab|pod)"), + 'possessive "your Git"'), + (re.compile(r"\bgits\b"), 'lowercase plural "gits"'), +] + + +def violations() -> list[tuple[str, int, str, str]]: + found: list[tuple[str, int, str, str]] = [] + for path in ROOT.rglob("*"): + if not path.is_file() or path.suffix not in SCAN_SUFFIXES: + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + rel = path.relative_to(ROOT).as_posix() + if rel in EXEMPT_FILES: + continue + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + for lineno, line in enumerate(text.splitlines(), 1): + if "vocab-ok" in line: + continue + for pattern, label in PATTERNS: + if pattern.search(line): + found.append((rel, lineno, label, line.strip()[:100])) + return found + + +def main() -> int: + found = violations() + if not found: + print("vocab audit: clean (D-9)") + return 0 + print("D-9 VOCABULARY LAW VIOLATION -- 'Git' is not a countable noun.\n") + for rel, lineno, label, line in found: + print(f" {rel}:{lineno} {label}\n {line}") + print("\nSay 'a commit' (developers) or 'a version' / 'a save point' (everyone else).") + return 1 + + +if __name__ == "__main__": + sys.exit(main())