🔥
Some checks failed
CI pipeline for ffd-indexer / checks (go test + golangci) (push) Failing after 26s
CI pipeline for ffd-indexer / build binaries + containers (push) Failing after 3s
CI pipeline for ffd-indexer / format (gofmt + nixfmt) (push) Failing after 3s
CI pipeline for ffd-indexer / integration (live scrape + Typesense) (push) Failing after 20s
Some checks failed
CI pipeline for ffd-indexer / checks (go test + golangci) (push) Failing after 26s
CI pipeline for ffd-indexer / build binaries + containers (push) Failing after 3s
CI pipeline for ffd-indexer / format (gofmt + nixfmt) (push) Failing after 3s
CI pipeline for ffd-indexer / integration (live scrape + Typesense) (push) Failing after 20s
This commit is contained in:
parent
bde13e9b68
commit
63c6f4b761
1 changed files with 3319 additions and 184 deletions
423
DOCS.md
Normal file
423
DOCS.md
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
# ffd-indexer — documentation and reference
|
||||
|
||||
This file holds the reference material for the **Federated Forge Discovery
|
||||
Indexer**: how scraping works, the exact request volume, the full configuration
|
||||
reference, the Prometheus metric reference and PromQL queries, the supported
|
||||
forge types and type detection, the NixOS module, and development setup.
|
||||
|
||||
See [README.md](README.md) for the project's purpose and quick start.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Scrape behavior](#scrape-behavior)
|
||||
- [Robots.txt](#robotstxt)
|
||||
- [Listing repositories](#listing-repositories)
|
||||
- [Enrichment](#enrichment)
|
||||
- [Request volume](#request-volume)
|
||||
- [Error handling](#error-handling)
|
||||
- [HTTP transport](#http-transport)
|
||||
- [HTTP/2](#http2)
|
||||
- [HTTP/3](#http3)
|
||||
- [Forcing a protocol](#forcing-a-protocol)
|
||||
- [Configuration reference](#configuration-reference)
|
||||
- [Prometheus metrics](#prometheus-metrics)
|
||||
- [Metric reference](#metric-reference)
|
||||
- [PromQL queries](#promql-queries)
|
||||
- [Metrics daemon](#metrics-daemon)
|
||||
- [Supported forge types](#supported-forge-types)
|
||||
- [Type detection](#type-detection)
|
||||
- [Detection detail](#detection-detail)
|
||||
- [NixOS module](#nixos-module)
|
||||
- [Development](#development)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Two binaries, a SQLite store, and an external search UI:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Forges["Configured forges"]
|
||||
F1["forge A"]
|
||||
F2["forge B"]
|
||||
end
|
||||
|
||||
subgraph Indexer["ffd-indexer · one-shot scrape"]
|
||||
IV["robots.txt check \n repo listing \n per-repo enrichment"]
|
||||
end
|
||||
|
||||
subgraph Metrics["ffd-metrics · long-running daemon"]
|
||||
MV["render store as Prometheus metrics"]
|
||||
end
|
||||
|
||||
DB[("SQLite \n projects · forge_status")]
|
||||
TS["Typesense \n search index"]
|
||||
PROM["Prometheus"]
|
||||
UI["Search UI"]
|
||||
|
||||
F1 --> IV
|
||||
F2 --> IV
|
||||
IV -->|writes| DB
|
||||
IV -->|syncs index| TS
|
||||
DB -->|reads on each scrape| MV
|
||||
MV -->|/metrics| PROM
|
||||
UI -->|queries| TS
|
||||
```
|
||||
|
||||
*(GitHub renders Mermaid diagrams natively in Markdown; no image to maintain.)*
|
||||
|
||||
- **`ffd-indexer`** — the scraper. Runs once per schedule, honors robots.txt,
|
||||
scrapes every configured forge, records per-forge status and upserts each
|
||||
project into SQLite, then syncs the index to Typesense.
|
||||
- **`ffd-metrics`** — a long-running daemon. Reads the SQLite store on every
|
||||
Prometheus scrape and renders it at `/metrics`. It **never** touches the
|
||||
forges itself.
|
||||
- **SQLite** (`db_path`) — the source of truth. Holds the `projects` table
|
||||
(with per-project forge identity and languages) and the `forge_status` table
|
||||
(per-forge online flag, detected type, software version, last error).
|
||||
- **Typesense** (`typesense`) — optional full-text search index over the
|
||||
stored projects, searched by the UI.
|
||||
- **Search UI** — a frontend that queries the Typesense index (not shipped in
|
||||
this repository); the indexer's sync keeps the collection searchable by it.
|
||||
|
||||
### Binaries
|
||||
|
||||
| Output | Package path | What it is |
|
||||
|---|---|---|
|
||||
| `ffd-indexer` | `git.xengi.de/ffd/indexer` | one-shot scraper + sync |
|
||||
| `ffd-metrics` | `git.xengi.de/ffd/indexer/cmd/ffd-metrics` | long-running metrics daemon |
|
||||
|
||||
Both are built from the flake (`nix build .#default .#ffd-metrics`); the
|
||||
default package installs both binaries.
|
||||
|
||||
---
|
||||
|
||||
## Scrape behavior
|
||||
|
||||
The indexer connects to each forge over HTTPS, trying to negotiate HTTP/2 via
|
||||
ALPN for lower latency and multiplexing, with HTTP/1.1 as the fallback. A
|
||||
single shared transport is used for robots checks, type detection and the
|
||||
scrape itself, so keep-alive connections are reused across a host's requests.
|
||||
(Full transport detail: [HTTP transport](#http-transport).)
|
||||
|
||||
### Robots.txt
|
||||
|
||||
Before any API call, the indexer fetches `GET /robots.txt` and consults the
|
||||
rules for the `User-Agent: *` group. If the instance disallows `/api/` (the
|
||||
API root), the instance is **skipped** — robots rules are honored for both
|
||||
scraping and type detection. A missing robots.txt (404) is treated as
|
||||
"allowed".
|
||||
|
||||
### Listing repositories
|
||||
|
||||
Public source repositories are listed via the repository search API
|
||||
(`GET /api/v1/repos/search`), paginated at 50 per page. Forks and mirrors are
|
||||
excluded.
|
||||
|
||||
### Enrichment
|
||||
|
||||
For each repository, the indexer:
|
||||
|
||||
- reads the repository root directory **once** (`GET /api/v1/repos/{o}/{r}/contents/`);
|
||||
this single listing is shared by README selection and license detection;
|
||||
- fetches topics, languages, latest commit, and latest release;
|
||||
- fetches the README (raw); and
|
||||
- detects SPDX licenses from the license files present in the repo.
|
||||
|
||||
### Request volume
|
||||
|
||||
**Per instance:**
|
||||
|
||||
| Request | Count |
|
||||
|---|---|
|
||||
| `GET /robots.txt` | 1 |
|
||||
| `GET /api/v1/repos/search` (page) | `ceil(N/50)` where N = total repos |
|
||||
|
||||
**Per repository:**
|
||||
|
||||
| Request | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| list root contents | `GET /api/v1/repos/{o}/{r}/contents/` | shared: README selection + license scan (1 call) |
|
||||
| languages | `GET /api/v1/repos/{o}/{r}/languages` | `Project.Languages` |
|
||||
| topics | `GET /api/v1/repos/{o}/{r}/topics` | `Project.Topics` |
|
||||
| latest commit | `GET /api/v1/repos/{o}/{r}/commits?limit=1` | `Project.LatestCommit` |
|
||||
| latest release | `GET /api/v1/repos/{o}/{r}/releases/latest` | `Project.LatestRelease` |
|
||||
| README | `GET /api/v1/repos/{o}/{r}/raw/{branch}/{file}` | `Project.Readme` |
|
||||
| license files | 1+ × `GET /api/v1/repos/{o}/{r}/raw/{branch}/{file}` | SPDX detection |
|
||||
|
||||
So roughly **6–8 API requests per repository**, plus the paginated search
|
||||
requests and a single robots.txt check per instance.
|
||||
|
||||
### Error handling
|
||||
|
||||
Errors are non-fatal: an instance or a single repo that fails is skipped and
|
||||
the run continues. A failed forge is still recorded in `forge_status` with
|
||||
`online=0` and its last error, so the metrics daemon always has a row per
|
||||
configured forge. A failure to sync Typesense at the end of a run is fatal
|
||||
(loud failure), so the scheduler can retry the run.
|
||||
|
||||
---
|
||||
|
||||
## HTTP transport
|
||||
|
||||
The shared transport always tries to use the **newest HTTP version available**
|
||||
and automatically falls back when it isn't available:
|
||||
|
||||
- keep-alive connections are reused across a host's requests (pagination and
|
||||
per-repo enrichment don't re-handshake);
|
||||
- HTTP/1.1 vs HTTP/2 is negotiated automatically by Go via ALPN
|
||||
(`ForceAttemptHTTP2` is set because the TLS config is custom);
|
||||
- HTTP/3 is tried first (per host) and falls back to HTTP/2 or HTTP/1.1 when
|
||||
the host doesn't have it.
|
||||
|
||||
### HTTP/2
|
||||
|
||||
HTTP/2 is negotiated over TLS via ALPN whenever the server supports it
|
||||
(verified against live forges), falling back to HTTP/1.1 otherwise. This gives
|
||||
multiplexing and lower latency without any per-host configuration.
|
||||
|
||||
### HTTP/3
|
||||
|
||||
HTTP/3 is supported via `github.com/quic-go/quic-go/http3` and is tried
|
||||
**first** on each host automatically — no manual opt-in is needed:
|
||||
|
||||
- The first request to a host attempts HTTP/3 (newest available); if the host
|
||||
answers over h3 it is used from then on.
|
||||
- If the h3 attempt fails (e.g. the host has no UDP/QUIC), the request
|
||||
transparently falls back to HTTP/2 or HTTP/1.1, so an h3 failure is never
|
||||
visible to the caller.
|
||||
- A **per-host negative cache** (TTL ~1h) remembers hosts that failed h3, so a
|
||||
host without h3 isn't re-triggered with a QUIC handshake on every request.
|
||||
Once the TTL expires, h3 is tried again in case the host started serving it.
|
||||
- An `Alt-Svc: h3=` response header records a positive capability (confirms
|
||||
h3 rather than gating it — the default is already to try).
|
||||
- `scraper.EnableHTTP3(host)` is only an override that keeps trying h3 even
|
||||
during a negative cool-down; normal operation never needs it.
|
||||
|
||||
Note: Go 1.27's stdlib has no HTTP/3 client and `x/net/http3` is only a
|
||||
registration shim; this is why the quic-go transport provides it.
|
||||
|
||||
### Forcing a protocol
|
||||
|
||||
If you set an explicit `url: http://...` in the config, the indexer connects
|
||||
with plain HTTP (HTTP/1.1) instead of HTTPS. The stored identity and metrics
|
||||
labels always use the bare hostname regardless of the connection scheme.
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Both `ffd-indexer` and `ffd-metrics` read the same YAML file
|
||||
(`-config <path>`, default `config.yaml`).
|
||||
|
||||
```yaml
|
||||
db_path: "./index.db"
|
||||
typesense:
|
||||
host: "http://localhost:8108"
|
||||
api_key: "dev-key" # inline key, or
|
||||
# api_key_file: /run/secrets/typesense # a secret file (takes precedence)
|
||||
metrics:
|
||||
listen: "127.0.0.1:9090"
|
||||
forges:
|
||||
- url: git.example.org
|
||||
type: forgejo
|
||||
- url: git.example.net
|
||||
type: auto # default when omitted
|
||||
```
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `db_path` | string | Path to the SQLite database. |
|
||||
| `typesense.host` | string | Typesense server address. |
|
||||
| `typesense.api_key` | string | Typesense API key (inline). |
|
||||
| `typesense.api_key_file` | string | Path to a file containing the API key (e.g. sops/agenix secret). Takes precedence over `api_key`; contents are trimmed of surrounding whitespace. |
|
||||
| `metrics.listen` | string (optional) | Address `ffd-metrics` binds for `/metrics`. The whole `metrics:` block may be omitted when metrics are not used. |
|
||||
| `forges[].url` | string | Forge bare hostname (FQDN); a leading scheme is accepted to force the connection protocol. Identity is always the bare hostname. |
|
||||
| `forges[].type` | string | `forgejo` \| `gitea` (pinned, recommended) or `auto` (default, experimental). |
|
||||
|
||||
A `type` other than `forgejo`, `gitea` or `auto` is rejected at startup. See
|
||||
[Supported forge types](#supported-forge-types) for detection behavior.
|
||||
|
||||
### Typesense API key
|
||||
|
||||
The API key can be given inline (`typesense.api_key`) or read from a file
|
||||
(`typesense.api_key_file`) — the file takes precedence when both are set. The
|
||||
file is read at startup and trimmed of surrounding whitespace/newlines, so a
|
||||
secret deployed via **sops** or **agenix** (e.g. at `/run/secrets/...`) can be
|
||||
referenced directly without the key ever appearing in the config or the Nix
|
||||
store.
|
||||
|
||||
### Container
|
||||
|
||||
- `nix build .#container` — an image running `ffd-indexer` once; expects a
|
||||
config mounted at `/etc/ffd-indexer/config.yaml` (see README quick start).
|
||||
`db_path` is resolved relative to the container working directory, so mount a
|
||||
volume. The image bundles CA certificates so HTTPS to forges works.
|
||||
- `nix build .#container-metrics` — an image running the `ffd-metrics` daemon,
|
||||
exposing `:9090`.
|
||||
- `nix run .#default -- -config config.yaml` — run the indexer from the flake.
|
||||
- `nix run .#ffd-metrics -- -config /etc/ffd-indexer/config.yaml` — run the
|
||||
metrics daemon from the flake.
|
||||
|
||||
---
|
||||
|
||||
## Prometheus metrics
|
||||
|
||||
A separate long-running binary, `ffd-metrics`, serves the state of the index
|
||||
as Prometheus metrics at `/metrics`. It never scrapes the forges itself: the
|
||||
indexer's scheduled runs record per-forge status into the SQLite database, and
|
||||
`ffd-metrics` renders that state on each scrape.
|
||||
|
||||
Both binaries share the same config file. Configure the listen address under
|
||||
`metrics.listen` (e.g. `127.0.0.1:9090`, default).
|
||||
|
||||
### Metric reference
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|---|---|---|---|
|
||||
| `ffd_forge_info` | gauge | `forge_url`, `forge_type`, `forge_version` | Constant 1 per forge with its detected type and software version |
|
||||
| `ffd_forge_online` | gauge | `forge_url`, `forge_type` | 1 if the forge was reachable and scraped successfully on its last run, else 0 (so offline forges still appear) |
|
||||
| `ffd_repository_info` | gauge | `repo_url`, `forge_url`, `forge_type` | Constant 1 per stored repository |
|
||||
| `ffd_repository_languages` | gauge | `repo_url`, `language`, `forge_url`, `forge_type` | Constant 1 per (repository, language) pair |
|
||||
|
||||
### PromQL queries
|
||||
|
||||
Everything beyond the primitive metrics is derived with PromQL:
|
||||
|
||||
- repositories per forge: `count by (forge_url) (ffd_repository_info)`
|
||||
- repositories overall: `count(ffd_repository_info)`
|
||||
- forges per type: `count by (forge_type) (ffd_forge_online)`
|
||||
- language usage: `sum by (language) (ffd_repository_languages)`
|
||||
- forge versions: `ffd_forge_info` (constant gauge, read `forge_version` label)
|
||||
- offline forges: `ffd_forge_online == 0`
|
||||
|
||||
### Metrics daemon
|
||||
|
||||
Run it directly:
|
||||
|
||||
```sh
|
||||
ffd-metrics -config config.yaml
|
||||
# or from the flake
|
||||
nix run .#ffd-metrics -- -config /etc/ffd-indexer/config.yaml
|
||||
```
|
||||
|
||||
The flake also ships `container-metrics` (`nix build .#container-metrics`), an
|
||||
image that runs the daemon on `:9090`, and the NixOS module enables a
|
||||
`ffd-metrics` systemd service alongside the one-shot indexer timer.
|
||||
|
||||
---
|
||||
|
||||
## Supported forge types
|
||||
|
||||
The forge software is either pinned per forge in the config (`type: forgejo` /
|
||||
`type: gitea`) or detected on every run (`type: auto`).
|
||||
|
||||
### Type detection
|
||||
|
||||
We recommend pinning each forge's `type` by hand; this selects the scraper
|
||||
deterministically and requires no probing. The default is `auto`, which
|
||||
re-detects the type on every run because the hoster may have changed the
|
||||
backend (e.g. Gitea→Forgejo). `auto` is **experimental**:
|
||||
|
||||
- robots.txt is honored — a forge that disallows `/api/` will not be probed,
|
||||
so `auto` reports `unknown` for it;
|
||||
- detection may misclassify until more detectors land;
|
||||
- the previously detected type naturally comes first because detection starts
|
||||
with the most specific signal.
|
||||
|
||||
Forge types without a detector are reported as `unknown`
|
||||
(`forge_type="unknown"`) and skipped; the metrics daemon still shows their
|
||||
status row.
|
||||
|
||||
### Detection detail
|
||||
|
||||
| Type | Detection |
|
||||
|---|---|
|
||||
| Forgejo | Serves the Forgejo-native `GET /api/forgejo/v1/version`, which returns 200 with a version JSON. Forgejo is a Gitea fork and also serves the shared `/api/v1/version`, so this endpoint is the discriminator: a hit is a strong Forgejo signal. The version is recorded and exposed as `ffd_forge_info{forge_version=...}`. |
|
||||
| Gitea | Does not serve `/api/forgejo/v1/version` (404) but does serve the shared `GET /api/v1/version`, so an instance is classified Gitea if and only if the Forgejo probe fails and the shared endpoint answers. If other forges later serve `/api/v1/version` (e.g. Gogs), that classification will need a further discriminator. |
|
||||
|
||||
Forgejo and Gitea share the same `/api/v1` surface (Forgejo is a fork of
|
||||
Gitea), so both use the same scraper class.
|
||||
|
||||
---
|
||||
|
||||
## NixOS module
|
||||
|
||||
The flake exposes `nixosModules.default` (option namespace
|
||||
`services.federated-forge-discovery.indexer`). It enables:
|
||||
|
||||
- a **one-shot `ffd-indexer` systemd service** and a **timer** driving it
|
||||
(`schedule`: `daily` [default], `hourly`, or `weekly` — or any systemd
|
||||
`OnCalendar` expression);
|
||||
- a **`ffd-metrics` systemd daemon** (`Type=simple`, restart on failure);
|
||||
- the rendered config at `/etc/ffd-indexer/config.yaml`, owned by the service
|
||||
user `ffd-indexer`;
|
||||
- a `container`/`container-metrics` integration.
|
||||
|
||||
Options:
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `enable` | — | Enables the service and timer. |
|
||||
| `package` | — | The package to run (`ffd-indexer` from the flake). |
|
||||
| `schedule` | `"daily"` | `daily` \| `hourly` \| `weekly`, or an `OnCalendar` string. |
|
||||
| `dbPath` | — | Path to the SQLite DB. |
|
||||
| `typesense.host` | — | Typesense address. |
|
||||
| `typesense.apiKey` | `""` | Typesense API key (inline). |
|
||||
| `typesense.apiKeyFile` | `null` | Path to a secret file (sops/agenix) containing the key; takes precedence over `apiKey`. |
|
||||
| `metrics.enable` | `false` | Enable the optional `ffd-metrics` daemon (and render the `metrics:` block). Disabled by default; set `true` to run metrics. |
|
||||
| `metrics.listen` | `"127.0.0.1:9090"` | Address for the metrics daemon. |
|
||||
| `forges` | `[]` | List of `{ url, type }`; `type` is `auto` (default), `forgejo` or `gitea`. |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
The dev shell (`nix develop`) provides Go, `gomod2nix`, `staticcheck`, and a
|
||||
local Typesense server for development.
|
||||
|
||||
```sh
|
||||
nix develop # enter the dev shell (Go toolchain, gomod2nix, etc.)
|
||||
go test ./... # run tests (store, scraper, config)
|
||||
go vet ./...
|
||||
nix flake check # lint (golangci-lint) + all tests inside Nix
|
||||
nix build .#default .#ffd-metrics .#container .#container-metrics
|
||||
```
|
||||
|
||||
### Continuous integration
|
||||
|
||||
[`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) runs the pipeline on
|
||||
Forgejo Actions with **no upstream actions**: the checkout is a plain
|
||||
`git clone`, and every step calls the flake (`nix flake check`, `nix build`,
|
||||
`nix develop`) inside the `nixos/nix` container image. Four isolated jobs:
|
||||
|
||||
| Job | Enforces | Fails on |
|
||||
|---|---|---|
|
||||
| **checks** | `nix flake check` | unit tests (`go test ./...`) or golangci-lint |
|
||||
| **build** | `nix build .#default .#ffd-metrics .#container .#container-metrics` | any package failing to build |
|
||||
| **format** | `gofmt -l` and `nix fmt` | unformatted `.go` / `.nix` files |
|
||||
| **integration** | live scrape of `git.xengi.de` sent to a **Typesense `services:` container** | a scrape or sync assertion failing |
|
||||
|
||||
The **integration** job is a real end-to-end test run inside the workflow: a
|
||||
Typesense container (the `services:` feature) is started alongside the job,
|
||||
the indexer scrapes the repository's own forge (`git.xengi.de`, `type: auto`
|
||||
→ real detection) and syncs into that service. It then asserts the forge is
|
||||
online in sqlite and that the Typesense document count equals the sqlite
|
||||
project count. The Typesense service image (`typesense/typesense:30.2`)
|
||||
matches the flake's `typesense-server` version, so CI mirrors local dev
|
||||
(where you would use `nix run .#typesense-server`). It depends on the forge
|
||||
being up — it is a live test by design.
|
||||
|
||||
Run all checks locally with `nix flake check` + `gofmt -l .` + `nix fmt`
|
||||
(followed by a `git diff --quiet`). The runner needs the `debian-stable` label and
|
||||
outbound access to the forge, github.com (flake inputs) and cache.nixos.org.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Go modules are vendored into Nix via `gomod2nix.toml` (generated with
|
||||
`gomod2nix generate`). After adding or upgrading a Go dependency, regenerate
|
||||
`gomod2nix.toml` before `nix flake check`/`nix build` will reflect it.
|
||||
Loading…
Reference in a new issue