1
0
Fork 0
get-installer-bootstrap/docs/superpowers/plans/2026-05-05-get-installer-bootstrap.md
2026-05-05 09:17:53 +02:00

1357 lines
35 KiB
Markdown

# Get Installer Bootstrap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Dockerized Symfony application that serves cached `.sh` installer scripts from configured Git repositories through stable public paths.
**Architecture:** Symfony handles routing, admin auth, forms, persistence, and tests. Public script requests resolve against SQLite mappings and read only from a local cache. Git synchronization is a manual admin action that updates cache files atomically and preserves the last good script on failure.
**Tech Stack:** PHP 8.3, Symfony, Doctrine ORM, SQLite by default through `DATABASE_URL`, Nginx, PHP-FPM, Docker Compose, Symfony Process, PHPUnit.
---
## File Structure
- `composer.json`: PHP package definition and Symfony dependencies.
- `.env`, `.env.test`: default runtime configuration.
- `public/index.php`: Symfony front controller.
- `src/Kernel.php`: Symfony kernel.
- `src/Entity/User.php`: admin user entity.
- `src/Entity/ScriptMapping.php`: mapping entity for public path to Git file.
- `src/Repository/UserRepository.php`: user lookup for Symfony Security.
- `src/Repository/ScriptMappingRepository.php`: mapping lookup by public path.
- `src/Service/PathNormalizer.php`: strict public and repository path validation.
- `src/Service/CachePathResolver.php`: cache path calculation.
- `src/Service/CachedScriptResolver.php`: public request to cached file resolution.
- `src/Service/GitSynchronizer.php`: clone, fetch, checkout, copy, and sync status update.
- `src/Security/LoginFormAuthenticator.php`: admin login authenticator.
- `src/Form/LoginFormType.php`: login form.
- `src/Form/ScriptMappingType.php`: admin mapping form.
- `src/Controller/Admin/AuthController.php`: login and logout routes.
- `src/Controller/Admin/DashboardController.php`: mapping list.
- `src/Controller/Admin/ScriptMappingController.php`: CRUD and sync actions.
- `src/Controller/PublicScriptController.php`: public catch-all script serving.
- `src/Command/CreateAdminUserCommand.php`: first admin user creation.
- `templates/admin/*.html.twig`: admin templates.
- `templates/base.html.twig`: shared HTML layout.
- `config/packages/*.yaml`: framework, Doctrine, Twig, security, validator, test configuration.
- `config/routes.yaml`: route import defaults.
- `docker/Dockerfile`: production PHP-FPM image.
- `docker/nginx/default.conf`: Nginx config.
- `docker/php/php.ini`: PHP runtime settings.
- `docker/entrypoint.sh`: install-time migrations and cache directory preparation.
- `compose.yaml`: production-like Compose stack for Coolify.
- `compose.dev.yaml`: development override with source bind mount.
- `.dockerignore`: Docker build exclusions.
- `tests/*`: focused PHPUnit and WebTestCase coverage.
- `README.md`: local and Coolify deployment instructions.
## Task 1: Bootstrap Symfony Skeleton
**Files:**
- Create: `composer.json`
- Create: `.env`
- Create: `.env.test`
- Create: `public/index.php`
- Create: `src/Kernel.php`
- Create: `config/bundles.php`
- Create: `config/packages/framework.yaml`
- Create: `config/packages/test/framework.yaml`
- Create: `config/routes.yaml`
- Create: `phpunit.xml.dist`
- Create: `.gitignore`
- [ ] **Step 1: Create the initial Composer project file**
Use `apply_patch` to create `composer.json`:
```json
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": "^8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "^2.12",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.2",
"symfony/asset": "^7.0",
"symfony/console": "^7.0",
"symfony/dotenv": "^7.0",
"symfony/flex": "^2",
"symfony/form": "^7.0",
"symfony/framework-bundle": "^7.0",
"symfony/password-hasher": "^7.0",
"symfony/process": "^7.0",
"symfony/runtime": "^7.0",
"symfony/security-bundle": "^7.0",
"symfony/twig-bundle": "^7.0",
"symfony/validator": "^7.0",
"symfony/yaml": "^7.0",
"twig/extra-bundle": "^3.10"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"symfony/browser-kit": "^7.0",
"symfony/css-selector": "^7.0",
"symfony/maker-bundle": "^1.60"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"sort-packages": true
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
}
}
```
- [ ] **Step 2: Install dependencies**
Run:
```bash
composer install
```
Expected: `vendor/` and `composer.lock` are created, and Composer exits with status `0`.
- [ ] **Step 3: Add base Symfony files if Flex did not create them**
Create missing files only. `src/Kernel.php`:
```php
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
}
```
`public/index.php`:
```php
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static fn (array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
```
- [ ] **Step 4: Add environment defaults**
`.env`:
```dotenv
APP_ENV=dev
APP_SECRET=change-me-in-production
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data/app.db"
APP_CACHE_DIR="%kernel.project_dir%/var/bootstrap-cache"
```
`.env.test`:
```dotenv
APP_ENV=test
APP_SECRET=test-secret
DATABASE_URL="sqlite:///%kernel.project_dir%/var/test/app.db"
APP_CACHE_DIR="%kernel.project_dir%/var/test/bootstrap-cache"
```
- [ ] **Step 5: Verify the skeleton boots**
Run:
```bash
php bin/console about
```
Expected: Symfony prints project information and exits with status `0`.
- [ ] **Step 6: Commit the bootstrap**
Run:
```bash
git add composer.json composer.lock .env .env.test public src config phpunit.xml.dist .gitignore
git commit -m "build: bootstrap symfony app"
```
## Task 2: Add Docker And Compose
**Files:**
- Create: `docker/Dockerfile`
- Create: `docker/nginx/default.conf`
- Create: `docker/php/php.ini`
- Create: `docker/entrypoint.sh`
- Create: `compose.yaml`
- Create: `compose.dev.yaml`
- Create: `.dockerignore`
- [ ] **Step 1: Add the production PHP-FPM image**
Create `docker/Dockerfile`:
```dockerfile
FROM php:8.3-fpm-alpine AS app
RUN apk add --no-cache bash git icu-dev sqlite-dev unzip \
&& docker-php-ext-install intl opcache pdo pdo_sqlite
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts
COPY . .
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/entrypoint.sh /usr/local/bin/app-entrypoint
RUN chmod +x /usr/local/bin/app-entrypoint \
&& mkdir -p var/data var/bootstrap-cache \
&& composer dump-autoload --classmap-authoritative --no-dev \
&& composer run-script --no-dev post-install-cmd \
&& chown -R www-data:www-data var
ENTRYPOINT ["app-entrypoint"]
CMD ["php-fpm"]
FROM nginx:1.27-alpine AS web
WORKDIR /app
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY public /app/public
```
- [ ] **Step 2: Add Nginx configuration**
Create `docker/nginx/default.conf`:
```nginx
server {
listen 80;
server_name _;
root /app/public;
index index.php;
client_max_body_size 2m;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass app:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location ~ \.php$ {
return 404;
}
}
```
- [ ] **Step 3: Add PHP runtime config and entrypoint**
Create `docker/php/php.ini`:
```ini
date.timezone=UTC
memory_limit=256M
upload_max_filesize=2M
post_max_size=2M
opcache.enable=1
opcache.enable_cli=0
opcache.validate_timestamps=0
```
Create `docker/entrypoint.sh`:
```sh
#!/bin/sh
set -eu
mkdir -p /app/var/data /app/var/bootstrap-cache
chown -R www-data:www-data /app/var
if [ "${APP_ENV:-prod}" != "test" ]; then
php /app/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
fi
exec "$@"
```
- [ ] **Step 4: Add Compose files**
Create `compose.yaml`:
```yaml
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
target: app
environment:
APP_ENV: prod
APP_DEBUG: "0"
APP_SECRET: ${APP_SECRET:-change-me}
DATABASE_URL: sqlite:////app/var/data/app.db
APP_CACHE_DIR: /app/var/bootstrap-cache
volumes:
- app-data:/app/var/data
- app-cache:/app/var/bootstrap-cache
nginx:
build:
context: .
dockerfile: docker/Dockerfile
target: app
target: web
depends_on:
- app
ports:
- "${HTTP_PORT:-8080}:80"
volumes:
app-data:
app-cache:
```
Create `compose.dev.yaml`:
```yaml
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
environment:
APP_ENV: dev
APP_DEBUG: "1"
APP_SECRET: dev-secret
DATABASE_URL: sqlite:////app/var/data/app.db
APP_CACHE_DIR: /app/var/bootstrap-cache
volumes:
- .:/app
- app-data:/app/var/data
- app-cache:/app/var/bootstrap-cache
nginx:
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./public:/app/public:ro
```
- [ ] **Step 5: Add Docker ignore**
Create `.dockerignore`:
```gitignore
.git
docs/superpowers/plans
var
vendor
.env.local
compose.dev.yaml
```
- [ ] **Step 6: Validate config syntax**
Run:
```bash
docker compose -f compose.yaml config
```
Expected: Compose prints resolved YAML and exits with status `0`.
- [ ] **Step 7: Commit Docker support**
Run:
```bash
git add docker compose.yaml compose.dev.yaml .dockerignore
git commit -m "build: add docker deployment files"
```
## Task 3: Add Persistence Model
**Files:**
- Create: `src/Entity/User.php`
- Create: `src/Entity/ScriptMapping.php`
- Create: `src/Repository/UserRepository.php`
- Create: `src/Repository/ScriptMappingRepository.php`
- Modify: `config/packages/doctrine.yaml`
- Create: `migrations/Version*.php`
- Create: `tests/Entity/ScriptMappingTest.php`
- [ ] **Step 1: Write entity behavior tests**
Create `tests/Entity/ScriptMappingTest.php`:
```php
<?php
namespace App\Tests\Entity;
use App\Entity\ScriptMapping;
use PHPUnit\Framework\TestCase;
final class ScriptMappingTest extends TestCase
{
public function testDefaultsAreUsableForNewMapping(): void
{
$mapping = new ScriptMapping();
self::assertSame('main', $mapping->getGitRef());
self::assertTrue($mapping->isActive());
self::assertSame('never_synced', $mapping->getLastSyncStatus());
self::assertNull($mapping->getLastSuccessfulSyncAt());
self::assertNull($mapping->getLastSyncError());
}
public function testSyncFailureDoesNotClearPreviousCacheKey(): void
{
$mapping = new ScriptMapping();
$mapping->setCacheKey('scripts/1.sh');
$mapping->markSyncFailed('git fetch failed');
self::assertSame('scripts/1.sh', $mapping->getCacheKey());
self::assertSame('failed', $mapping->getLastSyncStatus());
self::assertSame('git fetch failed', $mapping->getLastSyncError());
}
}
```
- [ ] **Step 2: Run the tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Entity/ScriptMappingTest.php
```
Expected: FAIL because `App\Entity\ScriptMapping` does not exist.
- [ ] **Step 3: Implement the user entity**
Create `src/Entity/User.php` with an auto-increment integer id, unique
`username`, `passwordHash`, and JSON `roles`. Implement Symfony's
`UserInterface` and `PasswordAuthenticatedUserInterface`:
```php
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'admin_user')]
#[ORM\UniqueConstraint(name: 'uniq_admin_user_username', columns: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180)]
private string $username = '';
#[ORM\Column]
private string $passwordHash = '';
/** @var list<string> */
#[ORM\Column(type: 'json')]
private array $roles = ['ROLE_ADMIN'];
public function getId(): ?int
{
return $this->id;
}
public function getUserIdentifier(): string
{
return $this->username;
}
public function getUsername(): string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = trim($username);
return $this;
}
/** @return list<string> */
public function getRoles(): array
{
return array_values(array_unique([...$this->roles, 'ROLE_ADMIN']));
}
/** @param list<string> $roles */
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->passwordHash;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function setPasswordHash(string $passwordHash): self
{
$this->passwordHash = $passwordHash;
return $this;
}
public function eraseCredentials(): void
{
}
}
```
- [ ] **Step 4: Implement the mapping entity**
Create `src/Entity/ScriptMapping.php` with fields listed in the spec and methods
used by tests. Use Doctrine attributes, `#[ORM\HasLifecycleCallbacks]`, unique
public path, nullable token, nullable cache key, and datetime immutable fields.
- [ ] **Step 5: Add repositories**
Create `src/Repository/UserRepository.php`:
```php
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
final class UserRepository extends ServiceEntityRepository implements UserLoaderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function loadUserByIdentifier(string $identifier): ?User
{
return $this->findOneBy(['username' => $identifier]);
}
}
```
Create `src/Repository/ScriptMappingRepository.php` with
`findActiveByPublicPath(string $publicPath): ?ScriptMapping` that filters by
`publicPath` and `active = true`.
- [ ] **Step 6: Generate and inspect migration**
Run:
```bash
php bin/console make:migration
```
Expected: a migration creates `admin_user` and `script_mapping` tables, including
a unique index on `script_mapping.public_path`.
- [ ] **Step 7: Run entity tests**
Run:
```bash
php vendor/bin/phpunit tests/Entity/ScriptMappingTest.php
```
Expected: PASS.
- [ ] **Step 8: Commit persistence**
Run:
```bash
git add src/Entity src/Repository migrations tests/Entity config/packages/doctrine.yaml
git commit -m "feat: add installer mapping persistence"
```
## Task 4: Add Strict Path Normalization
**Files:**
- Create: `src/Service/PathNormalizer.php`
- Create: `tests/Service/PathNormalizerTest.php`
- [ ] **Step 1: Write path tests**
Create `tests/Service/PathNormalizerTest.php`:
```php
<?php
namespace App\Tests\Service;
use App\Service\PathNormalizer;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class PathNormalizerTest extends TestCase
{
private PathNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new PathNormalizer();
}
public function testPublicPathIsNormalized(): void
{
self::assertSame('mcp/graylog/install.sh', $this->normalizer->publicPath('/mcp/graylog/install.sh'));
}
public function testPublicPathMustEndWithShellExtension(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->publicPath('mcp/graylog/install.txt');
}
public function testPublicPathRejectsTraversal(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->publicPath('mcp/../install.sh');
}
public function testRepositoryPathAllowsNestedShellFileWithoutShellRequirement(): void
{
self::assertSame('scripts/install', $this->normalizer->repositoryPath('/scripts/install'));
}
public function testRepositoryPathRejectsTraversal(): void
{
$this->expectException(InvalidArgumentException::class);
$this->normalizer->repositoryPath('../install.sh');
}
}
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Service/PathNormalizerTest.php
```
Expected: FAIL because `PathNormalizer` does not exist.
- [ ] **Step 3: Implement the normalizer**
Create `src/Service/PathNormalizer.php`:
```php
<?php
namespace App\Service;
use InvalidArgumentException;
final class PathNormalizer
{
public function publicPath(string $path): string
{
$normalized = $this->relativePath($path);
if (!str_ends_with($normalized, '.sh')) {
throw new InvalidArgumentException('Public path must end with .sh.');
}
return $normalized;
}
public function repositoryPath(string $path): string
{
return $this->relativePath($path);
}
private function relativePath(string $path): string
{
$path = trim(str_replace('\\', '/', $path));
$path = ltrim($path, '/');
if ($path === '') {
throw new InvalidArgumentException('Path cannot be empty.');
}
$segments = explode('/', $path);
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
throw new InvalidArgumentException('Path contains an invalid segment.');
}
}
return implode('/', $segments);
}
}
```
- [ ] **Step 4: Run tests**
Run:
```bash
php vendor/bin/phpunit tests/Service/PathNormalizerTest.php
```
Expected: PASS.
- [ ] **Step 5: Commit path validation**
Run:
```bash
git add src/Service/PathNormalizer.php tests/Service/PathNormalizerTest.php
git commit -m "feat: validate script mapping paths"
```
## Task 5: Serve Cached Public Scripts
**Files:**
- Create: `src/Service/CachePathResolver.php`
- Create: `src/Service/CachedScriptResolver.php`
- Create: `src/Controller/PublicScriptController.php`
- Modify: `config/services.yaml`
- Create: `tests/Controller/PublicScriptControllerTest.php`
- [ ] **Step 1: Write public endpoint tests**
Create `tests/Controller/PublicScriptControllerTest.php` with WebTestCase tests:
```php
<?php
namespace App\Tests\Controller;
use App\Entity\ScriptMapping;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PublicScriptControllerTest extends WebTestCase
{
public function testUnknownPathReturnsNotFound(): void
{
$client = self::createClient();
$client->request('GET', '/missing/install.sh');
self::assertResponseStatusCodeSame(404);
}
public function testCachedScriptIsServed(): void
{
$client = self::createClient();
$container = static::getContainer();
$cacheDir = $container->getParameter('app.cache_dir');
@mkdir($cacheDir.'/scripts', 0775, true);
file_put_contents($cacheDir.'/scripts/test.sh', "#!/bin/sh\necho ok\n");
$mapping = (new ScriptMapping())
->setPublicPath('mcp/graylog/install.sh')
->setRepositoryUrl('https://forge.lclr.dev/AI/graylog-mcp.git')
->setGitRef('main')
->setRepositoryFilePath('install.sh')
->setCacheKey('scripts/test.sh');
$mapping->markSyncSucceeded('scripts/test.sh');
$em = $container->get(EntityManagerInterface::class);
$em->persist($mapping);
$em->flush();
$client->request('GET', '/mcp/graylog/install.sh');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'text/x-shellscript; charset=UTF-8');
self::assertStringContainsString('echo ok', $client->getResponse()->getContent());
}
}
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Controller/PublicScriptControllerTest.php
```
Expected: FAIL because the controller and services do not exist.
- [ ] **Step 3: Configure cache directory parameter**
Modify `config/services.yaml`:
```yaml
parameters:
app.cache_dir: '%env(resolve:APP_CACHE_DIR)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
```
- [ ] **Step 4: Implement cache resolver services**
Create `src/Service/CachePathResolver.php` with methods
`servedScriptPath(string $cacheKey): string` and
`scriptCacheKeyForMapping(int $mappingId): string`. Store served scripts under
`scripts/{id}.sh`.
Create `src/Service/CachedScriptResolver.php` that normalizes the request path,
loads an active mapping through `ScriptMappingRepository`, returns `null` when
no cache key exists, and returns a `SplFileInfo` only when the resolved file is
readable under `app.cache_dir`.
- [ ] **Step 5: Implement public controller**
Create `src/Controller/PublicScriptController.php`:
```php
<?php
namespace App\Controller;
use App\Service\CachedScriptResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
final class PublicScriptController extends AbstractController
{
public function __construct(private readonly CachedScriptResolver $resolver)
{
}
#[Route('/{path}', name: 'public_script', requirements: ['path' => '(?!admin(?:/|$)).+'], priority: -255)]
public function __invoke(string $path): Response
{
$file = $this->resolver->resolve($path);
if ($file === null) {
throw $this->createNotFoundException();
}
$response = new BinaryFileResponse($file->getPathname());
$response->headers->set('Content-Type', 'text/x-shellscript; charset=UTF-8');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, basename($path));
return $response;
}
}
```
- [ ] **Step 6: Run public endpoint tests**
Run:
```bash
php vendor/bin/phpunit tests/Controller/PublicScriptControllerTest.php
```
Expected: PASS.
- [ ] **Step 7: Commit public serving**
Run:
```bash
git add src/Service/CachePathResolver.php src/Service/CachedScriptResolver.php src/Controller/PublicScriptController.php config/services.yaml tests/Controller/PublicScriptControllerTest.php
git commit -m "feat: serve cached installer scripts"
```
## Task 6: Add Admin Authentication
**Files:**
- Create: `src/Security/LoginFormAuthenticator.php`
- Create: `src/Form/LoginFormType.php`
- Create: `src/Controller/Admin/AuthController.php`
- Create: `src/Command/CreateAdminUserCommand.php`
- Modify: `config/packages/security.yaml`
- Create: `templates/base.html.twig`
- Create: `templates/admin/login.html.twig`
- Create: `tests/Controller/Admin/AuthControllerTest.php`
- [ ] **Step 1: Write admin auth tests**
Create `tests/Controller/Admin/AuthControllerTest.php`:
```php
<?php
namespace App\Tests\Controller\Admin;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class AuthControllerTest extends WebTestCase
{
public function testAdminRedirectsToLogin(): void
{
$client = self::createClient();
$client->request('GET', '/admin');
self::assertResponseRedirects('/admin/login');
}
public function testAdminCanLogin(): void
{
$client = self::createClient();
$container = static::getContainer();
$user = (new User())->setUsername('admin');
$hash = $container->get(UserPasswordHasherInterface::class)->hashPassword($user, 'secret-password');
$user->setPasswordHash($hash);
$em = $container->get(EntityManagerInterface::class);
$em->persist($user);
$em->flush();
$crawler = $client->request('GET', '/admin/login');
$form = $crawler->selectButton('Sign in')->form([
'login_form[username]' => 'admin',
'login_form[password]' => 'secret-password',
]);
$client->submit($form);
self::assertResponseRedirects('/admin');
}
}
```
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Controller/Admin/AuthControllerTest.php
```
Expected: FAIL because admin routes and security are not configured.
- [ ] **Step 3: Configure security**
Create or modify `config/packages/security.yaml` with password hashers,
`UserRepository` provider, form-login authenticator, logout route, and access
control requiring `ROLE_ADMIN` for `^/admin` except `^/admin/login`.
- [ ] **Step 4: Implement login form and authenticator**
Create `src/Form/LoginFormType.php` with `username`, `password`, and CSRF.
Create `src/Security/LoginFormAuthenticator.php` extending
`AbstractLoginFormAuthenticator`. Authenticate against `UserRepository`, verify
password credentials, redirect to `/admin` on success, and use `/admin/login`
as login URL.
- [ ] **Step 5: Implement auth controller and templates**
Create `src/Controller/Admin/AuthController.php` with:
- `GET|POST /admin/login` route named `admin_login`.
- `/admin/logout` route named `admin_logout`.
Create `templates/base.html.twig` and `templates/admin/login.html.twig` with a
simple login form. The submit button text must be `Sign in` to match tests.
- [ ] **Step 6: Add admin user command**
Create `src/Command/CreateAdminUserCommand.php` named `app:admin:create` with
arguments `username` and `password`. It hashes the password, creates the user
when absent, updates the password when present, and prints
`Admin user "<username>" is ready.`.
- [ ] **Step 7: Run admin auth tests**
Run:
```bash
php vendor/bin/phpunit tests/Controller/Admin/AuthControllerTest.php
```
Expected: PASS.
- [ ] **Step 8: Commit admin auth**
Run:
```bash
git add src/Security src/Form/LoginFormType.php src/Controller/Admin/AuthController.php src/Command/CreateAdminUserCommand.php config/packages/security.yaml templates tests/Controller/Admin/AuthControllerTest.php
git commit -m "feat: add admin authentication"
```
## Task 7: Add Admin Mapping CRUD
**Files:**
- Create: `src/Form/ScriptMappingType.php`
- Create: `src/Controller/Admin/DashboardController.php`
- Create: `src/Controller/Admin/ScriptMappingController.php`
- Create: `templates/admin/dashboard.html.twig`
- Create: `templates/admin/mapping_form.html.twig`
- Create: `tests/Controller/Admin/ScriptMappingControllerTest.php`
- [ ] **Step 1: Write CRUD tests**
Create `tests/Controller/Admin/ScriptMappingControllerTest.php` covering:
- unauthenticated `GET /admin/mappings/new` redirects to login;
- authenticated `POST /admin/mappings/new` creates a mapping with normalized
`publicPath = mcp/graylog/install.sh`;
- invalid public path `mcp/graylog/install.txt` renders a validation error.
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
```
Expected: FAIL because mapping admin routes do not exist.
- [ ] **Step 3: Implement mapping form**
Create `src/Form/ScriptMappingType.php` with fields:
- `publicPath`
- `repositoryUrl`
- `gitRef`
- `repositoryFilePath`
- `accessToken`, not required, `empty_data` set to an empty string
- `active`
Use a POST_SUBMIT listener with `PathNormalizer` to normalize public path and
repository file path. Preserve the existing token when the submitted token field
is empty during edit.
- [ ] **Step 4: Implement dashboard and CRUD controllers**
Create `/admin` dashboard route listing mappings ordered by public path.
Create routes:
- `GET|POST /admin/mappings/new`
- `GET|POST /admin/mappings/{id}/edit`
- `POST /admin/mappings/{id}/delete`
Use Doctrine transactions for create, edit, and delete. Add flash messages:
- `Mapping created.`
- `Mapping updated.`
- `Mapping deleted.`
- [ ] **Step 5: Add templates**
Create Twig templates with a compact table showing public path, repository URL,
Git ref, repository file path, active status, last sync status, last success,
and last error. Do not render access tokens in table or form initial values.
- [ ] **Step 6: Run CRUD tests**
Run:
```bash
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
```
Expected: PASS.
- [ ] **Step 7: Commit mapping admin**
Run:
```bash
git add src/Form/ScriptMappingType.php src/Controller/Admin/DashboardController.php src/Controller/Admin/ScriptMappingController.php templates/admin tests/Controller/Admin/ScriptMappingControllerTest.php
git commit -m "feat: manage script mappings from admin"
```
## Task 8: Add Git Synchronization
**Files:**
- Create: `src/Service/GitSynchronizer.php`
- Modify: `src/Controller/Admin/ScriptMappingController.php`
- Modify: `templates/admin/dashboard.html.twig`
- Create: `tests/Service/GitSynchronizerTest.php`
- [ ] **Step 1: Write synchronizer tests**
Create `tests/Service/GitSynchronizerTest.php` with tests using a local fixture
Git repository created under `sys_get_temp_dir()`:
- successful sync copies `install.sh` into `APP_CACHE_DIR/scripts/{id}.sh`;
- failed sync for a missing repository file keeps the previous cache key and
marks status `failed`;
- process error text stored on mapping does not contain the mapping access token.
- [ ] **Step 2: Run tests and verify failure**
Run:
```bash
php vendor/bin/phpunit tests/Service/GitSynchronizerTest.php
```
Expected: FAIL because `GitSynchronizer` does not exist.
- [ ] **Step 3: Implement GitSynchronizer**
Create `src/Service/GitSynchronizer.php` using Symfony Process.
Implementation rules:
- Working directory: `{APP_CACHE_DIR}/repos/{mappingId}`.
- Served cache key: `scripts/{mappingId}.sh`.
- Clone command: `git clone --no-checkout <repositoryUrl> <workDir>`.
- Update command: `git fetch --all --prune`.
- Checkout command: `git checkout --force <gitRef>`.
- Use `GIT_TERMINAL_PROMPT=0`.
- For token-authenticated HTTPS repositories, create a temporary askpass script
outside the repository workdir, set `GIT_ASKPASS` to that script, and pass the
token through an environment variable consumed by the script.
- Delete the temporary askpass script after every Git operation attempt.
- Copy the repository file to `{APP_CACHE_DIR}/scripts/{mappingId}.sh.tmp`, then
rename it to `{APP_CACHE_DIR}/scripts/{mappingId}.sh`.
- On success, call `markSyncSucceeded($cacheKey)`.
- On failure, call `markSyncFailed($sanitizedError)`.
- Sanitize errors by replacing the access token with `[redacted]`.
- [ ] **Step 4: Add admin sync action**
Modify `ScriptMappingController` to add:
- `POST /admin/mappings/{id}/sync`
- CSRF token id `sync_mapping_{id}`
- success flash `Mapping synchronized.`
- failure flash `Synchronization failed.`
Flush mapping status after sync. The action redirects to `/admin`.
- [ ] **Step 5: Add sync buttons**
Modify `templates/admin/dashboard.html.twig` so every mapping row has a POST
form with a sync button and CSRF token. The form action targets the sync route.
- [ ] **Step 6: Run synchronizer tests**
Run:
```bash
php vendor/bin/phpunit tests/Service/GitSynchronizerTest.php
```
Expected: PASS.
- [ ] **Step 7: Run admin CRUD tests**
Run:
```bash
php vendor/bin/phpunit tests/Controller/Admin/ScriptMappingControllerTest.php
```
Expected: PASS.
- [ ] **Step 8: Commit Git synchronization**
Run:
```bash
git add src/Service/GitSynchronizer.php src/Controller/Admin/ScriptMappingController.php templates/admin/dashboard.html.twig tests/Service/GitSynchronizerTest.php
git commit -m "feat: synchronize scripts from git"
```
## Task 9: Add Database Test Isolation
**Files:**
- Create: `tests/DatabaseTestCase.php`
- Modify: controller tests that use Doctrine.
- Modify: `phpunit.xml.dist`
- [ ] **Step 1: Add a reusable database test base**
Create `tests/DatabaseTestCase.php` with helpers to boot the kernel, drop and
create schema from Doctrine metadata, and expose `EntityManagerInterface`.
- [ ] **Step 2: Update Doctrine tests**
Modify WebTestCase classes that write to the database to reset schema in
`setUp()` and close the entity manager in `tearDown()`.
- [ ] **Step 3: Run the full test suite**
Run:
```bash
php vendor/bin/phpunit
```
Expected: PASS.
- [ ] **Step 4: Commit test isolation**
Run:
```bash
git add tests phpunit.xml.dist
git commit -m "test: isolate database-backed tests"
```
## Task 10: Add Documentation And Final Verification
**Files:**
- Create: `README.md`
- Modify: any Docker or config file found broken during verification.
- [ ] **Step 1: Write README**
Create `README.md` with:
- project purpose;
- local dev startup:
`docker compose -f compose.yaml -f compose.dev.yaml up --build`;
- admin creation:
`docker compose -f compose.yaml -f compose.dev.yaml exec app php bin/console app:admin:create admin '<password>'`;
- migration command;
- mapping example for `mcp/graylog/install.sh`;
- Coolify deployment notes for `APP_SECRET`, volumes, and port;
- private Git token behavior;
- public usage example:
`curl https://example.test/mcp/graylog/install.sh | bash`.
- [ ] **Step 2: Run PHP tests**
Run:
```bash
php vendor/bin/phpunit
```
Expected: PASS.
- [ ] **Step 3: Validate Symfony container**
Run:
```bash
php bin/console lint:container
```
Expected: PASS.
- [ ] **Step 4: Validate Docker Compose config**
Run:
```bash
docker compose -f compose.yaml -f compose.dev.yaml config
```
Expected: PASS.
- [ ] **Step 5: Build and boot the local stack**
Run:
```bash
docker compose -f compose.yaml -f compose.dev.yaml up --build
```
Expected: Nginx listens on `http://localhost:8080`, `/admin/login` renders the
login page, and the app container does not restart.
- [ ] **Step 6: Commit docs and final fixes**
Run:
```bash
git add README.md docker compose.yaml compose.dev.yaml config src templates tests
git commit -m "docs: document installer bootstrap deployment"
```
## Self-Review
- Spec coverage: the plan covers public script serving, `/admin` login,
SQLite persistence, mapping CRUD, optional private Git token support, manual
sync, Docker production deployment, dev bind mounts, and focused tests.
- Placeholder scan: there are no open placeholders in the plan. Where commands
create generated files, each step states the expected observable output.
- Type consistency: mapping terminology is consistent across entity,
repository, services, controllers, templates, and tests. The cache key is a
relative path such as `scripts/{id}.sh`, resolved only through
`CachePathResolver`.