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
19 changes: 19 additions & 0 deletions app/Domain/Docker/Actions/AgentTokenIssueResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Models\AgentToken;

/**
* Pair returned by {@see IssueAgentTokenAction}. The plaintext lives
* here so it travels exactly once — from the action to the controller
* to the session flash — and never gets persisted, logged, or
* serialised by accident.
*/
final readonly class AgentTokenIssueResult
{
public function __construct(
public AgentToken $token,
public string $plaintext,
) {}
}
40 changes: 40 additions & 0 deletions app/Domain/Docker/Actions/ArchiveHostAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Enums\HostStatus;
use App\Models\Host;
use Illuminate\Support\Facades\DB;

/**
* Soft-archive: mark the host as archived, freeze its status, and
* revoke every active agent token so a stale agent on a decommissioned
* box can't keep ingesting. Telemetry history is kept around so the
* host can show up in historical reports later.
*/
class ArchiveHostAction
{
public function execute(Host $host): Host
{
// Idempotent: a second archive call leaves `archived_at`
// pinned to the original timestamp instead of overwriting it.
// Active tokens still get a revoke pass — there should be none
// by then, but the `whereNull` filter makes that a no-op.
if ($host->archived_at !== null) {
return $host;
}

return DB::transaction(function () use ($host): Host {
$host->forceFill([
'status' => HostStatus::Archived->value,
'archived_at' => now(),
])->save();

$host->agentTokens()
->whereNull('revoked_at')
->update(['revoked_at' => now()]);

return $host;
});
}
}
66 changes: 66 additions & 0 deletions app/Domain/Docker/Actions/CreateHostAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Enums\HostConnectionType;
use App\Enums\HostStatus;
use App\Models\Host;
use App\Models\Project;
use Illuminate\Support\Str;

class CreateHostAction
{
/**
* @param array{
* name: string,
* provider?: ?string,
* endpoint_url?: ?string,
* connection_type?: HostConnectionType|string|null,
* os?: ?string,
* docker_version?: ?string,
* cpu_count?: ?int,
* memory_total_mb?: ?int,
* disk_total_gb?: ?int,
* } $attributes
*/
public function execute(Project $project, array $attributes): Host
{
$name = $attributes['name'];
$connectionType = $attributes['connection_type'] ?? HostConnectionType::Agent;

return Host::query()->create([
'project_id' => $project->id,
'name' => $name,
'slug' => $this->uniqueSlugForProject($project, $name),
'provider' => $attributes['provider'] ?? null,
'endpoint_url' => $attributes['endpoint_url'] ?? null,
'connection_type' => $connectionType instanceof HostConnectionType
? $connectionType->value
: (string) $connectionType,
'status' => HostStatus::Pending->value,
'os' => $attributes['os'] ?? null,
'docker_version' => $attributes['docker_version'] ?? null,
'cpu_count' => $attributes['cpu_count'] ?? null,
'memory_total_mb' => $attributes['memory_total_mb'] ?? null,
'disk_total_gb' => $attributes['disk_total_gb'] ?? null,
]);
}

/**
* Slugs are unique per project. The DB unique index is the final
* gate; this loop just keeps the user-visible URL reasonable when
* two hosts share a name.
*/
private function uniqueSlugForProject(Project $project, string $name): string
{
$base = Str::slug($name) ?: 'host';
$candidate = $base;
$i = 2;

while (Host::query()->where('project_id', $project->id)->where('slug', $candidate)->exists()) {
$candidate = $base.'-'.$i++;
}

return $candidate;
}
}
31 changes: 31 additions & 0 deletions app/Domain/Docker/Actions/IssueAgentTokenAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Models\AgentToken;
use App\Models\Host;
use App\Models\User;
use Illuminate\Support\Str;

/**
* Mint a new agent bearer token. The plaintext is returned **once** in
* the result tuple and then never persisted; the database stores only
* the sha256 hash. Callers (controllers) flash the plaintext to the
* session so the Vue layer can show it once and then drop it.
*/
class IssueAgentTokenAction
{
public function execute(Host $host, ?string $name = null, ?User $createdBy = null): AgentTokenIssueResult
{
$plaintext = Str::random(40);

$token = AgentToken::query()->create([
'host_id' => $host->id,
'name' => $name,
'hashed_token' => AgentToken::hash($plaintext),
'created_by_user_id' => $createdBy?->id,
]);

return new AgentTokenIssueResult($token, $plaintext);
}
}
17 changes: 17 additions & 0 deletions app/Domain/Docker/Actions/RevokeAgentTokenAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Models\AgentToken;

class RevokeAgentTokenAction
{
public function execute(AgentToken $token): AgentToken
{
if ($token->revoked_at === null) {
$token->forceFill(['revoked_at' => now()])->save();
}

return $token;
}
}
30 changes: 30 additions & 0 deletions app/Domain/Docker/Actions/RotateAgentTokenAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Models\Host;
use App\Models\User;
use Illuminate\Support\Facades\DB;

/**
* Revoke every currently-active token on a host and mint a fresh one.
* Wrapped in a transaction so the gap between "old token revoked" and
* "new token live" is invisible to the agent.
*/
class RotateAgentTokenAction
{
public function __construct(
private IssueAgentTokenAction $issue,
) {}

public function execute(Host $host, ?string $name = null, ?User $rotatedBy = null): AgentTokenIssueResult
{
return DB::transaction(function () use ($host, $name, $rotatedBy): AgentTokenIssueResult {
$host->agentTokens()
->whereNull('revoked_at')
->update(['revoked_at' => now()]);

return $this->issue->execute($host, $name, $rotatedBy);
});
}
}
33 changes: 33 additions & 0 deletions app/Domain/Docker/Actions/UpdateHostAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Domain\Docker\Actions;

use App\Models\Host;

class UpdateHostAction
{
/**
* Updates only the user-editable fields. Telemetry-derived columns
* (status, last_seen_at, cpu_count, memory_total_mb, etc.) are
* deliberately excluded — they're owned by the ingestion path
* (spec 027).
*
* @param array{
* name?: string,
* provider?: ?string,
* endpoint_url?: ?string,
* } $attributes
*/
public function execute(Host $host, array $attributes): Host
{
$host->fill(array_intersect_key($attributes, array_flip([
'name',
'provider',
'endpoint_url',
])));

$host->save();

return $host;
}
}
25 changes: 25 additions & 0 deletions app/Enums/HostConnectionType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Enums;

/**
* How Nexus reaches (or is reached by) a host.
*
* Phase 6 only ships the `agent` strategy — a small process running on
* the host pushes telemetry to `/agent/telemetry` (spec 027). The other
* cases exist as data so a host can be migrated to a different strategy
* without a column rename later (roadmap §6.5).
*
* - `agent` — push from a Nexus agent on the host (Phase 6 MVP).
* - `ssh` — pull over SSH (future).
* - `docker_api` — pull from Docker Engine API (future).
* - `manual` — no automatic telemetry; the host is tracked for
* inventory only.
*/
enum HostConnectionType: string
{
case Agent = 'agent';
case Ssh = 'ssh';
case DockerApi = 'docker_api';
case Manual = 'manual';
}
42 changes: 42 additions & 0 deletions app/Enums/HostStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Enums;

/**
* Lifecycle of a Docker host (spec 026).
*
* - `pending` — created, no telemetry received yet.
* - `online` — last telemetry was within the host's silence
* threshold.
* - `offline` — last telemetry exceeded the threshold (set by
* spec 029's offline watcher).
* - `degraded` — host is online but reporting unhealthy containers
* or resource pressure (spec 029).
* - `archived` — soft-archived; hidden from active lists. Pairs
* with `archived_at`.
*
* Tones map to `StatusBadge`:
* pending → muted (no signal yet)
* online → success
* offline → danger
* degraded → warning
* archived → muted
*/
enum HostStatus: string
{
case Pending = 'pending';
case Online = 'online';
case Offline = 'offline';
case Degraded = 'degraded';
case Archived = 'archived';

public function badgeTone(): string
{
return match ($this) {
self::Pending, self::Archived => 'muted',
self::Online => 'success',
self::Offline => 'danger',
self::Degraded => 'warning',
};
}
}
73 changes: 73 additions & 0 deletions app/Http/Controllers/Monitoring/AgentTokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace App\Http\Controllers\Monitoring;

use App\Domain\Docker\Actions\IssueAgentTokenAction;
use App\Domain\Docker\Actions\RevokeAgentTokenAction;
use App\Domain\Docker\Actions\RotateAgentTokenAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Monitoring\StoreAgentTokenRequest;
use App\Models\AgentToken;
use App\Models\Host;
use Illuminate\Http\RedirectResponse;

/**
* Lifecycle endpoints for a host's agent token. Plaintext travels via
* the session flash so the Vue layer can show it once on redirect and
* then drop it. We never write the plaintext to logs, JSON, or
* Inertia props.
*/
class AgentTokenController extends Controller
{
public function store(StoreAgentTokenRequest $request, Host $host, IssueAgentTokenAction $issue): RedirectResponse
{
$result = $issue->execute(
$host,
$request->validated()['name'] ?? null,
$request->user(),
);

return redirect()
->route('monitoring.hosts.show', $host)
->with('status', 'Agent token issued. Copy it now — it will not be shown again.')
->with('agentTokenPlaintext', $result->plaintext);
}

public function rotate(StoreAgentTokenRequest $request, Host $host, AgentToken $token, RotateAgentTokenAction $rotate): RedirectResponse
{
$this->ensureBelongs($host, $token);

$result = $rotate->execute(
$host,
$request->validated()['name'] ?? null,
$request->user(),
);

return redirect()
->route('monitoring.hosts.show', $host)
->with('status', 'Agent token rotated. Copy the new token now — it will not be shown again.')
->with('agentTokenPlaintext', $result->plaintext);
}

public function destroy(Host $host, AgentToken $token, RevokeAgentTokenAction $revoke): RedirectResponse
{
$this->authorize('manageTokens', $host);
$this->ensureBelongs($host, $token);

$revoke->execute($token);

return redirect()
->route('monitoring.hosts.show', $host)
->with('status', 'Agent token revoked.');
}

/**
* Defence in depth — the route already binds {host} and {token}
* separately, so a mismatched pair would otherwise just 404 on the
* agent's next request. Surface the mismatch as a 404 here.
*/
private function ensureBelongs(Host $host, AgentToken $token): void
{
abort_if($token->host_id !== $host->id, 404);
}
}
Loading
Loading