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
6 changes: 3 additions & 3 deletions app/Commands/Audit/AddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class AddCommand extends Command
{--ntfy-token= : (ntfy) ntfy bearer token — stored encrypted}
{--ntfy-priority= : (ntfy) Notification priority — min|low|default|high|max}
{--ntfy-tags= : (ntfy) Comma-separated tag strings}
{--set-default : Set this channel as audit.default}';
{--enable : Add this channel to audit.use (active delivery list)}';

/**
* @var string
Expand Down Expand Up @@ -120,8 +120,8 @@ public function handle(ConfigService $config): int
try {
$config->setAuditChannel($name, $channelConfig);

if ((bool) $this->option('set-default')) {
$config->setAuditDefault($name);
if ((bool) $this->option('enable')) {
$config->addAuditUse($name);
}
} catch (RuntimeException $runtimeException) {
$this->error('Failed to save channel: '.$runtimeException->getMessage());
Expand Down
7 changes: 3 additions & 4 deletions app/Commands/Audit/DeleteCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ public function handle(ConfigService $config): int
];
$this->table(['Field', 'Value'], $rows);

// Warn if this is the default channel
$default = $config->getAuditDefault();
$use = $config->getAuditUse();

if ($name === $default) {
if (in_array($name, $use, true)) {
$this->warn(sprintf(
"Channel '%s' is the current default (audit.default). Deleting it will leave audit.default unset.",
"Channel '%s' is active (audit.use). Deleting it will also remove it from the active list.",
$name,
));
}
Expand Down
8 changes: 4 additions & 4 deletions app/Commands/Audit/ListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ListCommand extends Command
public function handle(ConfigService $config): int
{
$channels = $config->getAuditChannels();
$default = $config->getAuditDefault();
$use = $config->getAuditUse();

if ($channels === []) {
$this->line('No audit channels configured. Run `audit:add` to add one.');
Expand All @@ -39,14 +39,14 @@ public function handle(ConfigService $config): int
$type = AuditChannelType::tryFrom($typeValue);
$typeLabel = $type?->label() ?? $typeValue;

$isDefault = ($name === $default) ? '★' : '';
$isActive = in_array($name, $use, true) ? '★' : '';

$details = $this->getDetails($type, $channel);

$rows[] = [$name, $typeLabel, $isDefault, $details];
$rows[] = [$name, $typeLabel, $isActive, $details];
}

$this->table(['Name', 'Type', 'Default', 'Details'], $rows);
$this->table(['Name', 'Type', 'Active', 'Details'], $rows);

return ExitCode::Success->value;
}
Expand Down
31 changes: 12 additions & 19 deletions app/Commands/Cloning/RunCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use App\Enums\AuditChannelType;
use App\Enums\ExitCode;
use App\Exceptions\KeyRemappingExhaustedException;
use App\Logging\AuditBuffer;
use App\Services\Audit\AuditDeliveryService;
use App\Services\Audit\AuditLogBuilder;
use App\Services\Audit\AuditLogRenderer;
Expand All @@ -35,7 +36,6 @@
use App\Services\Cloning\CloningYamlValidator;
use App\Services\Cloning\EncryptedFileKeyRemappingStore;
use App\Services\Cloning\KeyRemappingService;
use App\Services\Cloning\RunLogWriter;
use App\Services\Cloning\SkippedRow;
use App\Services\Config\ConfigService;
use App\Services\Database\DatabaseConnectionService;
Expand All @@ -46,6 +46,7 @@
use DateTimeZone;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use LaravelZero\Framework\Commands\Command;
use RuntimeException;
Expand Down Expand Up @@ -91,24 +92,17 @@ public function handle(
DatabaseConnectionService $connector,
SchemaInspector $inspector,
CloningRunOrchestrator $orchestrator,
RunLogWriter $runLog,
AuditBuffer $auditBuffer,
): int {
$ci = (bool) $this->option('ci');
$verbosity = $this->getOutput()->getVerbosity();
$isVerbose = $verbosity >= OutputInterface::VERBOSITY_VERBOSE;
$isVeryVerbose = $verbosity >= OutputInterface::VERBOSITY_VERY_VERBOSE;

if ($isVeryVerbose) {
/** @param array<string, mixed> $extra */
$runLog->setLiveOutput(function (string $level, string $event, array $extra): void {
$formatted = sprintf('[%s] %s', strtoupper($level), $event);
if ($extra !== []) {
$formatted .= ' '.json_encode($extra, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

fwrite(STDERR, $formatted."\n");
});
}
// Map Symfony verbosity to stderr log threshold so `-v` / `-vv` surface
// info / debug events on stderr without needing a separate live-output path.
$stderrLevel = $isVeryVerbose ? 'debug' : ($isVerbose ? 'info' : ($ci ? 'error' : 'warning'));
config(['logging.channels.stderr.level' => $stderrLevel]);

$step = new VerboseStepRenderer($this->output, $ci);

Expand Down Expand Up @@ -297,7 +291,7 @@ public function handle(

// ─── Phase 4-6: Full Run ───────────────────────────────────────────────
$startedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$runLog->log('info', 'run_started', ['source' => $config->connectionName, 'target' => $targetName]);
Log::info('run_started', ['source' => $config->connectionName, 'target' => $targetName]);

try {
$sourceSchema = $step->run('Resolving table order', fn (): DatabaseSchemaData => $inspector->inspect($sourceConnection));
Expand Down Expand Up @@ -467,12 +461,12 @@ public function handle(
);

$finishedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$runLog->log('info', 'run_finished', ['success' => $result->success, 'rows' => $result->totalRows]);
Log::info('run_finished', ['success' => $result->success, 'rows' => $result->totalRows]);

// ─── Phase 6b: Key Mapping Cleanup ────────────────────────────────────
if ($keyRemappingService instanceof KeyRemappingService) {
$keyRemappingService->cleanup();
$runLog->log('info', 'key_mapping_cleanup_completed', []);
Log::info('key_mapping_cleanup_completed', []);
}

// ─── Phase 7: Audit ────────────────────────────────────────────────────
Expand All @@ -486,7 +480,6 @@ public function handle(
$builder = new AuditLogBuilder($signer);
$renderer = new AuditLogRenderer;
$deliveryService = new AuditDeliveryService(
runLog: $runLog,
localAdapter: new LocalDeliveryAdapter,
stdoutAdapter: new StdoutDeliveryAdapter,
stderrAdapter: new StderrDeliveryAdapter,
Expand All @@ -502,7 +495,7 @@ public function handle(
/**
* @var array{auditArtefacts: array<string, string>, templateVars: array<string, string>, processLogContent: string} $auditPayload
*/
$auditPayload = $step->run('Generating audit log', function () use ($builder, $renderer, $signer, $config, $result, $targetName, $startedAt, $finishedAt, $yamlFileName, $runLog, $sourceConnection, $targetConnection): array {
$auditPayload = $step->run('Generating audit log', function () use ($builder, $renderer, $signer, $config, $result, $targetName, $startedAt, $finishedAt, $yamlFileName, $auditBuffer, $sourceConnection, $targetConnection): array {
$auditRecord = $builder->build(
config: $config,
result: $result,
Expand Down Expand Up @@ -535,7 +528,7 @@ public function handle(
'timestamp' => $timestamp,
];

$processLogContent = $runLog->flush();
$processLogContent = $auditBuffer->flush();

return [
'auditArtefacts' => $auditArtefacts,
Expand Down
5 changes: 2 additions & 3 deletions app/Commands/InitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,8 @@ private function ensureDefaultAuditChannels(ConfigService $config): void
}
}

// Ensure default channel is set
if ($config->getAuditDefault() === null) {
$config->setAuditDefault('local');
if ($config->getAuditUse() === []) {
$config->setAuditUse(['local']);
}

if ($created !== []) {
Expand Down
76 changes: 76 additions & 0 deletions app/Logging/AuditBuffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace App\Logging;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;

/**
* In-memory capture of structured log events for the audit PDF / process log artefact.
*
* The `audit_buffer` log channel writes every log record here via AuditBufferHandler.
* RunCommand reads `flush()` once the cloning run finishes and embeds the JSONL in
* the audit delivery payload.
*/
final class AuditBuffer
{
/** @var list<array<string, mixed>> */
private array $records = [];

private readonly DateTimeImmutable $startedAt;

public function __construct()
{
$this->startedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
}

public function startedAt(): DateTimeImmutable
{
return $this->startedAt;
}

/** @param array<string, mixed> $context */
public function record(DateTimeInterface $datetime, string $level, string $event, array $context): void
{
$utc = $datetime instanceof DateTimeImmutable
? $datetime->setTimezone(new DateTimeZone('UTC'))
: new DateTimeImmutable('@'.$datetime->getTimestamp())->setTimezone(new DateTimeZone('UTC'));

$ms = sprintf('%03d', (int) ($datetime->format('u') / 1000));
$ts = $utc->format('Y-m-d\TH:i:s.').$ms.'Z';

$this->records[] = array_merge([
'ts' => $ts,
'level' => strtolower($level),
'event' => $event,
], $context);
}

/** Reset the buffer (useful for tests and multi-run scenarios). */
public function clear(): void
{
$this->records = [];
}

/** @return list<array<string, mixed>> */
public function records(): array
{
return $this->records;
}

/** JSONL (one event per line, trailing newline) for embedding in the audit artefact. */
public function flush(): string
{
if ($this->records === []) {
return '';
}

return implode("\n", array_map(
static fn (array $r): string => (string) json_encode($r, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$this->records,
))."\n";
}
}
39 changes: 39 additions & 0 deletions app/Logging/AuditBufferHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Logging;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;

/**
* Monolog handler that forwards every record into the shared AuditBuffer singleton.
*
* Bridges the Laravel Log facade calls (`Log::info('event', ['key' => 'val'])`) to
* the audit log artefact without exposing AuditBuffer as a logging concern.
*/
final class AuditBufferHandler extends AbstractProcessingHandler
{
public function __construct(
private readonly AuditBuffer $buffer,
int|string|Level $level = Level::Debug,
bool $bubble = true,
) {
parent::__construct($level, $bubble);
}

protected function write(LogRecord $record): void
{
/** @var array<string, mixed> $context */
$context = $record->context;

$this->buffer->record(
datetime: $record->datetime,
level: $record->level->getName(),
event: $record->message,
context: $context,
);
}
}
27 changes: 27 additions & 0 deletions app/Logging/CreateAuditBufferLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Logging;

use Monolog\Logger;

/**
* Custom-driver factory for the `audit_buffer` log channel.
*
* Wired via config/logging.php `'driver' => 'custom', 'via' => CreateAuditBufferLogger::class`.
* Laravel's LogManager invokes `__invoke($config)` and the returned Monolog Logger becomes
* the channel implementation.
*/
final readonly class CreateAuditBufferLogger
{
public function __construct(private AuditBuffer $buffer) {}

/** @param array<string, mixed> $config */
public function __invoke(array $config): Logger
{
$name = is_string($config['name'] ?? null) ? $config['name'] : 'audit_buffer';

return new Logger($name, [new AuditBufferHandler($this->buffer)]);
}
}
Loading