Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ To add a new template variable:
- Do not use `dependency_overrides` or dev package versions unless there is truly no
stable alternative (ngdart 8.x dev is the documented exception).

## Before handing work over

Mandatory, every time — not "when the change looks risky". Run all four from
`unpub/` and report what they said:

```
fvm dart pub get
fvm dart analyze
fvm dart format --output=none --set-exit-if-changed .
fvm dart test
```

These are exactly what CI runs (`.github/workflows/analyze-and-test.yml`). A
change is not done until they are green locally; reporting it done on anything
less is how a red CI gets handed over.

`dart test` needs MongoDB on `localhost:27017`. CI runs the suite against
**both** versions of its matrix, and they disagree: MongoDB removed the legacy
OP_QUERY opcode in 5.1, so a mongo_dart call implemented over it passes on
5.0.6 (what the deployment runs) and is refused on 7 (what it would be upgraded
to). `.dev/docker-compose.yml` starts 5.0.6 only, so testing against it alone
is not testing the matrix — run the suite a second time against `mongo:7`.

## Changelog and commit messages

Keep both compact. Length is not evidence of care.
Expand Down
5 changes: 5 additions & 0 deletions unpub/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Unreleased

### Added
- `GET /health` for monitoring: 200 while the database answers, 503 when it does not. Public, no credential needed.

## 3.7.0

### Added
Expand Down
30 changes: 30 additions & 0 deletions unpub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,36 @@ From the command line docs are enabled by default; use `--no-docs` to disable
them and `--dart-executable` to point at a specific Dart SDK.


### Health check

`GET /health` is the endpoint to point a monitor at. It answers 200 while the
server can reach its metadata store and 503 when it cannot, so a check that
reads nothing but the status code is already right. The answer is never cached.

```json
{ "status": "ok", "checks": { "database": { "status": "ok", "latencyMs": 4 } } }
```

The check is a real query, not a look at the driver's connection state — a
database that has stopped answering keeps a socket that still calls itself
open. A store that does not answer within 5 seconds counts as unreachable, and
the failing check names the error by type; the full error, connection string
and all, goes to the server log.

```json
{
"status": "error",
"checks": { "database": { "status": "error", "error": "TimeoutException" } }
}
```

The route is public and stays that way with `--auth` on, so that a monitor
needs no credential. It is the same answer for every caller: nothing in it
describes what this repository holds. A package count would have been a number
anybody could poll every few seconds, and a count read on a schedule is a
publication feed for a repository whose whole point is that outsiders cannot
watch one.

### Usage behind reverse-proxy

Using in_pub behind reverse proxy(nginx or another), ensure you have necessary headers
Expand Down
65 changes: 65 additions & 0 deletions unpub/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ class App {

final String version;

/// How long `/health` waits for the metadata store before calling it
/// unreachable.
///
/// Bounded because the answer is for a monitor: a database that has stopped
/// answering must end as a failed check rather than as a probe left hanging
/// until the monitor's own timeout, which reads as a network problem
/// instead of a database one.
final Duration healthProbeTimeout;

/// validate if the package can be published
///
/// for more details, see: https://github.com/Innim/in_pub#package-validator
Expand All @@ -104,6 +113,7 @@ class App {
this.uploadValidator,
this.proxy_origin,
this.version = '',
this.healthProbeTimeout = const Duration(seconds: 5),
HostedUrlCompat? hostedUrlCompat,
}) :
// Off unless one is passed. The rewrite makes this server answer
Expand Down Expand Up @@ -499,6 +509,61 @@ class App {
/// the whole table on every request.
late final Router router = _$AppRouter(this);

/// What a monitor polls: is this server up, and can it still reach the
/// store it keeps every package's metadata in?
///
/// Answers 200 when the store answered and 503 when it did not, so a check
/// that reads nothing but the status code is already right.
///
/// The same answer for everybody, which is what makes it safe to leave
/// public — `classifyRoute` has reserved this path since before there was
/// a handler on it, and the gate resolves no credential for a public
/// route, so this handler could not tell a signed-in caller from anyone
/// else even if it wanted to. Nothing here describes what the repository
/// holds: a package count would have been a number an anonymous caller
/// could poll every few seconds, and a count polled on a schedule is a
/// publication feed for a repository whose whole point is that outsiders
/// cannot watch one.
@Route.get('/health')
Future<shelf.Response> health(shelf.Request req) async {
final watch = Stopwatch()..start();
Object? failure;
try {
await metaStore.checkHealth().timeout(healthProbeTimeout);
} catch (e) {
// Logged in full here and named only by type in the answer: the
// message of a driver error carries the connection string, and this
// endpoint is read by anyone who asks.
print('Health check failed: $e');
failure = e;
}
watch.stop();

final healthy = failure == null;

return shelf.Response(
healthy ? HttpStatus.ok : HttpStatus.serviceUnavailable,
headers: {
HttpHeaders.contentTypeHeader: jsonContentType,
// An answer served out of a proxy's cache says only that the server
// was up when the entry was stored.
HttpHeaders.cacheControlHeader: 'no-store',
},
body: json.encode({
'status': healthy ? 'ok' : 'error',
'checks': {
'database': {
'status': healthy ? 'ok' : 'error',
if (healthy)
'latencyMs': watch.elapsedMilliseconds
else
'error': failure.runtimeType.toString(),
},
},
}),
);
}

@Route.get('/api/packages/<name>')
Future<shelf.Response> getVersions(shelf.Request req, String name) async {
var package = await metaStore.queryPackage(name);
Expand Down
5 changes: 5 additions & 0 deletions unpub/lib/src/app.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions unpub/lib/src/meta_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,24 @@ abstract class MetaStore {
String? uploader,
String? dependency,
});

/// Reaches the store, for `/health`. Throws when it cannot be reached —
/// the caller turns that into what it reports.
///
/// A round trip on purpose, rather than a look at whatever the driver
/// calls its connection state: a socket that still describes itself as
/// open is exactly what a wedged database leaves behind, and only an
/// answer that came back over the wire says the store can still be used.
/// Nothing is returned, because the probe answers one question — did the
/// store answer — and `/health` reports nothing else about it.
///
/// Concrete rather than abstract because `MetaStore` is a documented
/// extension point (README, "Customize meta and package store"): an
/// implementation outside this repository must not stop compiling for a
/// probe it never asked for. The default asks for a single package, which
/// is the cheapest round trip this interface can express; a store that can
/// do better — [MongoStore] asks for one document's `_id` — overrides it.
Future<void> checkHealth() async {
await queryPackages(size: 1, page: 0, sort: 'download');
}
}
16 changes: 16 additions & 0 deletions unpub/lib/src/mongo_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ class MongoStore extends MetaStore {
return _queryPackagesBySelector(selector);
}

@override
Future<void> checkHealth() async {
// One document's `_id` and nothing else: the smallest answer the server
// can be asked for that still proves the connection carries a query.
//
// `findOne` rather than `count`, which reads no document at all and would
// otherwise be the obvious probe: mongo_dart 0.7.4 implements `count`
// over the legacy OP_QUERY opcode, and MongoDB removed that in 5.1 —
// against a 5.1-or-newer server every call is refused with
// `Unsupported OP_QUERY command: count`, so the probe would have reported
// the database unreachable on exactly the versions this repository is
// headed for. `findOne` picks the modern OP_MSG path where the server has
// one and falls back on the legacy path where it does not.
await db.collection(packageCollection).findOne(where.fields(['_id']));
}

@override
Future<List<UnpubRecentPublication>> queryRecentPublications({
required int size,
Expand Down
Loading
Loading