1358 lines
35 KiB
Markdown
1358 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`.
|