Skip to content
Closed
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
190 changes: 190 additions & 0 deletions lib/Controller/HealthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<?php

/**
* Larpinq HealthController.
*
* AppHost adopter by COMPOSITION, not inheritance: the OpenRegister AppHost
* observability engine is resolved lazily out of the DI container by FQCN
* string, and its result is rendered as the ADR-006
* `{status, app, version, checks}` envelope. Health-check execution, the
* status-code policy and the CORS decision come from the engine (declared in
* the `observability.health` block of `src/manifest.json`); the envelope and
* the OpenRegister-absent fallback are owned here.
*
* `\OCA\OpenRegister\AppHost\Routes::standard()` emits `health#index` for every
* adopter, so this route existed in larpinq's table with no class behind it
* until this file landed. gate-14 route-reachability named it
* `controller-class-not-found`.
*
* ⚠️ This class MUST NOT `extends` — nor name in any resolved position — a
* class from another app. Nextcloud's router `ReflectionClass()`es every file
* in `lib/Controller/` while MATCHING a route, so an unresolvable parent makes
* EVERY route in larpinq return HTTP 500, not just this one. `extends` is
* resolved by the autoloader, not the container, so no amount of lazy DI
* registration can rescue it. larpinq does not declare
* `<app>openregister</app>` in appinfo/info.xml, so the parent was
* unresolvable on any instance without OpenRegister.
*
* @category Controller
* @package OCA\Larpinq\Controller
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2024 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git_id>
*
* @link https://github.com/ConductionNL/larpinq
*
* @spec openspec/specs/apphost-adoption/spec.md
*/

declare(strict_types=1);

namespace OCA\Larpinq\Controller;

use OCA\Larpinq\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AnonRateLimit;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IConfig;
use OCP\IRequest;
use Psr\Container\ContainerInterface;

/**
* Public, declarative health endpoint backed by the AppHost engine.
*
* The engine collaborators are pulled from the container by FQCN string at
* dispatch time, so larpinq never binds an OpenRegister class at
* class-declaration time.
*
* @psalm-suppress UnusedClass
*
* @spec openspec/specs/apphost-adoption/spec.md
*/
class HealthController extends Controller {

/**
* FQCN of the AppHost observability manifest loader.
*
* Referenced as a string, never imported: the class only exists when
* openregister is installed.
*
* @var string
*/
private const MANIFEST_LOADER = 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader';

/**
* FQCN of the AppHost declarative health-check executor.
*
* Referenced as a string, never imported: the class only exists when
* openregister is installed.
*
* @var string
*/
private const HEALTH_EXECUTOR = 'OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor';

/**
* Constructor.
*
* @param IRequest $request The HTTP request.
* @param IConfig $config The Nextcloud config service (fallback version).
* @param ContainerInterface $container DI container — resolves the AppHost engine lazily.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly IConfig $config,
private readonly ContainerInterface $container,
) {
parent::__construct(appName: Application::APP_ID, request: $request);

}//end __construct()

/**
* GET /api/health — declarative health check (ADR-006), public probe.
*
* Runs the manifest-declared checks through the AppHost engine and renders
* the `{status, app, version, checks}` envelope with the status code the
* engine's policy resolved. CORS headers are emitted only when the
* manifest opts in, exactly as the engine does.
*
* When the AppHost engine cannot be resolved — openregister absent or
* disabled — the endpoint still answers (the whole point of a health
* probe): `status: degraded`, `checks.openregister: unavailable`, HTTP 200.
*
* The rate-limit ceiling is generous because this is a liveness probe,
* polled on a schedule by monitoring.
*
* @return JSONResponse `{status, app, version, checks}` with HTTP code per policy.
*
* @spec openspec/specs/apphost-adoption/spec.md
*/
#[PublicPage]
#[NoCSRFRequired]
#[AnonRateLimit(limit: 120, period: 60)]
public function index(): JSONResponse {
$engine = $this->engineResult();
if ($engine === null) {
return new JSONResponse(
[
'status' => 'degraded',
'app' => $this->appName,
'version' => $this->config->getAppValue(Application::APP_ID, 'installed_version', ''),
'checks' => ['openregister' => 'unavailable'],
],
Http::STATUS_OK
);
}

$response = new JSONResponse(
[
'status' => $engine['status'],
'app' => $this->appName,
'version' => $engine['version'],
'checks' => $engine['checks'],
],
$engine['httpStatus']
);

if ($engine['cors'] === true) {
$response->addHeader('Access-Control-Allow-Origin', '*');
$response->addHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
}

return $response;
}//end index()

/**
* Run the AppHost observability engine for this app.
*
* Null when the engine is unavailable (openregister absent/disabled).
*
* @return array{status: string, version: string, checks: array<string, string>, httpStatus: int, cors: bool}|null
*/
private function engineResult(): ?array {
try {
$manifestLoader = $this->container->get(self::MANIFEST_LOADER);
$executor = $this->container->get(self::HEALTH_EXECUTOR);

$appId = $this->appName;
$manifest = $manifestLoader->load(appId: $appId);
$result = $executor->execute(manifest: $manifest);

return [
'status' => (string)$result->status,
'version' => (string)$manifestLoader->appVersion(appId: $appId),
'checks' => (array)$result->checks,
'httpStatus' => (int)$result->httpStatusCode,
'cors' => ($manifest->cors === true),
];
} catch (\Throwable $e) {
return null;
}//end try

}//end engineResult()
}//end class
133 changes: 133 additions & 0 deletions lib/Controller/MetricsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

/**
* Larpinq MetricsController.
*
* AppHost adopter by COMPOSITION, not inheritance: the OpenRegister AppHost
* Prometheus engine is resolved lazily out of the DI container by FQCN string
* and rendered as text exposition 0.0.4.
*
* `\OCA\OpenRegister\AppHost\Routes::standard()` emits `metrics#index` for
* every adopter, so this route existed in larpinq's table with no class behind
* it until this file landed. gate-14 route-reachability named it
* `controller-class-not-found`.
*
* ⚠️ This class MUST NOT `extends` — nor name in any resolved position — a
* class from another app. Nextcloud's router `ReflectionClass()`es every file
* in `lib/Controller/` while MATCHING a route, so an unresolvable parent makes
* EVERY route in larpinq return HTTP 500, not just this one.
*
* @category Controller
* @package OCA\Larpinq\Controller
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2024 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git_id>
*
* @link https://github.com/ConductionNL/larpinq
*
* @spec openspec/specs/apphost-adoption/spec.md
*/

declare(strict_types=1);

namespace OCA\Larpinq\Controller;

use OCA\Larpinq\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\TextPlainResponse;
use OCP\IRequest;
use Psr\Container\ContainerInterface;

/**
* Admin-only declarative Prometheus metrics endpoint backed by the AppHost engine.
*
* No `#[NoAdminRequired]` — the absence of that attribute means NC requires an
* admin session, which is the intended ADR-006 posture for metrics. Anonymous
* callers get the NC login redirect / 401, never metric data.
*
* @psalm-suppress UnusedClass
*
* @spec openspec/specs/apphost-adoption/spec.md
*/
class MetricsController extends Controller {

/**
* FQCN of the AppHost observability manifest loader.
*
* Referenced as a string, never imported: the class only exists when
* openregister is installed.
*
* @var string
*/
private const MANIFEST_LOADER = 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader';

/**
* FQCN of the AppHost Prometheus metrics engine.
*
* Referenced as a string, never imported: the class only exists when
* openregister is installed.
*
* @var string
*/
private const METRICS_ENGINE = 'OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine';

/**
* Prometheus text exposition content type (mirrors the engine's renderer).
*
* @var string
*/
private const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';

/**
* Constructor.
*
* @param IRequest $request The HTTP request.
* @param ContainerInterface $container DI container — resolves the AppHost engine lazily.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly ContainerInterface $container,
) {
parent::__construct(appName: Application::APP_ID, request: $request);

}//end __construct()

/**
* GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006).
*
* Admin-only by the deliberate absence of `#[NoAdminRequired]`.
*
* Returns HTTP 503 with a Prometheus comment line when the AppHost engine
* is unavailable (openregister absent or disabled) — never a 500.
*
* @return TextPlainResponse Prometheus text exposition 0.0.4.
*
* @spec openspec/specs/apphost-adoption/spec.md
*/
#[NoCSRFRequired]
public function index(): TextPlainResponse {
try {
$manifestLoader = $this->container->get(self::MANIFEST_LOADER);
$engine = $this->container->get(self::METRICS_ENGINE);

$manifest = $manifestLoader->load(appId: $this->appName);
$body = (string)$engine->render(manifest: $manifest);
$status = Http::STATUS_OK;
} catch (\Throwable $e) {
$body = '# metrics unavailable: the OpenRegister AppHost observability engine is not installed' . "\n";
$status = Http::STATUS_SERVICE_UNAVAILABLE;
}//end try

$response = new TextPlainResponse($body, $status);
$response->addHeader('Content-Type', self::CONTENT_TYPE);

return $response;
}//end index()
}//end class
Loading
Loading