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
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function handle(DeprecatedEndpointService $catalog, LokiService $loki): i
if ($endpoints === null) {
// Spec unreachable/misconfigured — surface it (red cron badge, non-zero
// exit) rather than silently behaving like "nothing is deprecated".
$this->warn('Could not fetch the apiv2 OpenAPI spec ('.config('freegle.apiv2_swagger_url').'); cannot report on deprecated endpoints. Check APIV2_SWAGGER_URL.');
$this->warn('Could not fetch the apiv2 deprecated-endpoint registry ('.config('freegle.apiv2_deprecated_url').'); cannot report on deprecated endpoints. Check APIV2_DEPRECATED_URL.');

return self::FAILURE;
}
Expand Down
90 changes: 42 additions & 48 deletions iznik-batch/app/Services/DeprecatedEndpointService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,85 +7,79 @@
use Illuminate\Support\Facades\Log;

/**
* Reads the live apiv2 OpenAPI spec (the single source of truth for what is
* deprecated and when it sunsets) and returns the operations whose sunset date
* has passed — the set monitor:deprecated-endpoints must report on.
* Reads apiv2's deprecated-endpoint registry (GET /deprecated) — the set of
* routes wrapped in deprecation.Marker() plus their sunset dates — and returns
* the ones whose sunset has passed, for monitor:deprecated-endpoints.
*
* The registry is served from the SAME Marker() calls that log the hits, so the
* observed set and the logging can't drift (an observed-but-unlogged endpoint
* would falsely read as "safe to retire"). We deliberately do NOT parse the
* OpenAPI spec: it is generated by go-swagger, whose swagger:route form can't
* carry an x-sunset vendor extension.
*/
class DeprecatedEndpointService
{
/**
* Returns the deprecated operations whose sunset date has passed — or NULL if
* the spec couldn't be fetched, so the caller can tell "apiv2 unreachable /
* misconfigured" apart from "reachable but nothing deprecated" and surface the
* former loudly instead of silently emailing nothing forever.
* Deprecated endpoints past their sunset date — or NULL if apiv2 couldn't be
* reached, so the caller can surface "apiv2 unreachable / misconfigured"
* loudly instead of silently behaving like "nothing is deprecated".
*
* @return array<int, array{method:string, path:string, sunset:string, logged_endpoint:string}>|null
* @return array<int, array{endpoint:string, sunset:string, logged_endpoint:string}>|null
*/
public function pastSunset(Carbon $now): ?array
{
$spec = $this->fetchSpec();
if ($spec === null) {
$list = $this->fetchRegistry();
if ($list === null) {
return null;
}

$out = [];
foreach (($spec['paths'] ?? []) as $path => $operations) {
foreach ($operations as $method => $op) {
if (! is_array($op) || empty($op['deprecated'])) {
continue;
}
$sunset = $op['x-sunset'] ?? null;
if (! $sunset) {
// Deprecated but no sunset date yet: not armed, skip.
continue;
}
if (Carbon::parse($sunset)->startOfDay()->gt($now->copy()->startOfDay())) {
continue; // still inside the grace window
}

$upperMethod = strtoupper($method);
$out[] = [
'method' => $upperMethod,
'path' => $path,
'sunset' => $sunset,
'logged_endpoint' => $this->loggedEndpoint($upperMethod, $path),
];
foreach ($list as $entry) {
if (! is_array($entry)) {
continue;
}
$endpoint = $entry['endpoint'] ?? null;
$sunset = $entry['sunset'] ?? null;
if (! $endpoint || ! $sunset) {
continue;
}
if (Carbon::parse($sunset)->startOfDay()->gt($now->copy()->startOfDay())) {
continue; // still inside the grace window
}

$out[] = [
'endpoint' => $endpoint,
'sunset' => $sunset,
// apiv2 already emits the Fiber route-pattern form ("GET /message/:id"),
// which is exactly what the Loki hit lines are keyed by.
'logged_endpoint' => $endpoint,
];
}

return $out;
}

/**
* Convert an OpenAPI path ("/message/{id}") to the Fiber route-pattern form
* the Go middleware logs ("GET /message/:id"), so the LogQL filter matches.
* @return array<int, mixed>|null
*/
private function loggedEndpoint(string $method, string $path): string
private function fetchRegistry(): ?array
{
$fiberPath = preg_replace('/\{([^}]+)\}/', ':$1', $path);

return $method.' '.$fiberPath;
}

/**
* @return array<string, mixed>|null
*/
private function fetchSpec(): ?array
{
$url = config('freegle.apiv2_swagger_url');
$url = config('freegle.apiv2_deprecated_url');
try {
$resp = Http::timeout(30)->get($url);
} catch (\Throwable $e) {
Log::warning('DeprecatedEndpointService: spec fetch failed: '.$e->getMessage());
Log::warning('DeprecatedEndpointService: registry fetch failed: '.$e->getMessage());

return null;
}
if (! $resp->ok()) {
Log::warning('DeprecatedEndpointService: spec fetch non-200: '.$resp->status());
Log::warning('DeprecatedEndpointService: registry fetch non-200: '.$resp->status());

return null;
}

return $resp->json();
$json = $resp->json();

return is_array($json) ? $json : null;
}
}
18 changes: 10 additions & 8 deletions iznik-batch/config/freegle.php
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,16 @@
'query_url' => env('LOKI_URL', 'http://loki:3100'),
],

// Served OpenAPI spec for monitor:deprecated-endpoints (source of truth for
// which apiv2 endpoints are deprecated + their x-sunset dates). The Go API
// serves it at /swagger/swagger.json on port 8192; inside the compose network
// the batch container reaches it as http://apiv2:8192 (verified 2026-07-09 —
// NOT port 80, and /swagger/doc.json 404s). PROD must set APIV2_SWAGGER_URL to
// the reachable apiv2 spec URL for its network (batch-prod isn't in this
// compose network); if the fetch fails the command warns and exits non-zero.
'apiv2_swagger_url' => env('APIV2_SWAGGER_URL', 'http://apiv2:8192/swagger/swagger.json'),
// apiv2's deprecated-endpoint registry for monitor:deprecated-endpoints — the
// single source of truth for which routes are deprecated + their sunset dates
// (served from the same deprecation.Marker() calls that log the hits, so the
// set and the logging can't drift). NOT the OpenAPI spec: go-swagger's
// swagger:route form can't carry an x-sunset extension. The Go API serves it
// at /deprecated on port 8192; inside the compose network the batch container
// reaches it as http://apiv2:8192 (NOT port 80). PROD must set
// APIV2_DEPRECATED_URL for its network (batch-prod isn't in this compose
// network); if the fetch fails the command warns and exits non-zero.
'apiv2_deprecated_url' => env('APIV2_DEPRECATED_URL', 'http://apiv2:8192/apiv2/deprecated'),

// monitor:deprecated-endpoints observation window (days). Bounded under Loki's
// max_query_length (~30d) — a longer since-sunset range 400s; this many days of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@
/**
* Tests for monitor:deprecated-endpoints.
*
* The command reads the apiv2 spec for deprecated endpoints past their x-sunset
* date, checks Loki for hits since sunset, and emails geeks@ a retire/chase
* report — but only when at least one endpoint is past sunset.
* The command reads apiv2's deprecated-endpoint registry (GET /deprecated) for
* endpoints past their sunset date, checks Loki for hits since sunset, and emails
* geeks@ a retire/chase report — but only when at least one endpoint is past sunset.
*/
class DeprecatedEndpointsCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('freegle.apiv2_swagger_url', 'http://apiv2/swagger/swagger.json');
config()->set('freegle.apiv2_deprecated_url', 'http://apiv2:8192/deprecated');
config()->set('freegle.loki.query_url', 'http://loki:3100');
config()->set('freegle.geeks_addr', 'geeks@ilovefreegle.org');
Carbon::setTestNow('2026-07-09T06:20:00Z');
Expand All @@ -31,25 +31,24 @@ protected function tearDown(): void
parent::tearDown();
}

private function fakeSpec(): array
/** The shape apiv2 GET /deprecated returns (already the Fiber route-pattern form). */
private function fakeRegistry(): array
{
return [
'paths' => [
// Past sunset, still called by an app straggler -> still in use.
'/message/{id}' => ['get' => ['deprecated' => true, 'x-sunset' => '2026-07-01']],
// Past sunset, silent -> safe to retire.
'/activity' => ['get' => ['deprecated' => true, 'x-sunset' => '2026-07-01']],
// Not yet armed.
'/future' => ['get' => ['deprecated' => true, 'x-sunset' => '2099-01-01']],
],
// Past sunset, still called by an app straggler -> still in use.
['endpoint' => 'GET /message/:id', 'sunset' => '2026-07-01'],
// Past sunset, silent -> safe to retire.
['endpoint' => 'GET /activity', 'sunset' => '2026-07-01'],
// Not yet armed.
['endpoint' => 'GET /future', 'sunset' => '2099-01-01'],
];
}

public function test_emails_retire_and_still_in_use_split(): void
{
Mail::fake();
Http::fake([
'http://apiv2/*' => Http::response($this->fakeSpec(), 200),
'http://apiv2:8192/*' => Http::response($this->fakeRegistry(), 200),
// GET /message/:id — still used by an app straggler.
'http://loki:3100/*message*' => Http::response([
'data' => ['result' => [[
Expand Down Expand Up @@ -86,8 +85,8 @@ public function test_loki_query_failure_is_not_treated_as_retirable(): void
{
Mail::fake();
Http::fake([
'http://apiv2/*' => Http::response([
'paths' => ['/message/{id}' => ['get' => ['deprecated' => true, 'x-sunset' => '2026-07-01']]],
'http://apiv2:8192/*' => Http::response([
['endpoint' => 'GET /message/:id', 'sunset' => '2026-07-01'],
], 200),
// Loki errors on the query -> must NOT count as "safe to retire".
'http://loki:3100/*' => Http::response('range too long', 400),
Expand All @@ -102,8 +101,8 @@ public function test_no_email_when_nothing_past_sunset(): void
{
Mail::fake();
Http::fake([
'http://apiv2/*' => Http::response([
'paths' => ['/future' => ['get' => ['deprecated' => true, 'x-sunset' => '2099-01-01']]],
'http://apiv2:8192/*' => Http::response([
['endpoint' => 'GET /future', 'sunset' => '2099-01-01'],
], 200),
]);

Expand All @@ -114,12 +113,12 @@ public function test_no_email_when_nothing_past_sunset(): void
Mail::assertNothingSent();
}

public function test_fails_loudly_when_spec_unreachable(): void
public function test_fails_loudly_when_registry_unreachable(): void
{
Mail::fake();
// apiv2 spec unreachable/misconfigured -> the command must NOT behave like
// apiv2 registry unreachable/misconfigured -> the command must NOT behave like
// "nothing deprecated"; it exits non-zero (red cron badge) and sends nothing.
Http::fake(['http://apiv2/*' => Http::response('down', 503)]);
Http::fake(['http://apiv2:8192/*' => Http::response('down', 503)]);

$this->artisan('monitor:deprecated-endpoints')
->assertExitCode(1);
Expand Down
47 changes: 16 additions & 31 deletions iznik-batch/tests/Unit/DeprecatedEndpointServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,56 +9,41 @@

class DeprecatedEndpointServiceTest extends TestCase
{
private function fakeSpec(): array
/** The shape apiv2 GET /deprecated returns. */
private function fakeRegistry(): array
{
return [
'paths' => [
'/message/{id}' => [
'get' => ['deprecated' => true, 'x-sunset' => '2026-07-01'],
'delete' => ['deprecated' => true, 'x-sunset' => '2099-01-01'], // future
],
'/activity' => [
'get' => ['deprecated' => true, 'x-sunset' => '2026-06-15'],
],
'/deprecated-no-sunset' => [
'get' => ['deprecated' => true], // no x-sunset: not armed
],
'/live' => [
'get' => ['summary' => 'not deprecated'],
],
],
['endpoint' => 'GET /activity', 'sunset' => '2026-06-15'], // past
['endpoint' => 'DELETE /message/:id', 'sunset' => '2026-07-01'], // past
['endpoint' => 'POST /team', 'sunset' => '2099-01-01'], // future -> excluded
];
}

public function test_returns_only_deprecated_past_sunset_operations(): void
public function test_returns_only_past_sunset_entries(): void
{
config()->set('freegle.apiv2_swagger_url', 'http://apiv2/swagger/swagger.json');
Http::fake(['http://apiv2/*' => Http::response($this->fakeSpec(), 200)]);
config()->set('freegle.apiv2_deprecated_url', 'http://apiv2:8192/deprecated');
Http::fake(['http://apiv2:8192/*' => Http::response($this->fakeRegistry(), 200)]);

$svc = new DeprecatedEndpointService();
$out = $svc->pastSunset(Carbon::parse('2026-07-09'));

// GET /message/{id} (sunset 07-01, passed) and GET /activity (06-15,
// passed). NOT DELETE /message/{id} (future), GET /deprecated-no-sunset
// (not armed), or GET /live (not deprecated).
$keys = array_map(fn ($e) => $e['method'].' '.$e['path'], $out);
$keys = array_map(fn ($e) => $e['endpoint'], $out);
sort($keys);
$this->assertSame(['GET /activity', 'GET /message/{id}'], $keys);
$this->assertSame(['DELETE /message/:id', 'GET /activity'], $keys);

$msg = collect($out)->firstWhere('path', '/message/{id}');
$msg = collect($out)->firstWhere('endpoint', 'DELETE /message/:id');
$this->assertSame('2026-07-01', $msg['sunset']);
// The endpoint label matches the Go middleware's route-pattern form,
// with Fiber ":id" params (see loggedEndpoint()).
$this->assertSame('GET /message/:id', $msg['logged_endpoint']);
// apiv2 already emits the Fiber form, so logged_endpoint == endpoint.
$this->assertSame('DELETE /message/:id', $msg['logged_endpoint']);
}

public function test_returns_null_when_spec_unreachable(): void
public function test_returns_null_when_registry_unreachable(): void
{
config()->set('freegle.apiv2_swagger_url', 'http://apiv2/swagger/swagger.json');
config()->set('freegle.apiv2_deprecated_url', 'http://apiv2:8192/deprecated');
Http::fake(['*' => Http::response('nope', 503)]);

$svc = new DeprecatedEndpointService();
// null (not []) so the caller can distinguish unreachable from empty.
// null (not []) so the command can surface "apiv2 unreachable" loudly.
$this->assertNull($svc->pastSunset(Carbon::parse('2026-07-09')));
}
}
Loading