From 4b9bd8ae5d5480b446f056a3fda79c015d0520b2 Mon Sep 17 00:00:00 2001 From: Yoany Vaillant Date: Fri, 1 May 2026 11:32:50 -0700 Subject: [PATCH 1/3] =?UTF-8?q?Spec=20026=20=E2=80=94=20Hosts=20+=20agent?= =?UTF-8?q?=20tokens=20scaffolding=20(issue=20#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay the data + auth foundation for Phase 6 (Docker Host Agent MVP). - Migrations: hosts, agent_tokens, containers, host_metric_snapshots, container_metric_snapshots. - Models, factories, HostStatus + HostConnectionType enums. - Docker domain actions: Create/Update/Archive Host, Issue/Rotate/Revoke AgentToken. Plaintext token returned once via AgentTokenIssueResult and flashed to the session — never persisted, never logged. - HostPolicy + AgentTokenPolicy mirror WebsitePolicy (project-owner gates). - Routes + Inertia controllers under /monitoring/hosts/* (sibling to /monitoring/websites/*). - Vue pages Index / Create / Edit / Show + AgentTokenPanel component surfaces the one-time plaintext via Inertia flash. - Tests: 20 new feature tests covering CRUD, policy gating, token issuance / rotation / revocation, and mismatched host/token pairs. Telemetry ingestion lands in spec 027; metric rendering in 028. --- .../Docker/Actions/AgentTokenIssueResult.php | 19 ++ .../Docker/Actions/ArchiveHostAction.php | 32 +++ .../Docker/Actions/CreateHostAction.php | 66 +++++ .../Docker/Actions/IssueAgentTokenAction.php | 31 +++ .../Docker/Actions/RevokeAgentTokenAction.php | 17 ++ .../Docker/Actions/RotateAgentTokenAction.php | 30 +++ .../Docker/Actions/UpdateHostAction.php | 33 +++ app/Enums/HostConnectionType.php | 25 ++ app/Enums/HostStatus.php | 42 +++ .../Monitoring/AgentTokenController.php | 86 ++++++ .../Controllers/Monitoring/HostController.php | 207 +++++++++++++++ app/Http/Middleware/HandleInertiaRequests.php | 5 + .../Requests/Monitoring/StoreHostRequest.php | 41 +++ .../Requests/Monitoring/UpdateHostRequest.php | 33 +++ app/Models/AgentToken.php | 70 +++++ app/Models/Container.php | 74 ++++++ app/Models/ContainerMetricSnapshot.php | 47 ++++ app/Models/Host.php | 81 ++++++ app/Models/HostMetricSnapshot.php | 47 ++++ app/Policies/AgentTokenPolicy.php | 25 ++ app/Policies/HostPolicy.php | 56 ++++ app/Providers/AppServiceProvider.php | 6 + database/factories/AgentTokenFactory.php | 38 +++ database/factories/ContainerFactory.php | 42 +++ .../ContainerMetricSnapshotFactory.php | 29 ++ database/factories/HostFactory.php | 60 +++++ .../factories/HostMetricSnapshotFactory.php | 29 ++ .../2026_05_01_080000_create_hosts_table.php | 79 ++++++ ...05_01_080001_create_agent_tokens_table.php | 54 ++++ ...6_05_01_080002_create_containers_table.php | 80 ++++++ ...003_create_host_metric_snapshots_table.php | 50 ++++ ...reate_container_metric_snapshots_table.php | 39 +++ .../js/Components/Hosts/AgentTokenPanel.vue | 212 +++++++++++++++ .../js/Pages/Monitoring/Hosts/Create.vue | 173 ++++++++++++ resources/js/Pages/Monitoring/Hosts/Edit.vue | 107 ++++++++ resources/js/Pages/Monitoring/Hosts/Index.vue | 198 ++++++++++++++ resources/js/Pages/Monitoring/Hosts/Show.vue | 250 ++++++++++++++++++ resources/js/lib/hostStyles.ts | 29 ++ resources/js/types/index.d.ts | 6 + routes/web.php | 14 + specs/README.md | 2 +- .../026-hosts-and-agent-tokens.md | 144 ++++++++++ specs/phase-6-docker-hosts/README.md | 31 +++ .../Monitoring/AgentTokenLifecycleTest.php | 171 ++++++++++++ .../Feature/Monitoring/HostControllerTest.php | 200 ++++++++++++++ tests/Feature/Monitoring/HostPolicyTest.php | 59 +++++ 46 files changed, 3168 insertions(+), 1 deletion(-) create mode 100644 app/Domain/Docker/Actions/AgentTokenIssueResult.php create mode 100644 app/Domain/Docker/Actions/ArchiveHostAction.php create mode 100644 app/Domain/Docker/Actions/CreateHostAction.php create mode 100644 app/Domain/Docker/Actions/IssueAgentTokenAction.php create mode 100644 app/Domain/Docker/Actions/RevokeAgentTokenAction.php create mode 100644 app/Domain/Docker/Actions/RotateAgentTokenAction.php create mode 100644 app/Domain/Docker/Actions/UpdateHostAction.php create mode 100644 app/Enums/HostConnectionType.php create mode 100644 app/Enums/HostStatus.php create mode 100644 app/Http/Controllers/Monitoring/AgentTokenController.php create mode 100644 app/Http/Controllers/Monitoring/HostController.php create mode 100644 app/Http/Requests/Monitoring/StoreHostRequest.php create mode 100644 app/Http/Requests/Monitoring/UpdateHostRequest.php create mode 100644 app/Models/AgentToken.php create mode 100644 app/Models/Container.php create mode 100644 app/Models/ContainerMetricSnapshot.php create mode 100644 app/Models/Host.php create mode 100644 app/Models/HostMetricSnapshot.php create mode 100644 app/Policies/AgentTokenPolicy.php create mode 100644 app/Policies/HostPolicy.php create mode 100644 database/factories/AgentTokenFactory.php create mode 100644 database/factories/ContainerFactory.php create mode 100644 database/factories/ContainerMetricSnapshotFactory.php create mode 100644 database/factories/HostFactory.php create mode 100644 database/factories/HostMetricSnapshotFactory.php create mode 100644 database/migrations/2026_05_01_080000_create_hosts_table.php create mode 100644 database/migrations/2026_05_01_080001_create_agent_tokens_table.php create mode 100644 database/migrations/2026_05_01_080002_create_containers_table.php create mode 100644 database/migrations/2026_05_01_080003_create_host_metric_snapshots_table.php create mode 100644 database/migrations/2026_05_01_080004_create_container_metric_snapshots_table.php create mode 100644 resources/js/Components/Hosts/AgentTokenPanel.vue create mode 100644 resources/js/Pages/Monitoring/Hosts/Create.vue create mode 100644 resources/js/Pages/Monitoring/Hosts/Edit.vue create mode 100644 resources/js/Pages/Monitoring/Hosts/Index.vue create mode 100644 resources/js/Pages/Monitoring/Hosts/Show.vue create mode 100644 resources/js/lib/hostStyles.ts create mode 100644 specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md create mode 100644 specs/phase-6-docker-hosts/README.md create mode 100644 tests/Feature/Monitoring/AgentTokenLifecycleTest.php create mode 100644 tests/Feature/Monitoring/HostControllerTest.php create mode 100644 tests/Feature/Monitoring/HostPolicyTest.php diff --git a/app/Domain/Docker/Actions/AgentTokenIssueResult.php b/app/Domain/Docker/Actions/AgentTokenIssueResult.php new file mode 100644 index 0000000..bbbc075 --- /dev/null +++ b/app/Domain/Docker/Actions/AgentTokenIssueResult.php @@ -0,0 +1,19 @@ +forceFill([ + 'status' => HostStatus::Archived->value, + 'archived_at' => now(), + ])->save(); + + $host->agentTokens() + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + + return $host; + }); + } +} diff --git a/app/Domain/Docker/Actions/CreateHostAction.php b/app/Domain/Docker/Actions/CreateHostAction.php new file mode 100644 index 0000000..698cc28 --- /dev/null +++ b/app/Domain/Docker/Actions/CreateHostAction.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/app/Domain/Docker/Actions/IssueAgentTokenAction.php b/app/Domain/Docker/Actions/IssueAgentTokenAction.php new file mode 100644 index 0000000..7632251 --- /dev/null +++ b/app/Domain/Docker/Actions/IssueAgentTokenAction.php @@ -0,0 +1,31 @@ +create([ + 'host_id' => $host->id, + 'name' => $name, + 'hashed_token' => AgentToken::hash($plaintext), + 'created_by_user_id' => $createdBy?->id, + ]); + + return new AgentTokenIssueResult($token, $plaintext); + } +} diff --git a/app/Domain/Docker/Actions/RevokeAgentTokenAction.php b/app/Domain/Docker/Actions/RevokeAgentTokenAction.php new file mode 100644 index 0000000..5e853fe --- /dev/null +++ b/app/Domain/Docker/Actions/RevokeAgentTokenAction.php @@ -0,0 +1,17 @@ +revoked_at === null) { + $token->forceFill(['revoked_at' => now()])->save(); + } + + return $token; + } +} diff --git a/app/Domain/Docker/Actions/RotateAgentTokenAction.php b/app/Domain/Docker/Actions/RotateAgentTokenAction.php new file mode 100644 index 0000000..3e98d71 --- /dev/null +++ b/app/Domain/Docker/Actions/RotateAgentTokenAction.php @@ -0,0 +1,30 @@ +agentTokens() + ->whereNull('revoked_at') + ->update(['revoked_at' => now()]); + + return $this->issue->execute($host, $name, $rotatedBy); + }); + } +} diff --git a/app/Domain/Docker/Actions/UpdateHostAction.php b/app/Domain/Docker/Actions/UpdateHostAction.php new file mode 100644 index 0000000..7966458 --- /dev/null +++ b/app/Domain/Docker/Actions/UpdateHostAction.php @@ -0,0 +1,33 @@ +fill(array_intersect_key($attributes, array_flip([ + 'name', + 'provider', + 'endpoint_url', + ]))); + + $host->save(); + + return $host; + } +} diff --git a/app/Enums/HostConnectionType.php b/app/Enums/HostConnectionType.php new file mode 100644 index 0000000..3530957 --- /dev/null +++ b/app/Enums/HostConnectionType.php @@ -0,0 +1,25 @@ + 'muted', + self::Online => 'success', + self::Offline => 'danger', + self::Degraded => 'warning', + }; + } +} diff --git a/app/Http/Controllers/Monitoring/AgentTokenController.php b/app/Http/Controllers/Monitoring/AgentTokenController.php new file mode 100644 index 0000000..064d176 --- /dev/null +++ b/app/Http/Controllers/Monitoring/AgentTokenController.php @@ -0,0 +1,86 @@ +authorize('manageTokens', $host); + + $name = $request->input('name'); + if ($name !== null && ! is_string($name)) { + $name = null; + } + + $result = $issue->execute( + $host, + $name !== null ? mb_substr($name, 0, 80) : 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(Request $request, Host $host, AgentToken $token, RotateAgentTokenAction $rotate): RedirectResponse + { + $this->authorize('manageTokens', $host); + $this->ensureBelongs($host, $token); + + $name = $request->input('name'); + if ($name !== null && ! is_string($name)) { + $name = null; + } + + $result = $rotate->execute( + $host, + $name !== null ? mb_substr($name, 0, 80) : 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); + } +} diff --git a/app/Http/Controllers/Monitoring/HostController.php b/app/Http/Controllers/Monitoring/HostController.php new file mode 100644 index 0000000..4d39ccb --- /dev/null +++ b/app/Http/Controllers/Monitoring/HostController.php @@ -0,0 +1,207 @@ +authorize('viewAny', Host::class); + + $user = $request->user(); + + $hosts = Host::query() + ->with(['project:id,slug,name,color,icon,owner_user_id', 'activeAgentToken']) + ->whereHas('project', fn ($q) => $q->where('owner_user_id', $user->id)) + ->orderBy('name') + ->get() + ->map(fn (Host $host) => $this->transform($host)); + + return Inertia::render('Monitoring/Hosts/Index', [ + 'hosts' => $hosts, + ]); + } + + public function create(Request $request): Response + { + $this->authorize('viewAny', Host::class); + + $user = $request->user(); + + $preselectId = $request->integer('project_id') ?: null; + $preselect = $preselectId !== null + ? Project::query()->where('owner_user_id', $user->id)->find($preselectId) + : null; + + return Inertia::render('Monitoring/Hosts/Create', [ + 'projects' => $this->ownedProjects($user->id), + 'preselectedProjectId' => $preselect?->id, + 'options' => $this->formOptions(), + ]); + } + + public function store(StoreHostRequest $request, CreateHostAction $createHost): RedirectResponse + { + $this->authorize('create', [Host::class, $request->resolvedProject()]); + + $host = $createHost->execute( + $request->resolvedProject(), + $request->validated(), + ); + + return redirect() + ->route('monitoring.hosts.show', $host) + ->with('status', "Host “{$host->name}” created. Mint an agent token to bring it online."); + } + + public function show(Request $request, Host $host): Response + { + $this->authorize('view', $host); + + $host->loadMissing([ + 'project:id,slug,name,color,icon,owner_user_id', + 'activeAgentToken', + ]); + + return Inertia::render('Monitoring/Hosts/Show', [ + 'host' => $this->transform($host), + 'canUpdate' => $request->user()?->can('update', $host) ?? false, + 'canDelete' => $request->user()?->can('delete', $host) ?? false, + 'canManageTokens' => $request->user()?->can('manageTokens', $host) ?? false, + ]); + } + + public function edit(Request $request, Host $host): Response + { + $this->authorize('update', $host); + + $host->loadMissing('project:id,slug,name,color,icon,owner_user_id'); + + return Inertia::render('Monitoring/Hosts/Edit', [ + 'host' => $this->transform($host), + 'options' => $this->formOptions(), + ]); + } + + public function update(UpdateHostRequest $request, Host $host, UpdateHostAction $updateHost): RedirectResponse + { + $updateHost->execute($host, $request->validated()); + + return redirect() + ->route('monitoring.hosts.show', $host) + ->with('status', 'Host updated.'); + } + + public function destroy(Host $host, ArchiveHostAction $archive): RedirectResponse + { + $this->authorize('delete', $host); + + $archive->execute($host); + + return redirect() + ->route('monitoring.hosts.index') + ->with('status', "Host “{$host->name}” archived. Existing agent tokens have been revoked."); + } + + /** + * Single source of truth for the host JSON shape. Centralised so + * Index/Show/Edit don't drift on field set. + */ + private function transform(Host $host): array + { + $activeToken = $host->activeAgentToken; + + return [ + 'id' => $host->id, + 'name' => $host->name, + 'slug' => $host->slug, + 'provider' => $host->provider, + 'endpoint_url' => $host->endpoint_url, + 'connection_type' => $host->connection_type?->value, + 'status' => $host->status?->value, + 'last_seen_at' => $host->last_seen_at?->diffForHumans(), + 'last_seen_at_iso' => $host->last_seen_at?->toIso8601String(), + 'cpu_count' => $host->cpu_count, + 'memory_total_mb' => $host->memory_total_mb, + 'disk_total_gb' => $host->disk_total_gb, + 'os' => $host->os, + 'docker_version' => $host->docker_version, + 'archived_at' => $host->archived_at?->toIso8601String(), + 'project' => $host->project ? [ + 'id' => $host->project->id, + 'slug' => $host->project->slug, + 'name' => $host->project->name, + 'color' => $host->project->color, + 'icon' => $host->project->icon, + ] : null, + // The plaintext token never travels through `transform()` — + // it only exists in the session flash. We surface only + // metadata about the active token so the UI can label + // "active token: agent v0.2 — last seen 2h ago". + 'active_agent_token' => $activeToken ? [ + 'id' => $activeToken->id, + 'name' => $activeToken->name, + 'last_used_at' => $activeToken->last_used_at?->diffForHumans(), + 'created_at' => $activeToken->created_at?->toIso8601String(), + ] : null, + ]; + } + + /** + * Project dropdown payload — only projects the user owns. + * + * @return array> + */ + private function ownedProjects(int $userId): array + { + return Project::query() + ->where('owner_user_id', $userId) + ->orderBy('name') + ->get(['id', 'name', 'color']) + ->map(fn (Project $project) => [ + 'id' => $project->id, + 'name' => $project->name, + 'color' => $project->color, + ]) + ->all(); + } + + private function formOptions(): array + { + return [ + 'connection_types' => collect(HostConnectionType::cases())->map(fn (HostConnectionType $case) => [ + 'value' => $case->value, + 'label' => match ($case) { + HostConnectionType::Agent => 'Agent (push)', + HostConnectionType::Ssh => 'SSH (coming soon)', + HostConnectionType::DockerApi => 'Docker API (coming soon)', + HostConnectionType::Manual => 'Manual / inventory only', + }, + 'enabled' => $case === HostConnectionType::Agent || $case === HostConnectionType::Manual, + ])->all(), + ]; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 77dcb65..2f80f4b 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,6 +42,11 @@ public function share(Request $request): array 'flash' => [ 'status' => fn () => $request->session()->get('status'), 'error' => fn () => $request->session()->get('error'), + // Spec 026 — plaintext agent token shown once after + // issue/rotate. Flashed by AgentTokenController; read + // by AgentTokenPanel.vue via `usePage().props.flash`. + // Never persisted, never logged. + 'agentTokenPlaintext' => fn () => $request->session()->get('agentTokenPlaintext'), ], // Right-rail activity feed (spec 018). Inertia evaluates this // closure on every render (it's not a `LazyProp`); the diff --git a/app/Http/Requests/Monitoring/StoreHostRequest.php b/app/Http/Requests/Monitoring/StoreHostRequest.php new file mode 100644 index 0000000..0effc9d --- /dev/null +++ b/app/Http/Requests/Monitoring/StoreHostRequest.php @@ -0,0 +1,41 @@ +resolvedProject(); + + return $this->user()?->can('create', [Host::class, $project]) === true; + } + + public function rules(): array + { + return [ + 'project_id' => ['required', Rule::exists('projects', 'id')], + 'name' => ['required', 'string', 'max:120'], + 'provider' => ['nullable', 'string', 'max:32'], + 'endpoint_url' => ['nullable', 'url', 'max:2048'], + 'connection_type' => ['required', Rule::enum(HostConnectionType::class)], + ]; + } + + public function resolvedProject(): ?Project + { + $id = $this->input('project_id'); + + return $id !== null ? Project::query()->find($id) : null; + } +} diff --git a/app/Http/Requests/Monitoring/UpdateHostRequest.php b/app/Http/Requests/Monitoring/UpdateHostRequest.php new file mode 100644 index 0000000..e003660 --- /dev/null +++ b/app/Http/Requests/Monitoring/UpdateHostRequest.php @@ -0,0 +1,33 @@ +route('host'); + + return $host !== null + && $this->user()?->can('update', $host) === true; + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + 'provider' => ['nullable', 'string', 'max:32'], + 'endpoint_url' => ['nullable', 'url', 'max:2048'], + ]; + } +} diff --git a/app/Models/AgentToken.php b/app/Models/AgentToken.php new file mode 100644 index 0000000..c0c63d2 --- /dev/null +++ b/app/Models/AgentToken.php @@ -0,0 +1,70 @@ + */ + use HasFactory; + + protected $fillable = [ + 'host_id', + 'name', + 'hashed_token', + 'last_used_at', + 'revoked_at', + 'created_by_user_id', + ]; + + /** + * The hash is the secret — keep it out of every default + * serialisation (Inertia props, JSON, logs). + */ + protected $hidden = [ + 'hashed_token', + ]; + + protected function casts(): array + { + return [ + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function isActive(): bool + { + return $this->revoked_at === null; + } + + /** + * Hash a plaintext token the same way `IssueAgentTokenAction` does + * so `/agent/telemetry` middleware can do `where('hashed_token', + * static::hash($plaintext))` without leaking the plaintext into + * other layers. + */ + public static function hash(string $plaintext): string + { + return hash('sha256', $plaintext); + } +} diff --git a/app/Models/Container.php b/app/Models/Container.php new file mode 100644 index 0000000..696ba16 --- /dev/null +++ b/app/Models/Container.php @@ -0,0 +1,74 @@ + */ + use HasFactory; + + protected $fillable = [ + 'host_id', + 'project_id', + 'container_id', + 'name', + 'image', + 'image_tag', + 'status', + 'state', + 'health_status', + 'ports', + 'labels', + 'cpu_percent', + 'memory_usage_mb', + 'memory_limit_mb', + 'memory_percent', + 'network_rx_bytes', + 'network_tx_bytes', + 'block_read_bytes', + 'block_write_bytes', + 'started_at', + 'finished_at', + 'last_seen_at', + ]; + + protected function casts(): array + { + return [ + 'ports' => 'array', + 'labels' => 'array', + 'cpu_percent' => 'float', + 'memory_percent' => 'float', + 'memory_usage_mb' => 'integer', + 'memory_limit_mb' => 'integer', + 'network_rx_bytes' => 'integer', + 'network_tx_bytes' => 'integer', + 'block_read_bytes' => 'integer', + 'block_write_bytes' => 'integer', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'last_seen_at' => 'datetime', + ]; + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function metricSnapshots(): HasMany + { + return $this->hasMany(ContainerMetricSnapshot::class); + } +} diff --git a/app/Models/ContainerMetricSnapshot.php b/app/Models/ContainerMetricSnapshot.php new file mode 100644 index 0000000..0d987d3 --- /dev/null +++ b/app/Models/ContainerMetricSnapshot.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + protected $fillable = [ + 'container_id', + 'cpu_percent', + 'memory_usage_mb', + 'memory_limit_mb', + 'memory_percent', + 'network_rx_bytes', + 'network_tx_bytes', + 'block_read_bytes', + 'block_write_bytes', + 'recorded_at', + ]; + + protected function casts(): array + { + return [ + 'cpu_percent' => 'float', + 'memory_percent' => 'float', + 'memory_usage_mb' => 'integer', + 'memory_limit_mb' => 'integer', + 'network_rx_bytes' => 'integer', + 'network_tx_bytes' => 'integer', + 'block_read_bytes' => 'integer', + 'block_write_bytes' => 'integer', + 'recorded_at' => 'datetime', + ]; + } + + public function container(): BelongsTo + { + return $this->belongsTo(Container::class); + } +} diff --git a/app/Models/Host.php b/app/Models/Host.php new file mode 100644 index 0000000..55ab97c --- /dev/null +++ b/app/Models/Host.php @@ -0,0 +1,81 @@ + */ + use HasFactory; + + protected $fillable = [ + 'project_id', + 'name', + 'slug', + 'provider', + 'endpoint_url', + 'connection_type', + 'status', + 'last_seen_at', + 'cpu_count', + 'memory_total_mb', + 'disk_total_gb', + 'os', + 'docker_version', + 'metadata', + 'archived_at', + ]; + + protected function casts(): array + { + return [ + 'connection_type' => HostConnectionType::class, + 'status' => HostStatus::class, + 'last_seen_at' => 'datetime', + 'archived_at' => 'datetime', + 'cpu_count' => 'integer', + 'memory_total_mb' => 'integer', + 'disk_total_gb' => 'integer', + 'metadata' => 'array', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function agentTokens(): HasMany + { + return $this->hasMany(AgentToken::class); + } + + /** + * The currently-active agent token for this host. Null when none + * exists or all have been revoked. Only one is expected to be + * active at a time — `RotateAgentTokenAction` revokes the previous + * before issuing the next. + */ + public function activeAgentToken(): HasOne + { + return $this->hasOne(AgentToken::class)->whereNull('revoked_at')->latestOfMany(); + } + + public function containers(): HasMany + { + return $this->hasMany(Container::class); + } + + public function metricSnapshots(): HasMany + { + return $this->hasMany(HostMetricSnapshot::class); + } +} diff --git a/app/Models/HostMetricSnapshot.php b/app/Models/HostMetricSnapshot.php new file mode 100644 index 0000000..7ea75d0 --- /dev/null +++ b/app/Models/HostMetricSnapshot.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + protected $fillable = [ + 'host_id', + 'cpu_percent', + 'memory_used_mb', + 'memory_total_mb', + 'disk_used_gb', + 'disk_total_gb', + 'load_average', + 'network_rx_bytes', + 'network_tx_bytes', + 'recorded_at', + ]; + + protected function casts(): array + { + return [ + 'cpu_percent' => 'float', + 'load_average' => 'float', + 'memory_used_mb' => 'integer', + 'memory_total_mb' => 'integer', + 'disk_used_gb' => 'integer', + 'disk_total_gb' => 'integer', + 'network_rx_bytes' => 'integer', + 'network_tx_bytes' => 'integer', + 'recorded_at' => 'datetime', + ]; + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class); + } +} diff --git a/app/Policies/AgentTokenPolicy.php b/app/Policies/AgentTokenPolicy.php new file mode 100644 index 0000000..11776b1 --- /dev/null +++ b/app/Policies/AgentTokenPolicy.php @@ -0,0 +1,25 @@ +host !== null + && $user->can('view', $token->host); + } + + public function delete(User $user, AgentToken $token): bool + { + return $token->host !== null + && $user->can('manageTokens', $token->host); + } +} diff --git a/app/Policies/HostPolicy.php b/app/Policies/HostPolicy.php new file mode 100644 index 0000000..f55e17d --- /dev/null +++ b/app/Policies/HostPolicy.php @@ -0,0 +1,56 @@ +hasVerifiedEmail(); + } + + public function view(User $user, Host $host): bool + { + return $user->hasVerifiedEmail(); + } + + /** + * `create` is project-scoped — invoked via + * `Gate::authorize('create', [Host::class, $project])`. + */ + public function create(User $user, ?Project $project): bool + { + return $project !== null + && $user->hasVerifiedEmail() + && $user->can('update', $project); + } + + public function update(User $user, Host $host): bool + { + $project = $host->project; + + return $project !== null + && $user->hasVerifiedEmail() + && $user->can('update', $project); + } + + public function delete(User $user, Host $host): bool + { + return $this->update($user, $host); + } + + /** Token issue/rotate/revoke share the host's update gate. */ + public function manageTokens(User $user, Host $host): bool + { + return $this->update($user, $host); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1a71491..e80416e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,13 @@ namespace App\Providers; +use App\Models\AgentToken; +use App\Models\Host; use App\Models\Project; use App\Models\Repository; use App\Models\Website; +use App\Policies\AgentTokenPolicy; +use App\Policies\HostPolicy; use App\Policies\ProjectPolicy; use App\Policies\RepositoryPolicy; use App\Policies\WebsitePolicy; @@ -33,6 +37,8 @@ public function boot(): void Gate::policy(Project::class, ProjectPolicy::class); Gate::policy(Repository::class, RepositoryPolicy::class); Gate::policy(Website::class, WebsitePolicy::class); + Gate::policy(Host::class, HostPolicy::class); + Gate::policy(AgentToken::class, AgentTokenPolicy::class); // Force https URL generation when APP_URL is https. Required for // Cloudflare/ngrok tunnels: TLS terminates at the tunnel and diff --git a/database/factories/AgentTokenFactory.php b/database/factories/AgentTokenFactory.php new file mode 100644 index 0000000..172e0e1 --- /dev/null +++ b/database/factories/AgentTokenFactory.php @@ -0,0 +1,38 @@ + */ +class AgentTokenFactory extends Factory +{ + protected $model = AgentToken::class; + + public function definition(): array + { + // Tests almost never need the plaintext — they assert against + // the hash directly. When they do need the plaintext, they go + // through `IssueAgentTokenAction` which is the production path. + $plaintext = Str::random(40); + + return [ + 'host_id' => Host::factory(), + 'name' => 'agent token', + 'hashed_token' => AgentToken::hash($plaintext), + 'last_used_at' => null, + 'revoked_at' => null, + 'created_by_user_id' => null, + ]; + } + + public function revoked(): static + { + return $this->state(fn () => [ + 'revoked_at' => now(), + ]); + } +} diff --git a/database/factories/ContainerFactory.php b/database/factories/ContainerFactory.php new file mode 100644 index 0000000..c0bfec2 --- /dev/null +++ b/database/factories/ContainerFactory.php @@ -0,0 +1,42 @@ + */ +class ContainerFactory extends Factory +{ + protected $model = Container::class; + + public function definition(): array + { + return [ + 'host_id' => Host::factory(), + 'project_id' => null, + 'container_id' => Str::random(12), + 'name' => 'svc-'.fake()->word(), + 'image' => 'nginx', + 'image_tag' => 'latest', + 'status' => 'running', + 'state' => 'running', + 'health_status' => null, + 'ports' => [], + 'labels' => [], + 'cpu_percent' => null, + 'memory_usage_mb' => null, + 'memory_limit_mb' => null, + 'memory_percent' => null, + 'network_rx_bytes' => null, + 'network_tx_bytes' => null, + 'block_read_bytes' => null, + 'block_write_bytes' => null, + 'started_at' => now()->subHours(2), + 'finished_at' => null, + 'last_seen_at' => now(), + ]; + } +} diff --git a/database/factories/ContainerMetricSnapshotFactory.php b/database/factories/ContainerMetricSnapshotFactory.php new file mode 100644 index 0000000..fef7e15 --- /dev/null +++ b/database/factories/ContainerMetricSnapshotFactory.php @@ -0,0 +1,29 @@ + */ +class ContainerMetricSnapshotFactory extends Factory +{ + protected $model = ContainerMetricSnapshot::class; + + public function definition(): array + { + return [ + 'container_id' => Container::factory(), + 'cpu_percent' => fake()->randomFloat(1, 0, 100), + 'memory_usage_mb' => fake()->numberBetween(64, 4096), + 'memory_limit_mb' => 4096, + 'memory_percent' => fake()->randomFloat(1, 0, 100), + 'network_rx_bytes' => fake()->numberBetween(0, 1_000_000_000), + 'network_tx_bytes' => fake()->numberBetween(0, 1_000_000_000), + 'block_read_bytes' => fake()->numberBetween(0, 1_000_000_000), + 'block_write_bytes' => fake()->numberBetween(0, 1_000_000_000), + 'recorded_at' => now(), + ]; + } +} diff --git a/database/factories/HostFactory.php b/database/factories/HostFactory.php new file mode 100644 index 0000000..0689848 --- /dev/null +++ b/database/factories/HostFactory.php @@ -0,0 +1,60 @@ + */ +class HostFactory extends Factory +{ + protected $model = Host::class; + + public function definition(): array + { + $name = fake()->unique()->domainWord().'-'.fake()->randomNumber(2, true); + + return [ + 'project_id' => Project::factory(), + 'name' => $name, + 'slug' => Str::slug($name), + 'provider' => 'self-hosted', + 'endpoint_url' => null, + 'connection_type' => HostConnectionType::Agent->value, + 'status' => HostStatus::Pending->value, + 'last_seen_at' => null, + 'cpu_count' => null, + 'memory_total_mb' => null, + 'disk_total_gb' => null, + 'os' => null, + 'docker_version' => null, + 'metadata' => null, + 'archived_at' => null, + ]; + } + + public function online(): static + { + return $this->state(fn () => [ + 'status' => HostStatus::Online->value, + 'last_seen_at' => now(), + 'cpu_count' => 4, + 'memory_total_mb' => 8192, + 'disk_total_gb' => 100, + 'os' => 'Ubuntu 24.04', + 'docker_version' => '26.1.0', + ]); + } + + public function archived(): static + { + return $this->state(fn () => [ + 'status' => HostStatus::Archived->value, + 'archived_at' => now(), + ]); + } +} diff --git a/database/factories/HostMetricSnapshotFactory.php b/database/factories/HostMetricSnapshotFactory.php new file mode 100644 index 0000000..cf7f181 --- /dev/null +++ b/database/factories/HostMetricSnapshotFactory.php @@ -0,0 +1,29 @@ + */ +class HostMetricSnapshotFactory extends Factory +{ + protected $model = HostMetricSnapshot::class; + + public function definition(): array + { + return [ + 'host_id' => Host::factory(), + 'cpu_percent' => fake()->randomFloat(1, 5, 95), + 'memory_used_mb' => fake()->numberBetween(1024, 16384), + 'memory_total_mb' => 16384, + 'disk_used_gb' => fake()->numberBetween(20, 200), + 'disk_total_gb' => 256, + 'load_average' => fake()->randomFloat(2, 0, 4), + 'network_rx_bytes' => fake()->numberBetween(1_000_000, 10_000_000_000), + 'network_tx_bytes' => fake()->numberBetween(1_000_000, 10_000_000_000), + 'recorded_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_05_01_080000_create_hosts_table.php b/database/migrations/2026_05_01_080000_create_hosts_table.php new file mode 100644 index 0000000..534fc85 --- /dev/null +++ b/database/migrations/2026_05_01_080000_create_hosts_table.php @@ -0,0 +1,79 @@ +id(); + + $table->foreignId('project_id') + ->constrained('projects') + ->cascadeOnDelete(); + + $table->string('name'); + // Slug is unique within a project so URLs (28+) and agent + // self-identification can rely on it. Per-project rather + // than global so two projects can both have a `prod-01`. + $table->string('slug', 80); + + // Free-text label of where the host runs (e.g. "DigitalOcean + // FRA1", "self-hosted"). Pure metadata for now — the agent + // strategy split (§6.5 of the roadmap) will bind it later. + $table->string('provider', 32)->nullable(); + + // Informational only for `connection_type=agent` (the agent + // pushes to us). Reserved for ssh/docker_api strategies in + // a later phase. + $table->string('endpoint_url', 2048)->nullable(); + + // agent | ssh | docker_api | manual (per HostConnectionType + // enum). Phase 6 only ships the `agent` path; the others + // exist as data so a host can be migrated without a column + // rename later. + $table->string('connection_type', 16)->default('agent'); + + // pending | online | offline | degraded | archived (per + // HostStatus enum). `pending` is the initial state — a host + // that has never reported telemetry. Transitions to other + // states arrive in 027 (online on first telemetry) and 029 + // (offline / degraded via the watcher). + $table->string('status', 16)->default('pending'); + + $table->timestamp('last_seen_at')->nullable(); + + // Static facts captured from telemetry once the host is + // online (or filled in by the user at create time). Stored + // as columns rather than JSON because they're surfaced on + // the host card and queried in aggregate. + $table->unsignedSmallInteger('cpu_count')->nullable(); + $table->unsignedInteger('memory_total_mb')->nullable(); + $table->unsignedInteger('disk_total_gb')->nullable(); + $table->string('os', 80)->nullable(); + $table->string('docker_version', 32)->nullable(); + + // Free-form bag for fields we don't yet promote to columns + // (kernel version, agent build, region tags). Bounded by + // payload validation in 027. + $table->json('metadata')->nullable(); + + // Soft-archive: keep the row for historical reports but hide + // it from the active list. Pairs with `status=archived`. + $table->timestamp('archived_at')->nullable(); + + $table->timestamps(); + + $table->unique(['project_id', 'slug']); + $table->index(['project_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('hosts'); + } +}; diff --git a/database/migrations/2026_05_01_080001_create_agent_tokens_table.php b/database/migrations/2026_05_01_080001_create_agent_tokens_table.php new file mode 100644 index 0000000..645c7a3 --- /dev/null +++ b/database/migrations/2026_05_01_080001_create_agent_tokens_table.php @@ -0,0 +1,54 @@ +id(); + + $table->foreignId('host_id') + ->constrained('hosts') + ->cascadeOnDelete(); + + // Optional human label so a user can rotate tokens without + // losing context ("rotated 2026-05-01", "agent v0.2"). The + // active token also doubles as the install-command label. + $table->string('name', 80)->nullable(); + + // sha256 of the plaintext bearer token. Plaintext is shown + // to the user once at issuance/rotation and then discarded. + // Unique so middleware can `where(hashed_token = ?)` and + // hit the index. + $table->string('hashed_token', 64)->unique(); + + $table->timestamp('last_used_at')->nullable(); + + // Set when the token is rotated or explicitly revoked. The + // hash stays in the row so a stolen plaintext can be traced + // post-incident; middleware ignores rows where this is set. + $table->timestamp('revoked_at')->nullable(); + + $table->foreignId('created_by_user_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + + $table->timestamps(); + + // Active-token lookup pattern: by host, scoped to non-revoked + // rows. Index covers the host_id half; the revoked filter is + // small enough to satisfy at scan time. + $table->index(['host_id', 'revoked_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('agent_tokens'); + } +}; diff --git a/database/migrations/2026_05_01_080002_create_containers_table.php b/database/migrations/2026_05_01_080002_create_containers_table.php new file mode 100644 index 0000000..53d91d4 --- /dev/null +++ b/database/migrations/2026_05_01_080002_create_containers_table.php @@ -0,0 +1,80 @@ +id(); + + $table->foreignId('host_id') + ->constrained('hosts') + ->cascadeOnDelete(); + + // Optional project pin — set when the container's labels + // identify it as belonging to a specific project. Defaults + // null; the agent reconciliation in 027 fills it where it + // can. + $table->foreignId('project_id') + ->nullable() + ->constrained('projects') + ->nullOnDelete(); + + // Docker's own container ID (12 or 64 char hex). Unique per + // host because Docker recycles short IDs across hosts. + $table->string('container_id', 80); + + $table->string('name'); + $table->string('image'); + $table->string('image_tag', 128)->nullable(); + + // running | exited | created | paused | restarting | dead. + // Stored as string so Docker's evolving status set doesn't + // require migrations. + $table->string('status', 32)->nullable(); + + // Same string as `status` historically — kept distinct + // because Docker's `State` object is richer (boolean + // running, paused, etc.). MVP just mirrors `status`; future + // probes can populate fine-grained fields. + $table->string('state', 32)->nullable(); + + // healthy | unhealthy | starting | none. Sourced from + // Docker healthcheck output when present. + $table->string('health_status', 16)->nullable(); + + $table->json('ports')->nullable(); + $table->json('labels')->nullable(); + + // Latest stat sample. The full time series goes into + // container_metric_snapshots; these columns power the + // current-state UI without a join. + $table->float('cpu_percent')->nullable(); + $table->unsignedBigInteger('memory_usage_mb')->nullable(); + $table->unsignedBigInteger('memory_limit_mb')->nullable(); + $table->float('memory_percent')->nullable(); + $table->unsignedBigInteger('network_rx_bytes')->nullable(); + $table->unsignedBigInteger('network_tx_bytes')->nullable(); + $table->unsignedBigInteger('block_read_bytes')->nullable(); + $table->unsignedBigInteger('block_write_bytes')->nullable(); + + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + + $table->timestamps(); + + $table->unique(['host_id', 'container_id']); + $table->index(['host_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('containers'); + } +}; diff --git a/database/migrations/2026_05_01_080003_create_host_metric_snapshots_table.php b/database/migrations/2026_05_01_080003_create_host_metric_snapshots_table.php new file mode 100644 index 0000000..d9c2ab7 --- /dev/null +++ b/database/migrations/2026_05_01_080003_create_host_metric_snapshots_table.php @@ -0,0 +1,50 @@ +id(); + + $table->foreignId('host_id') + ->constrained('hosts') + ->cascadeOnDelete(); + + // Percentage utilisation at the snapshot moment. Stored as + // float — Docker reports up to 1 decimal place. + $table->float('cpu_percent')->nullable(); + + $table->unsignedBigInteger('memory_used_mb')->nullable(); + $table->unsignedBigInteger('memory_total_mb')->nullable(); + $table->unsignedBigInteger('disk_used_gb')->nullable(); + $table->unsignedBigInteger('disk_total_gb')->nullable(); + + // Unix `uptime` 1-min load average. Not normalised against + // CPU count — that's done at render time. + $table->float('load_average')->nullable(); + + $table->unsignedBigInteger('network_rx_bytes')->nullable(); + $table->unsignedBigInteger('network_tx_bytes')->nullable(); + + // The agent's clock for when these stats were observed. The + // `created_at` column captures when the row landed, which is + // useful for ingestion-lag monitoring. + $table->timestamp('recorded_at'); + + $table->timestamps(); + + // Time-series query pattern. + $table->index(['host_id', 'recorded_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('host_metric_snapshots'); + } +}; diff --git a/database/migrations/2026_05_01_080004_create_container_metric_snapshots_table.php b/database/migrations/2026_05_01_080004_create_container_metric_snapshots_table.php new file mode 100644 index 0000000..db6a176 --- /dev/null +++ b/database/migrations/2026_05_01_080004_create_container_metric_snapshots_table.php @@ -0,0 +1,39 @@ +id(); + + $table->foreignId('container_id') + ->constrained('containers') + ->cascadeOnDelete(); + + $table->float('cpu_percent')->nullable(); + $table->unsignedBigInteger('memory_usage_mb')->nullable(); + $table->unsignedBigInteger('memory_limit_mb')->nullable(); + $table->float('memory_percent')->nullable(); + $table->unsignedBigInteger('network_rx_bytes')->nullable(); + $table->unsignedBigInteger('network_tx_bytes')->nullable(); + $table->unsignedBigInteger('block_read_bytes')->nullable(); + $table->unsignedBigInteger('block_write_bytes')->nullable(); + + $table->timestamp('recorded_at'); + + $table->timestamps(); + + $table->index(['container_id', 'recorded_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('container_metric_snapshots'); + } +}; diff --git a/resources/js/Components/Hosts/AgentTokenPanel.vue b/resources/js/Components/Hosts/AgentTokenPanel.vue new file mode 100644 index 0000000..abd153f --- /dev/null +++ b/resources/js/Components/Hosts/AgentTokenPanel.vue @@ -0,0 +1,212 @@ + + + diff --git a/resources/js/Pages/Monitoring/Hosts/Create.vue b/resources/js/Pages/Monitoring/Hosts/Create.vue new file mode 100644 index 0000000..ac8fdcc --- /dev/null +++ b/resources/js/Pages/Monitoring/Hosts/Create.vue @@ -0,0 +1,173 @@ + + + diff --git a/resources/js/Pages/Monitoring/Hosts/Edit.vue b/resources/js/Pages/Monitoring/Hosts/Edit.vue new file mode 100644 index 0000000..e35e9b3 --- /dev/null +++ b/resources/js/Pages/Monitoring/Hosts/Edit.vue @@ -0,0 +1,107 @@ + + + diff --git a/resources/js/Pages/Monitoring/Hosts/Index.vue b/resources/js/Pages/Monitoring/Hosts/Index.vue new file mode 100644 index 0000000..283cdb9 --- /dev/null +++ b/resources/js/Pages/Monitoring/Hosts/Index.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/Pages/Monitoring/Hosts/Show.vue b/resources/js/Pages/Monitoring/Hosts/Show.vue new file mode 100644 index 0000000..487561f --- /dev/null +++ b/resources/js/Pages/Monitoring/Hosts/Show.vue @@ -0,0 +1,250 @@ + + + diff --git a/resources/js/lib/hostStyles.ts b/resources/js/lib/hostStyles.ts new file mode 100644 index 0000000..2676390 --- /dev/null +++ b/resources/js/lib/hostStyles.ts @@ -0,0 +1,29 @@ +/** + * Shared style helpers for Docker hosts (spec 026). + * + * Mirrors `websiteStyles.ts`: keep this map in sync with + * `HostStatus::badgeTone()` so server- and client-rendered badges + * agree. + */ + +export type HostStatusValue = + | 'pending' + | 'online' + | 'offline' + | 'degraded' + | 'archived' + | string + | null; + +export const hostStatusTone = ( + status: HostStatusValue, +): 'muted' | 'success' | 'warning' | 'danger' => + ( + ({ + pending: 'muted', + online: 'success', + offline: 'danger', + degraded: 'warning', + archived: 'muted', + }) as const + )[status ?? ''] ?? 'muted'; diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index acf7ed6..a382247 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -14,6 +14,12 @@ export type PageProps< flash?: { status?: string | null; error?: string | null; + /** + * Spec 026 — plaintext agent token surfaced once after issue or + * rotate. Read by `Components/Hosts/AgentTokenPanel.vue`. Never + * persisted on the client; the next page load drops it. + */ + agentTokenPlaintext?: string | null; }; /** * Right-rail activity feed shared by `HandleInertiaRequests::share()` diff --git a/routes/web.php b/routes/web.php index b67cb38..f2d17a9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,8 @@ use App\Http\Controllers\DeploymentController; use App\Http\Controllers\GithubConnectionController; use App\Http\Controllers\GithubRepositoryImportController; +use App\Http\Controllers\Monitoring\AgentTokenController; +use App\Http\Controllers\Monitoring\HostController; use App\Http\Controllers\Monitoring\WebsiteController; use App\Http\Controllers\Monitoring\WebsiteProbeController; use App\Http\Controllers\OverviewController; @@ -98,6 +100,18 @@ ->names('monitoring.websites'); Route::post('/monitoring/websites/{website}/probe', WebsiteProbeController::class) ->name('monitoring.websites.probe'); + + // Spec 026 — Docker hosts CRUD + agent token lifecycle. Telemetry + // ingestion arrives in spec 027; the UI metric rendering in 028. + Route::resource('monitoring/hosts', HostController::class) + ->parameters(['hosts' => 'host']) + ->names('monitoring.hosts'); + Route::post('/monitoring/hosts/{host}/tokens', [AgentTokenController::class, 'store']) + ->name('monitoring.hosts.tokens.store'); + Route::post('/monitoring/hosts/{host}/tokens/{token}/rotate', [AgentTokenController::class, 'rotate']) + ->name('monitoring.hosts.tokens.rotate'); + Route::delete('/monitoring/hosts/{host}/tokens/{token}', [AgentTokenController::class, 'destroy']) + ->name('monitoring.hosts.tokens.destroy'); }); // Spec 017 — GitHub webhooks (no auth/CSRF; signature-verified inside). diff --git a/specs/README.md b/specs/README.md index 0087ceb..bbab608 100644 --- a/specs/README.md +++ b/specs/README.md @@ -41,7 +41,7 @@ Status legend: ⬜ not started · 🟡 in progress · 🟢 done · 🔴 blocked | 3 | GitHub Webhooks & Activity Feed | 🟢 | 3/3 specs done (017–019). Phase complete. | | 4 | Deployments & CI/CD | 🟢 | 3/3 specs done (020–022). Phase complete. | | 5 | Website Monitoring | 🟢 | 3/3 specs done (023–025). Phase complete. | -| 6 | Docker Host Agent MVP | ⬜ | — | +| 6 | Docker Host Agent MVP | 🟡 | 0/4 specs done (026–029). Folder + spec 026 drafted. | | 7 | Alerts Engine | ⬜ | — | | 8 | Analytics & Health Scores | ⬜ | — | | 9 | Polish & Production Readiness | ⬜ | — | diff --git a/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md new file mode 100644 index 0000000..c0ac379 --- /dev/null +++ b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md @@ -0,0 +1,144 @@ +--- +spec: hosts-and-agent-tokens +phase: 6 +status: in-progress +owner: Yoany +created: 2026-05-01 +updated: 2026-05-01 +--- + +# 026 — Hosts + Agent Tokens Scaffolding + +## Goal +Lay the data + auth foundation for Phase 6. Add the `hosts`, `agent_tokens`, `containers`, `host_metric_snapshots`, and `container_metric_snapshots` tables; build the host CRUD UX inside Settings; and let the user mint an agent token (shown once, hashed at rest) and rotate it. No telemetry ingestion in this spec — that lands in 027. + +Roadmap refs: §8.7 Docker Hosts, §9.1 Core Tables, §16.5 Agent Security. + +## Scope + +**In scope:** +- Migrations + models for `hosts`, `agent_tokens`, `containers`, `host_metric_snapshots`, `container_metric_snapshots`. +- `Host` policy + factory; `AgentToken` policy + factory. +- Host CRUD action classes (`CreateHostAction`, `UpdateHostAction`, `ArchiveHostAction`). +- Token issuance (`IssueAgentTokenAction`) — generates a random 40-char token, stores `hashed_token`, returns plaintext **once** in the response payload only. +- Token rotation (`RotateAgentTokenAction`) — invalidates the previous token by replacing the hash; previous plaintext is unrecoverable. +- Settings UI under `/settings/integrations/hosts` (or analogous route consistent with how other integrations live): + - Hosts table. + - Create / edit form. + - "Show install command" panel that displays the agent token **once** after creation/rotation. +- Project detail page gets a "Hosts" tab placeholder (real wiring lands in 028). +- Tests for: action classes, policy gating, token issuance flow (token shown once, hash persisted), rotation flow. + +**Out of scope:** +- The `/agent/telemetry` endpoint and `agent.auth` middleware (027). +- Reference agent script (027). +- Hosts index/show pages and metric rendering (028). +- Offline detection job + activity events (029). +- Alert table integration (Phase 7). + +## Plan + +1. **Migrations.** New files under `database/migrations/` in this order: + - `create_hosts_table` — fields per roadmap §8.7 (`id`, `project_id`, `name`, `slug`, `provider`, `endpoint_url` *nullable*, `connection_type`, `status` enum (`pending|online|offline|degraded|archived`), `last_seen_at` *nullable*, `cpu_count`, `memory_total_mb`, `disk_total_gb`, `os`, `docker_version`, `metadata` json, `archived_at` *nullable*, timestamps). Slug unique per project. + - `create_agent_tokens_table` — `id`, `host_id`, `name` (label), `hashed_token` (string, indexed unique), `last_used_at` *nullable*, `revoked_at` *nullable*, `created_by_user_id`, timestamps. + - `create_containers_table` — fields per roadmap §8.7 (`id`, `host_id`, `project_id` *nullable*, `container_id`, `name`, `image`, `image_tag`, `status`, `state`, `health_status`, `ports` json, `labels` json, plus stats fields). Index on `(host_id, container_id)`. + - `create_host_metric_snapshots_table` — `id`, `host_id`, `cpu_percent`, `memory_used_mb`, `memory_total_mb`, `disk_used_gb`, `disk_total_gb`, `load_average` (float), `network_rx_bytes` bigint, `network_tx_bytes` bigint, `recorded_at`, timestamps. Index `(host_id, recorded_at)`. + - `create_container_metric_snapshots_table` — same shape but per-container plus `cpu_percent`, `memory_usage_mb`, `memory_limit_mb`, `block_read_bytes`, `block_write_bytes`. Index `(container_id, recorded_at)`. + +2. **Models.** + - `app/Models/Host.php` — belongsTo Project, hasMany AgentToken, hasMany Container, hasMany HostMetricSnapshot. Casts: `metadata` array, `last_seen_at` datetime, `archived_at` datetime, `status` to enum. `slug()` accessor. + - `app/Models/AgentToken.php` — belongsTo Host, belongsTo User (creator). Hidden: `hashed_token`. Static helper `findActiveByPlaintext(string $plaintext): ?self` that hashes + lookups; used later by middleware. + - `app/Models/Container.php`, `app/Models/HostMetricSnapshot.php`, `app/Models/ContainerMetricSnapshot.php`. + +3. **Enums.** (Repo convention: top-level `app/Enums/`, not nested under domain folders.) + - `app/Enums/HostStatus.php` — string enum (`pending|online|offline|degraded|archived`). Includes `badgeTone()` matching `WebsiteStatus::badgeTone()`. + - `app/Enums/HostConnectionType.php` — `agent | ssh | docker_api | manual`. + +4. **Actions.** + - `app/Domain/Docker/Actions/CreateHostAction.php` — input DTO, persists `Host` with `status: pending`, returns model. + - `app/Domain/Docker/Actions/UpdateHostAction.php`. + - `app/Domain/Docker/Actions/ArchiveHostAction.php` — soft-archive (sets `archived_at`, status `archived`, revokes any active tokens). + - `app/Domain/Docker/Actions/IssueAgentTokenAction.php` — generates `Str::random(40)`, computes `hash('sha256', ...)`, persists `AgentToken`, returns object containing `{token: AgentToken, plaintext: string}`. Plaintext is never logged. + - `app/Domain/Docker/Actions/RotateAgentTokenAction.php` — marks previous token revoked, issues a new one. + - `app/Domain/Docker/Actions/RevokeAgentTokenAction.php` — sets `revoked_at`. + +5. **Policies.** + - `app/Policies/HostPolicy.php` — view/update/delete tied to project membership (mirrors how `WebsitePolicy` was wired in Phase 5). + - `app/Policies/AgentTokenPolicy.php` — same. + +6. **Controllers + routes.** (Repo convention: hosts sit under `/monitoring/*` next to websites, per the comment in `WebsiteController.php`. Tests + Form Requests follow the same `Monitoring/` namespace.) + - `app/Http/Controllers/Monitoring/HostController.php` — index/create/store/show/edit/update/destroy. Uses Inertia. Mirrors `WebsiteController` (private `transform()` for response shape, `ownedProjects()` helper, `?project_id=N` preselect on `create`). + - `app/Http/Controllers/Monitoring/AgentTokenController.php` — `store`, `rotate`, `destroy`. Plaintext is returned via session flash on `store`/`rotate` so the Vue layer can show it once. + - `app/Http/Requests/Monitoring/StoreHostRequest.php` + `UpdateHostRequest.php`. + - Routes append to `routes/web.php` next to the websites routes: + ```php + Route::resource('monitoring/hosts', HostController::class) + ->parameters(['hosts' => 'host']) + ->names('monitoring.hosts'); + Route::post('/monitoring/hosts/{host}/tokens', [AgentTokenController::class, 'store']) + ->name('monitoring.hosts.tokens.store'); + Route::post('/monitoring/hosts/{host}/tokens/{token}/rotate', [AgentTokenController::class, 'rotate']) + ->name('monitoring.hosts.tokens.rotate'); + Route::delete('/monitoring/hosts/{host}/tokens/{token}', [AgentTokenController::class, 'destroy']) + ->name('monitoring.hosts.tokens.destroy'); + ``` + +7. **Frontend (Monitoring/Hosts pages + components).** + - `resources/js/Pages/Monitoring/Hosts/Index.vue` — table with name, project, status badge, last seen, agent token state. + - `resources/js/Pages/Monitoring/Hosts/Create.vue` + `Edit.vue` — forms (mirrors `Monitoring/Websites/{Create,Edit}.vue`). + - `resources/js/Pages/Monitoring/Hosts/Show.vue` — detail page for the host card + token panel. Metric rendering is intentionally deferred to 028; this page exists so the post-create redirect target is real. + - `resources/js/Components/Hosts/HostStatusBadge.vue`. + - `resources/js/Components/Hosts/AgentTokenPanel.vue` — handles "show once" plaintext reveal (driven by `flash('agentTokenPlaintext')`) + copy-to-clipboard + rotate confirmation. + - The project detail page already has a Hosts tab placeholder (verified in `resources/js/Pages/Projects/Show.vue:170`). No change needed here in 026 — the placeholder already shows "Phase 6" pending state. + - Sidebar: `Hosts` link **stays disabled** until 028 wires it to `monitoring.hosts.index`. + +8. **Tests.** + - Feature: Settings hosts CRUD (index, store, update, archive). Auth required, policy enforced. + - Feature: token issue — plaintext returned in response, `hashed_token` matches `hash('sha256', plaintext)`. + - Feature: token rotate — old hash gone, new hash present, `last_used_at` cleared. + - Unit: `IssueAgentTokenAction` does not log plaintext (assert via Pail/log channel mock or simply by not depending on the logger). + - Existing test suites still green (`php artisan test`, `npm run build`, Pint). + +## Acceptance criteria +- [ ] All five migrations apply cleanly on a fresh DB and roll back cleanly. +- [ ] Models + factories exist; `php artisan tinker` can create a Host + AgentToken via factory. +- [ ] Settings → Hosts page lists hosts under the current team's projects. +- [ ] Creating a host then minting a token displays the plaintext **once**, then only ever shows the hash state. +- [ ] Rotating a token replaces the active token; old plaintext no longer validates against any hash in DB. +- [ ] Project detail page shows a `Hosts` tab with an empty state. +- [ ] Sidebar still shows `Hosts` as disabled or marked "coming soon" — routing to a real index lands in 028. +- [ ] Pint clean, tests green, `npm run build` succeeds. + +## Files touched +Fill in as work progresses. + +- `database/migrations/...` — new migrations (5) +- `app/Models/Host.php` — new +- `app/Models/AgentToken.php` — new +- `app/Models/Container.php` — new +- `app/Models/HostMetricSnapshot.php` — new +- `app/Models/ContainerMetricSnapshot.php` — new +- `app/Enums/HostStatus.php` — new +- `app/Enums/HostConnectionType.php` — new +- `app/Domain/Docker/Actions/*.php` — new (5 actions) +- `app/Policies/HostPolicy.php` — new +- `app/Policies/AgentTokenPolicy.php` — new +- `app/Http/Controllers/Monitoring/HostController.php` — new +- `app/Http/Controllers/Monitoring/AgentTokenController.php` — new +- `app/Http/Requests/Monitoring/{StoreHostRequest,UpdateHostRequest}.php` — new +- `routes/web.php` — register new routes +- `resources/js/Pages/Monitoring/Hosts/{Index,Create,Edit,Show}.vue` — new +- `resources/js/Components/Hosts/{HostStatusBadge,AgentTokenPanel}.vue` — new +- `tests/Feature/Monitoring/HostControllerTest.php` — new +- `tests/Feature/Monitoring/HostPolicyTest.php` — new +- `tests/Feature/Monitoring/AgentTokenLifecycleTest.php` — new + +## Work log + +### 2026-05-01 +- Spec drafted. +- Issue [#78](https://github.com/Copxer/nexus/issues/78) opened, branch `spec/026-hosts-and-agent-tokens` cut off `main`. + +## Open questions / blockers +- Are agent tokens scoped per-host (current plan) or per-team? Per-host is more secure and matches §16.5; sticking with per-host unless we change our minds. +- Should `endpoint_url` be required for `connection_type=agent`? Plan: no — agent pushes to us, so the field is informational only and stays nullable. diff --git a/specs/phase-6-docker-hosts/README.md b/specs/phase-6-docker-hosts/README.md new file mode 100644 index 0000000..de9212b --- /dev/null +++ b/specs/phase-6-docker-hosts/README.md @@ -0,0 +1,31 @@ +# Phase 6 — Docker Host Agent MVP + +Source: [roadmap §19 Phase 6](../../nexus_control_center_roadmap.md), §8.7 Docker Hosts. + +## Phase goal +Stand up Docker host + container monitoring end-to-end via a pull-from-agent model. By the end of phase 6, a user can register a host under a project, mint an agent token, run a small reference script on the host that posts telemetry to `/agent/telemetry`, and see the host + its containers with live CPU/memory on the Hosts page. Hosts that go silent past their timeout flip to offline and emit an activity event; a recovery does the same. The Overview Hosts KPI is fed by real data. + +## Tasks + +| # | Task | Status | +|---|------|--------| +| 026 | Hosts + agent tokens scaffolding (CRUD + token rotation) | ⬜ | +| 027 | Telemetry ingestion endpoint + reference agent script | ⬜ | +| 028 | Hosts UI (index + show + project Hosts tab) | ⬜ | +| 029 | Host offline detection + activity events + Overview KPI wiring | ⬜ | + +## Acceptance criteria (phase-level) +- [ ] User can create / edit / archive a host under a project. +- [ ] User can mint an agent token (shown once) and rotate it. +- [ ] Agent endpoint accepts signed telemetry, rejects invalid tokens, and is rate-limited. +- [ ] Host + container snapshots persist with CPU / memory / disk / network fields. +- [ ] Hosts index + detail pages render real data with empty/loading/error states. +- [ ] Host that hasn't reported within its timeout flips to `offline` and emits a `host.offline` activity event; recovery emits `host.recovered`. +- [ ] Overview Hosts KPI reflects real online/offline counts (no mocks). +- [ ] Sidebar `Hosts` entry is enabled and routes to the hosts listing. +- [ ] Pint + tests + build clean. CI green for every spec PR. + +## Scope notes +- Alerts (the dedicated `alerts` table + acknowledge/resolve flow) are Phase 7. Phase 6 surfaces host issues as activity events only. +- Container ↔ deployment correlation, Kubernetes, and cloud-provider integrations are out of scope. +- The reference agent in 027 is a small Node script documenting the contract, not a production-ready Go binary. diff --git a/tests/Feature/Monitoring/AgentTokenLifecycleTest.php b/tests/Feature/Monitoring/AgentTokenLifecycleTest.php new file mode 100644 index 0000000..998ea12 --- /dev/null +++ b/tests/Feature/Monitoring/AgentTokenLifecycleTest.php @@ -0,0 +1,171 @@ +create(['email_verified_at' => now()]); + } + + public function test_issue_returns_plaintext_once_and_persists_only_the_hash(): void + { + $action = app(IssueAgentTokenAction::class); + $host = Host::factory()->create(); + + $result = $action->execute($host, 'agent v0.1'); + + $this->assertSame(40, mb_strlen($result->plaintext)); + $this->assertNotSame($result->plaintext, $result->token->hashed_token); + $this->assertSame( + AgentToken::hash($result->plaintext), + $result->token->fresh()->hashed_token, + ); + + // The DB never sees the plaintext. + $this->assertSame( + 0, + AgentToken::query()->where('hashed_token', $result->plaintext)->count(), + ); + } + + public function test_issue_does_not_log_plaintext(): void + { + $host = Host::factory()->create(); + + $logSpy = Log::spy(); + + app(IssueAgentTokenAction::class)->execute($host); + + // No log call at all is the strongest assertion. If we ever add + // an audit log call here, narrow the assertion to "the plaintext + // string isn't a substring of any logged message." + $logSpy->shouldNotHaveReceived('info'); + $logSpy->shouldNotHaveReceived('debug'); + $logSpy->shouldNotHaveReceived('warning'); + $logSpy->shouldNotHaveReceived('error'); + } + + public function test_rotate_revokes_previous_active_tokens_and_issues_a_fresh_one(): void + { + $rotate = app(RotateAgentTokenAction::class); + $host = Host::factory()->create(); + $existing = AgentToken::factory()->create(['host_id' => $host->id]); + + $result = $rotate->execute($host, 'rotated'); + + $existing->refresh(); + $this->assertNotNull($existing->revoked_at); + + $this->assertNull($result->token->revoked_at); + $this->assertSame('rotated', $result->token->name); + $this->assertNotSame($existing->id, $result->token->id); + } + + public function test_store_endpoint_flashes_plaintext_for_one_request(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $response = $this->actingAs($user) + ->post(route('monitoring.hosts.tokens.store', $host), ['name' => 'agent v0.1']); + + $response->assertRedirect(route('monitoring.hosts.show', $host)); + $response->assertSessionHas('agentTokenPlaintext'); + + $plaintext = session('agentTokenPlaintext'); + $this->assertIsString($plaintext); + $this->assertSame(40, mb_strlen($plaintext)); + + $token = AgentToken::query()->where('host_id', $host->id)->latest('id')->firstOrFail(); + $this->assertSame(AgentToken::hash($plaintext), $token->hashed_token); + } + + public function test_rotate_endpoint_invalidates_old_plaintext(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + // Mint the first token via the controller so we capture the + // real plaintext exactly once. + $this->actingAs($user) + ->post(route('monitoring.hosts.tokens.store', $host)); + $oldPlaintext = session('agentTokenPlaintext'); + $oldToken = AgentToken::query()->where('host_id', $host->id)->latest('id')->firstOrFail(); + + // Now rotate and capture the new plaintext. + $this->actingAs($user) + ->post(route('monitoring.hosts.tokens.rotate', [$host, $oldToken])) + ->assertRedirect(route('monitoring.hosts.show', $host)); + $newPlaintext = session('agentTokenPlaintext'); + + $this->assertNotSame($oldPlaintext, $newPlaintext); + + // The old plaintext's hash now belongs only to a revoked row. + $oldHashRow = AgentToken::query() + ->where('hashed_token', AgentToken::hash($oldPlaintext)) + ->firstOrFail(); + $this->assertNotNull($oldHashRow->revoked_at); + + // The new plaintext's hash maps to an active row. + $newHashRow = AgentToken::query() + ->where('hashed_token', AgentToken::hash($newPlaintext)) + ->firstOrFail(); + $this->assertNull($newHashRow->revoked_at); + } + + public function test_destroy_endpoint_revokes_a_token(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + $token = AgentToken::factory()->create(['host_id' => $host->id]); + + $this->actingAs($user) + ->delete(route('monitoring.hosts.tokens.destroy', [$host, $token])) + ->assertRedirect(route('monitoring.hosts.show', $host)); + + $token->refresh(); + $this->assertNotNull($token->revoked_at); + } + + public function test_token_endpoints_404_on_mismatched_pair(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $hostA = Host::factory()->create(['project_id' => $project->id]); + $hostB = Host::factory()->create(['project_id' => $project->id]); + $tokenForB = AgentToken::factory()->create(['host_id' => $hostB->id]); + + $this->actingAs($user) + ->delete(route('monitoring.hosts.tokens.destroy', [$hostA, $tokenForB])) + ->assertNotFound(); + } + + public function test_token_endpoints_blocked_for_non_owner(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->actingAs($other) + ->post(route('monitoring.hosts.tokens.store', $host)) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Monitoring/HostControllerTest.php b/tests/Feature/Monitoring/HostControllerTest.php new file mode 100644 index 0000000..799139b --- /dev/null +++ b/tests/Feature/Monitoring/HostControllerTest.php @@ -0,0 +1,200 @@ +create(['email_verified_at' => now()]); + } + + public function test_index_lists_hosts_under_users_projects(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + Host::factory()->create([ + 'project_id' => $project->id, + 'name' => 'prod-frankfurt-01', + ]); + + // Sibling user's host must not leak. + $other = $this->verifiedUser(); + $otherProject = Project::factory()->create(['owner_user_id' => $other->id]); + Host::factory()->create(['project_id' => $otherProject->id]); + + $this->actingAs($user) + ->get(route('monitoring.hosts.index')) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Hosts/Index') + ->has('hosts', 1) + ->where('hosts.0.name', 'prod-frankfurt-01') + ); + } + + public function test_create_form_renders_with_owned_projects(): void + { + $user = $this->verifiedUser(); + Project::factory()->create(['owner_user_id' => $user->id]); + + $this->actingAs($user) + ->get(route('monitoring.hosts.create')) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Hosts/Create') + ->has('projects', 1) + ->has('options.connection_types') + ); + } + + public function test_store_creates_a_host_for_project_owner(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + + $response = $this->actingAs($user)->post(route('monitoring.hosts.store'), [ + 'project_id' => $project->id, + 'name' => 'prod-frankfurt-01', + 'provider' => 'DigitalOcean', + 'endpoint_url' => null, + 'connection_type' => 'agent', + ]); + + $host = Host::query()->firstWhere('name', 'prod-frankfurt-01'); + $this->assertNotNull($host); + $this->assertSame(HostStatus::Pending, $host->status); + $this->assertSame('prod-frankfurt-01', $host->slug); + $response->assertRedirect(route('monitoring.hosts.show', $host)); + } + + public function test_store_assigns_a_unique_slug_per_project(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + + Host::factory()->create([ + 'project_id' => $project->id, + 'name' => 'prod-frankfurt-01', + 'slug' => 'prod-frankfurt-01', + ]); + + $this->actingAs($user)->post(route('monitoring.hosts.store'), [ + 'project_id' => $project->id, + 'name' => 'prod-frankfurt-01', + 'connection_type' => 'agent', + ])->assertRedirect(); + + $second = Host::query()->latest('id')->first(); + $this->assertNotNull($second); + $this->assertSame('prod-frankfurt-01-2', $second->slug); + } + + public function test_store_blocked_for_non_owner_of_target_project(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + $this->actingAs($other) + ->post(route('monitoring.hosts.store'), [ + 'project_id' => $project->id, + 'name' => 'sneaky', + 'connection_type' => 'agent', + ]) + ->assertForbidden(); + + $this->assertSame(0, Host::query()->count()); + } + + public function test_store_rejects_invalid_connection_type(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + + $this->actingAs($user) + ->post(route('monitoring.hosts.store'), [ + 'project_id' => $project->id, + 'name' => 'host', + 'connection_type' => 'wireless-pigeon', + ]) + ->assertSessionHasErrors('connection_type'); + } + + public function test_show_returns_host_with_active_token_metadata(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + AgentToken::factory()->create(['host_id' => $host->id, 'name' => 'agent v0.1']); + + $this->actingAs($user) + ->get(route('monitoring.hosts.show', $host)) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Hosts/Show') + ->where('host.name', $host->name) + ->where('host.active_agent_token.name', 'agent v0.1') + ->where('canUpdate', true) + ->where('canDelete', true) + ->where('canManageTokens', true) + ); + } + + public function test_update_changes_the_host_for_owner(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create([ + 'project_id' => $project->id, + 'name' => 'old', + ]); + + $response = $this->actingAs($user)->patch( + route('monitoring.hosts.update', $host), + [ + 'name' => 'new-name', + 'provider' => 'Hetzner', + 'endpoint_url' => null, + ], + ); + + $host->refresh(); + $this->assertSame('new-name', $host->name); + $this->assertSame('Hetzner', $host->provider); + $response->assertRedirect(route('monitoring.hosts.show', $host)); + } + + public function test_destroy_archives_the_host_and_revokes_active_tokens(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + $token = AgentToken::factory()->create(['host_id' => $host->id]); + + $this->actingAs($user) + ->delete(route('monitoring.hosts.destroy', $host)) + ->assertRedirect(route('monitoring.hosts.index')); + + $host->refresh(); + $token->refresh(); + + $this->assertSame(HostStatus::Archived, $host->status); + $this->assertNotNull($host->archived_at); + $this->assertNotNull($token->revoked_at); + } +} diff --git a/tests/Feature/Monitoring/HostPolicyTest.php b/tests/Feature/Monitoring/HostPolicyTest.php new file mode 100644 index 0000000..250f8ac --- /dev/null +++ b/tests/Feature/Monitoring/HostPolicyTest.php @@ -0,0 +1,59 @@ +create(['email_verified_at' => now()]); + } + + public function test_create_requires_a_project_the_user_owns(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + $this->assertTrue($owner->can('create', [Host::class, $project])); + $this->assertFalse($other->can('create', [Host::class, $project])); + $this->assertFalse($owner->can('create', [Host::class, null])); + } + + public function test_update_and_delete_follow_host_project_owner(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->assertTrue($owner->can('update', $host)); + $this->assertTrue($owner->can('delete', $host)); + $this->assertTrue($owner->can('manageTokens', $host)); + + $this->assertFalse($other->can('update', $host)); + $this->assertFalse($other->can('delete', $host)); + $this->assertFalse($other->can('manageTokens', $host)); + } + + public function test_view_open_to_any_verified_user_in_phase_1(): void + { + $user = $this->verifiedUser(); + $owner = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + // Mirrors WebsitePolicy — single-tenant phase-1 keeps view open + // so the team-pivot lands without changing every callsite. + $this->assertTrue($user->can('view', $host)); + $this->assertTrue($user->can('viewAny', Host::class)); + } +} From f4ee30a05f1004e354e6deef070a1a5654f2d078 Mon Sep 17 00:00:00 2001 From: Yoany Vaillant Date: Fri, 1 May 2026 11:43:21 -0700 Subject: [PATCH 2/3] =?UTF-8?q?Spec=20026=20=E2=80=94=20address=20self-rev?= =?UTF-8?q?iew=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArchiveHostAction is now idempotent: re-archive leaves archived_at pinned to the original timestamp instead of overwriting. - Replace ad-hoc mb_substr name truncation with StoreAgentTokenRequest form-request validation (name nullable, max:80) so overlong labels surface a real validation error. - Add rotate-mismatch 404 test (mirrors the existing destroy-mismatch case) and assert no token state mutation on either side. - Add 4 sibling-isolation feature tests covering show/edit/update/ destroy (the policy is unit-tested, but a controller-level regression would slip past those — these lock the contract end-to-end). - Add archive-idempotency feature test asserting archived_at is pinned across a second destroy call. - Update spec Files-touched list to reflect the lib/hostStyles.ts + StoreAgentTokenRequest additions and the dropped HostStatusBadge.vue. Tests: 27 → 30 in this file family, full suite 434 green. Pint clean, build green. --- .../Docker/Actions/ArchiveHostAction.php | 8 ++ .../Monitoring/AgentTokenController.php | 23 ++--- .../Monitoring/StoreAgentTokenRequest.php | 30 +++++++ .../026-hosts-and-agent-tokens.md | 13 ++- .../Monitoring/AgentTokenLifecycleTest.php | 39 ++++++++- .../Feature/Monitoring/HostControllerTest.php | 87 +++++++++++++++++++ 6 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 app/Http/Requests/Monitoring/StoreAgentTokenRequest.php diff --git a/app/Domain/Docker/Actions/ArchiveHostAction.php b/app/Domain/Docker/Actions/ArchiveHostAction.php index e191a81..5d6aaa7 100644 --- a/app/Domain/Docker/Actions/ArchiveHostAction.php +++ b/app/Domain/Docker/Actions/ArchiveHostAction.php @@ -16,6 +16,14 @@ 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, diff --git a/app/Http/Controllers/Monitoring/AgentTokenController.php b/app/Http/Controllers/Monitoring/AgentTokenController.php index 064d176..efc2237 100644 --- a/app/Http/Controllers/Monitoring/AgentTokenController.php +++ b/app/Http/Controllers/Monitoring/AgentTokenController.php @@ -6,10 +6,10 @@ 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; -use Illuminate\Http\Request; /** * Lifecycle endpoints for a host's agent token. Plaintext travels via @@ -19,18 +19,11 @@ */ class AgentTokenController extends Controller { - public function store(Request $request, Host $host, IssueAgentTokenAction $issue): RedirectResponse + public function store(StoreAgentTokenRequest $request, Host $host, IssueAgentTokenAction $issue): RedirectResponse { - $this->authorize('manageTokens', $host); - - $name = $request->input('name'); - if ($name !== null && ! is_string($name)) { - $name = null; - } - $result = $issue->execute( $host, - $name !== null ? mb_substr($name, 0, 80) : null, + $request->validated()['name'] ?? null, $request->user(), ); @@ -40,19 +33,13 @@ public function store(Request $request, Host $host, IssueAgentTokenAction $issue ->with('agentTokenPlaintext', $result->plaintext); } - public function rotate(Request $request, Host $host, AgentToken $token, RotateAgentTokenAction $rotate): RedirectResponse + public function rotate(StoreAgentTokenRequest $request, Host $host, AgentToken $token, RotateAgentTokenAction $rotate): RedirectResponse { - $this->authorize('manageTokens', $host); $this->ensureBelongs($host, $token); - $name = $request->input('name'); - if ($name !== null && ! is_string($name)) { - $name = null; - } - $result = $rotate->execute( $host, - $name !== null ? mb_substr($name, 0, 80) : null, + $request->validated()['name'] ?? null, $request->user(), ); diff --git a/app/Http/Requests/Monitoring/StoreAgentTokenRequest.php b/app/Http/Requests/Monitoring/StoreAgentTokenRequest.php new file mode 100644 index 0000000..712b911 --- /dev/null +++ b/app/Http/Requests/Monitoring/StoreAgentTokenRequest.php @@ -0,0 +1,30 @@ +route('host'); + + return $host !== null + && $this->user()?->can('manageTokens', $host) === true; + } + + public function rules(): array + { + return [ + 'name' => ['nullable', 'string', 'max:80'], + ]; + } +} diff --git a/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md index c0ac379..bfeaddd 100644 --- a/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md +++ b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md @@ -87,7 +87,7 @@ Roadmap refs: §8.7 Docker Hosts, §9.1 Core Tables, §16.5 Agent Security. - `resources/js/Pages/Monitoring/Hosts/Index.vue` — table with name, project, status badge, last seen, agent token state. - `resources/js/Pages/Monitoring/Hosts/Create.vue` + `Edit.vue` — forms (mirrors `Monitoring/Websites/{Create,Edit}.vue`). - `resources/js/Pages/Monitoring/Hosts/Show.vue` — detail page for the host card + token panel. Metric rendering is intentionally deferred to 028; this page exists so the post-create redirect target is real. - - `resources/js/Components/Hosts/HostStatusBadge.vue`. + - `resources/js/lib/hostStyles.ts` — `hostStatusTone()` helper consumed by the existing shared `Components/Dashboard/StatusBadge.vue`. Mirrors `websiteStyles.ts`; avoids spinning up a host-specific badge wrapper for one tone map. - `resources/js/Components/Hosts/AgentTokenPanel.vue` — handles "show once" plaintext reveal (driven by `flash('agentTokenPlaintext')`) + copy-to-clipboard + rotate confirmation. - The project detail page already has a Hosts tab placeholder (verified in `resources/js/Pages/Projects/Show.vue:170`). No change needed here in 026 — the placeholder already shows "Phase 6" pending state. - Sidebar: `Hosts` link **stays disabled** until 028 wires it to `monitoring.hosts.index`. @@ -125,10 +125,14 @@ Fill in as work progresses. - `app/Policies/AgentTokenPolicy.php` — new - `app/Http/Controllers/Monitoring/HostController.php` — new - `app/Http/Controllers/Monitoring/AgentTokenController.php` — new -- `app/Http/Requests/Monitoring/{StoreHostRequest,UpdateHostRequest}.php` — new +- `app/Http/Requests/Monitoring/{StoreHostRequest,UpdateHostRequest,StoreAgentTokenRequest}.php` — new +- `app/Http/Middleware/HandleInertiaRequests.php` — share `flash.agentTokenPlaintext` +- `app/Providers/AppServiceProvider.php` — register HostPolicy + AgentTokenPolicy - `routes/web.php` — register new routes - `resources/js/Pages/Monitoring/Hosts/{Index,Create,Edit,Show}.vue` — new -- `resources/js/Components/Hosts/{HostStatusBadge,AgentTokenPanel}.vue` — new +- `resources/js/Components/Hosts/AgentTokenPanel.vue` — new +- `resources/js/lib/hostStyles.ts` — new (replaces planned HostStatusBadge.vue) +- `resources/js/types/index.d.ts` — extend `flash` with `agentTokenPlaintext` - `tests/Feature/Monitoring/HostControllerTest.php` — new - `tests/Feature/Monitoring/HostPolicyTest.php` — new - `tests/Feature/Monitoring/AgentTokenLifecycleTest.php` — new @@ -138,6 +142,9 @@ Fill in as work progresses. ### 2026-05-01 - Spec drafted. - Issue [#78](https://github.com/Copxer/nexus/issues/78) opened, branch `spec/026-hosts-and-agent-tokens` cut off `main`. +- Implementation landed in one commit: 5 migrations, 5 models, 2 enums, 7 actions (incl. DTO), 2 policies, 2 controllers, 3 form requests, 4 Vue pages + 1 component + 1 style helper, route registrations, Inertia flash plumbing. +- Self-review pass via `superpowers:code-reviewer` surfaced 4 should-fix items: (1) rotate mismatched-pair test missing, (2) sibling-isolation feature tests missing for show/edit/update/destroy, (3) token name length silently truncated instead of validated, (4) `ArchiveHostAction` not idempotent on `archived_at`. All four addressed in a follow-up commit. +- Tests grew 20 → 27 (added rotate-mismatch, store-overlong-name, archive-idempotent, sibling-blocked × 4). Full suite 434 passing, Pint clean, build green. ## Open questions / blockers - Are agent tokens scoped per-host (current plan) or per-team? Per-host is more secure and matches §16.5; sticking with per-host unless we change our minds. diff --git a/tests/Feature/Monitoring/AgentTokenLifecycleTest.php b/tests/Feature/Monitoring/AgentTokenLifecycleTest.php index 998ea12..6f13ca0 100644 --- a/tests/Feature/Monitoring/AgentTokenLifecycleTest.php +++ b/tests/Feature/Monitoring/AgentTokenLifecycleTest.php @@ -144,7 +144,7 @@ public function test_destroy_endpoint_revokes_a_token(): void $this->assertNotNull($token->revoked_at); } - public function test_token_endpoints_404_on_mismatched_pair(): void + public function test_destroy_endpoint_404s_on_mismatched_pair(): void { $user = $this->verifiedUser(); $project = Project::factory()->create(['owner_user_id' => $user->id]); @@ -155,6 +155,43 @@ public function test_token_endpoints_404_on_mismatched_pair(): void $this->actingAs($user) ->delete(route('monitoring.hosts.tokens.destroy', [$hostA, $tokenForB])) ->assertNotFound(); + + // The mismatched token must not have been touched. + $tokenForB->refresh(); + $this->assertNull($tokenForB->revoked_at); + } + + public function test_rotate_endpoint_404s_on_mismatched_pair(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $hostA = Host::factory()->create(['project_id' => $project->id]); + $hostB = Host::factory()->create(['project_id' => $project->id]); + $tokenForB = AgentToken::factory()->create(['host_id' => $hostB->id]); + + $this->actingAs($user) + ->post(route('monitoring.hosts.tokens.rotate', [$hostA, $tokenForB])) + ->assertNotFound(); + + $tokenForB->refresh(); + $this->assertNull($tokenForB->revoked_at); + // No new token should have been minted on either host. + $this->assertSame(1, AgentToken::query()->count()); + } + + public function test_store_endpoint_rejects_overlong_name(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->actingAs($user) + ->post(route('monitoring.hosts.tokens.store', $host), [ + 'name' => str_repeat('a', 81), + ]) + ->assertSessionHasErrors('name'); + + $this->assertSame(0, AgentToken::query()->count()); } public function test_token_endpoints_blocked_for_non_owner(): void diff --git a/tests/Feature/Monitoring/HostControllerTest.php b/tests/Feature/Monitoring/HostControllerTest.php index 799139b..55175b6 100644 --- a/tests/Feature/Monitoring/HostControllerTest.php +++ b/tests/Feature/Monitoring/HostControllerTest.php @@ -197,4 +197,91 @@ public function test_destroy_archives_the_host_and_revokes_active_tokens(): void $this->assertNotNull($host->archived_at); $this->assertNotNull($token->revoked_at); } + + public function test_destroy_is_idempotent_and_does_not_overwrite_archived_at(): void + { + $user = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $user->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->actingAs($user)->delete(route('monitoring.hosts.destroy', $host)); + $host->refresh(); + $firstArchivedAt = $host->archived_at; + $this->assertNotNull($firstArchivedAt); + + $this->travel(5)->minutes(); + + $this->actingAs($user)->delete(route('monitoring.hosts.destroy', $host)); + $host->refresh(); + + // archived_at should be pinned to the first archive, not the + // second call's `now()`. + $this->assertTrue($host->archived_at->equalTo($firstArchivedAt)); + } + + public function test_show_blocked_for_unrelated_user(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + // `view` policy is open in phase-1 (mirrors WebsitePolicy), but + // `canUpdate` / `canDelete` / `canManageTokens` must be false + // for a non-owner — that's what gates the buttons. + $this->actingAs($other) + ->get(route('monitoring.hosts.show', $host)) + ->assertSuccessful() + ->assertInertia( + fn (AssertableInertia $page) => $page + ->component('Monitoring/Hosts/Show') + ->where('canUpdate', false) + ->where('canDelete', false) + ->where('canManageTokens', false) + ); + } + + public function test_edit_blocked_for_unrelated_user(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->actingAs($other) + ->get(route('monitoring.hosts.edit', $host)) + ->assertForbidden(); + } + + public function test_update_blocked_for_unrelated_user(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id, 'name' => 'kept']); + + $this->actingAs($other) + ->patch(route('monitoring.hosts.update', $host), [ + 'name' => 'hijacked', + 'provider' => null, + 'endpoint_url' => null, + ]) + ->assertForbidden(); + + $this->assertSame('kept', $host->fresh()->name); + } + + public function test_destroy_blocked_for_unrelated_user(): void + { + $owner = $this->verifiedUser(); + $other = $this->verifiedUser(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + $host = Host::factory()->create(['project_id' => $project->id]); + + $this->actingAs($other) + ->delete(route('monitoring.hosts.destroy', $host)) + ->assertForbidden(); + + $this->assertNull($host->fresh()->archived_at); + } } From 2e085ee8c7a461ff8ebebc7f360908fbf7506ee9 Mon Sep 17 00:00:00 2001 From: Yoany Vaillant Date: Fri, 1 May 2026 11:43:41 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Spec=20026=20=E2=80=94=20flip=20status=20to?= =?UTF-8?q?=20done=20+=20bump=20trackers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/README.md | 2 +- specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md | 2 +- specs/phase-6-docker-hosts/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/README.md b/specs/README.md index bbab608..04017da 100644 --- a/specs/README.md +++ b/specs/README.md @@ -41,7 +41,7 @@ Status legend: ⬜ not started · 🟡 in progress · 🟢 done · 🔴 blocked | 3 | GitHub Webhooks & Activity Feed | 🟢 | 3/3 specs done (017–019). Phase complete. | | 4 | Deployments & CI/CD | 🟢 | 3/3 specs done (020–022). Phase complete. | | 5 | Website Monitoring | 🟢 | 3/3 specs done (023–025). Phase complete. | -| 6 | Docker Host Agent MVP | 🟡 | 0/4 specs done (026–029). Folder + spec 026 drafted. | +| 6 | Docker Host Agent MVP | 🟡 | 1/4 specs done (026–029). 026 shipped. | | 7 | Alerts Engine | ⬜ | — | | 8 | Analytics & Health Scores | ⬜ | — | | 9 | Polish & Production Readiness | ⬜ | — | diff --git a/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md index bfeaddd..907aa79 100644 --- a/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md +++ b/specs/phase-6-docker-hosts/026-hosts-and-agent-tokens.md @@ -1,7 +1,7 @@ --- spec: hosts-and-agent-tokens phase: 6 -status: in-progress +status: done owner: Yoany created: 2026-05-01 updated: 2026-05-01 diff --git a/specs/phase-6-docker-hosts/README.md b/specs/phase-6-docker-hosts/README.md index de9212b..8b79a2a 100644 --- a/specs/phase-6-docker-hosts/README.md +++ b/specs/phase-6-docker-hosts/README.md @@ -9,7 +9,7 @@ Stand up Docker host + container monitoring end-to-end via a pull-from-agent mod | # | Task | Status | |---|------|--------| -| 026 | Hosts + agent tokens scaffolding (CRUD + token rotation) | ⬜ | +| 026 | Hosts + agent tokens scaffolding (CRUD + token rotation) | 🟢 | | 027 | Telemetry ingestion endpoint + reference agent script | ⬜ | | 028 | Hosts UI (index + show + project Hosts tab) | ⬜ | | 029 | Host offline detection + activity events + Overview KPI wiring | ⬜ |