WIP: Poll for logged events

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
This commit is contained in:
Pablo Zmdl
2026-02-05 12:34:44 +01:00
parent 2c9a1f3fad
commit fbffdeed9f
11 changed files with 274 additions and 4 deletions

View File

@@ -5,9 +5,12 @@ namespace AIO\Container;
use AIO\Data\ConfigurationManager;
use AIO\Docker\DockerActionManager;
use AIO\ContainerDefinitionFetcher;
use AIO\Data\ContainerEventsLog;
use JsonException;
readonly class Container {
protected ContainerEventsLog $eventsLog;
public function __construct(
public string $identifier,
public string $displayName,
@@ -39,6 +42,7 @@ readonly class Container {
public string $documentation,
private DockerActionManager $dockerActionManager
) {
$this->eventsLog = new ContainerEventsLog();
}
public function GetUiSecret() : string {
@@ -66,4 +70,8 @@ readonly class Container {
public function GetStartingState() : ContainerState {
return $this->dockerActionManager->GetContainerStartingState($this);
}
public function logEvent(string $message) : void {
$this->eventsLog->add($this->identifier, $message);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace AIO\Controller;
use AIO\Container\ContainerState;
use AIO\ContainerDefinitionFetcher;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use AIO\Data\ConfigurationManager;
use AIO\Data\DataConst;
use AIO\Data\ContainerEventsLog;
readonly class ContainerEventsController {
public function __construct(
private ContainerDefinitionFetcher $containerDefinitionFetcher,
private ConfigurationManager $configurationManager
) {
}
public function getEventsLog(Request $request, Response $response, array $args) : Response
{
$eventsLog = new ContainerEventsLog();
$currentMtime = $eventsLog->lastModified();
if ($currentMtime === false) {
error_log("Error: Could not get mtime of file '{$eventsLog->filename}', something is wrong. Responding with status 502.");
return $response->withStatus(502);
}
$currentMtimeHash = md5($currentMtime);
$knownMtimeHash = $request->getHeaderLine('If-None-Match');
if ($knownMtimeHash === $currentMtimeHash) {
return $response->withStatus(304);
}
return $response
->withStatus(200)
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withHeader('Content-Disposition', 'inline')
->withHeader('Cache-Control', 'no-cache')
->withHeader('Etag', $currentMtimeHash)
->withBody(\GuzzleHttp\Psr7\Utils::streamFor(fopen($eventsLog->filename, 'rb')));
}
}

View File

@@ -50,6 +50,7 @@ readonly class DockerController {
$this->dockerActionManager->CreateContainer($container);
$this->dockerActionManager->StartContainer($container);
$this->dockerActionManager->ConnectContainerToNetwork($container);
$container->logEvent('Container is running');
}
private function PerformRecursiveImagePull(string $id) : void {

View File

@@ -0,0 +1,48 @@
<?php
namespace AIO\Data;
class ContainerEventsLog {
readonly public string $filename;
public function __construct()
{
$this->filename = DataConst::GetDataDirectory() . "/container_events.log";
if (file_exists($this->filename)) {
$this->pruneFileIfTooLarge();
} else {
touch($this->filename);
}
}
public function lastModified() : int|false {
return filemtime($this->filename);
}
public function add(string $id, string $message) : void
{
$json = json_encode(['time' => time(), 'id' => $id, 'message' => $message]);
// Append new event (atomic via LOCK_EX)
file_put_contents($this->filename, $json . PHP_EOL, FILE_APPEND | LOCK_EX);
}
// Truncate the file to keep only the last bytes, aligned to a newline boundary.
protected function pruneFileIfTooLarge() : void {
$maxBytes = 512 * 1024; // 512 KB
$maxLines = 1000; // keep last 1000 events
if (filesize($this->filename) <= $maxBytes) {
return;
}
$lines = file($this->filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
$total = count($lines);
$start = max(0, $total - $maxLines);
$keep = array_slice($lines, $start);
// rewrite file with kept lines
file_put_contents($this->filename, implode(PHP_EOL, $keep) . PHP_EOL, LOCK_EX);
}
}
}

View File

@@ -8,6 +8,7 @@ use AIO\Container\VersionState;
use AIO\ContainerDefinitionFetcher;
use AIO\Data\ConfigurationManager;
use AIO\Data\DataConst;
use AIO\Data\ContainerEventsLog;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use http\Env\Response;
@@ -167,6 +168,7 @@ readonly class DockerActionManager {
public function StartContainer(Container $container): void {
$url = $this->BuildApiUrl(sprintf('containers/%s/start', urlencode($container->identifier)));
$container->logEvent('Starting container');
try {
$this->guzzleClient->post($url);
} catch (RequestException $e) {
@@ -201,6 +203,7 @@ readonly class DockerActionManager {
}
public function CreateContainer(Container $container): void {
$container->logEvent('Creating container');
$volumes = [];
foreach ($container->volumes->GetVolumes() as $volume) {
// // NEXTCLOUD_MOUNT gets added via bind-mount later on
@@ -501,12 +504,14 @@ readonly class DockerActionManager {
$imageIsThere = false;
}
$container->logEvent('Pulling image');
$maxRetries = 3;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
$this->guzzleClient->post($url);
$container->logEvent('Finished pulling image');
break;
} catch (RequestException $e) {
} catch (\Throwable $e) {
$message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents();
if ($attempt === $maxRetries) {
if ($imageIsThere === false) {
@@ -514,6 +519,7 @@ readonly class DockerActionManager {
} else {
error_log($message);
}
$container->logEvent('Pulling image failed, please review the output of the "nextcloud-aio-mastercontainer" container');
} else {
error_log($message . ' Retrying...');
sleep(1);
@@ -829,6 +835,7 @@ readonly class DockerActionManager {
}
public function ConnectContainerToNetwork(Container $container): void {
$container->logEvent('Connecting container to network');
// Add a secondary alias for domaincheck container, to keep it as similar to actual apache controller as possible.
// If a reverse-proxy is relying on container name as hostname this allows it to operate as usual and still validate the domain
// The domaincheck container and apache container are never supposed to be active at the same time because they use the same APACHE_PORT anyway, so this doesn't add any new constraints.
@@ -851,6 +858,7 @@ readonly class DockerActionManager {
$maxShutDownTime = $container->maxShutdownTime;
}
$url = $this->BuildApiUrl(sprintf('containers/%s/stop?t=%s', urlencode($container->identifier), $maxShutDownTime));
$container->logEvent('Stopping container');
try {
$this->guzzleClient->post($url);
} catch (RequestException $e) {