G0: cell substrate — invariants made executable
Strand G0 complete and VERIFIED against real Postgres, not asserted.
- FastAPI plane, fail-closed provider seams, repair-pointer error taxonomy
- migration 001: all 10 tables incl. repo_type NOT NULL and model_cards (I-7)
- 17 invariant tests, ruff clean, vocabulary audit clean
Two bugs found by RUNNING it that review would not have caught:
1. SQLAlchemy Enum persists .name, not .value — so RepoState.deleted_soft
and CreatedVia.imported would have written labels migration 001 never
declared, failing at runtime rather than at review. Pinned via
values_callable.
2. op.create_table asks each Enum to emit its own CREATE TYPE with no
checkfirst, so the second reference raised DuplicateObject and the
migration died halfway. Types are now created once, referenced with
create_type=False.
Proven live, with the hostile env var set:
- I-12: COMMIT_SHA=deadbeef... in the environment, /version reports real HEAD.
That env pin is the documented root cause of nine sibling services
misreporting their commit; here it is structurally ignored.
- I-8: three unconfigured providers -> status degraded, HTTP 503, each saying
'refusing to report healthy'. No mock, no false green.
- G0.4: upgrade -> downgrade -> upgrade round-trip clean (10 -> 0 -> 10).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal file
@@ -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).
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ cloudflared/*.json
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.egg-info/
|
||||||
|
data/
|
||||||
|
.venv/
|
||||||
|
|||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -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"]
|
||||||
41
Makefile
Normal file
41
Makefile
Normal file
@@ -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
|
||||||
87
SUBSTRATE.md
Normal file
87
SUBSTRATE.md
Normal file
@@ -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.
|
||||||
30
alembic.ini
Normal file
30
alembic.ini
Normal file
@@ -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
|
||||||
53
alembic/env.py
Normal file
53
alembic/env.py
Normal file
@@ -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()
|
||||||
23
alembic/script.py.mako
Normal file
23
alembic/script.py.mako
Normal file
@@ -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"}
|
||||||
231
alembic/versions/001_genesis.py
Normal file
231
alembic/versions/001_genesis.py
Normal file
@@ -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.
|
||||||
0
api/app/__init__.py
Normal file
0
api/app/__init__.py
Normal file
87
api/app/buildinfo.py
Normal file
87
api/app/buildinfo.py
Normal file
@@ -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")
|
||||||
119
api/app/config.py
Normal file
119
api/app/config.py
Normal file
@@ -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()
|
||||||
108
api/app/errors.py
Normal file
108
api/app/errors.py
Normal file
@@ -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."
|
||||||
|
)
|
||||||
132
api/app/main.py
Normal file
132
api/app/main.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
0
api/app/models/__init__.py
Normal file
0
api/app/models/__init__.py
Normal file
323
api/app/models/core.py
Normal file
323
api/app/models/core.py
Normal file
@@ -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())
|
||||||
0
api/app/providers/__init__.py
Normal file
0
api/app/providers/__init__.py
Normal file
55
api/app/providers/base.py
Normal file
55
api/app/providers/base.py
Normal file
@@ -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}")
|
||||||
128
api/app/providers/registry.py
Normal file
128
api/app/providers/registry.py
Normal file
@@ -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)
|
||||||
0
api/app/routes/__init__.py
Normal file
0
api/app/routes/__init__.py
Normal file
75
api/app/routes/health.py
Normal file
75
api/app/routes/health.py
Normal file
@@ -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."
|
||||||
|
),
|
||||||
|
}
|
||||||
0
api/app/services/__init__.py
Normal file
0
api/app/services/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
236
api/tests/test_invariants.py
Normal file
236
api/tests/test_invariants.py
Normal file
@@ -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}"
|
||||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
@@ -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
|
||||||
45
docs/MEMBRANE.v1.md
Normal file
45
docs/MEMBRANE.v1.md
Normal file
@@ -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.
|
||||||
10
patches/README.md
Normal file
10
patches/README.md
Normal file
@@ -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.
|
||||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@@ -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"
|
||||||
90
scripts/vocab_audit.py
Executable file
90
scripts/vocab_audit.py
Executable file
@@ -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())
|
||||||
Reference in New Issue
Block a user