feat(ComputeEngineClient): Ajoute le nom de l'instance à l'entité Compute.

 feat(Website): Ajoute une relation Website-Serveur.
 feat(form/website): Ajoute le choix du serveur au formulaire de création de site.
 feat(infra/website): Crée un endpoint API pour le déploiement des sites.
 feat(twig): Ajoute des filtres twig pour slugify et récupérer la clé API principale.
 feat(compute): Ajoute une relation OneToMany vers Website.
♻️ refactor(ApiSubscriber): Gère les préfixes d'API privés séparément.
```
This commit is contained in:
Serreau Jovann
2025-11-12 13:28:51 +01:00
parent 18ac4dfb3c
commit 1c5fe82c92
11 changed files with 248 additions and 38 deletions

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251112113223 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE compute ADD name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE website ADD server_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE website ADD CONSTRAINT FK_476F5DE71844E6B7 FOREIGN KEY (server_id) REFERENCES compute (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_476F5DE71844E6B7 ON website (server_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE website DROP CONSTRAINT FK_476F5DE71844E6B7');
$this->addSql('DROP INDEX IDX_476F5DE71844E6B7');
$this->addSql('ALTER TABLE website DROP server_id');
$this->addSql('ALTER TABLE compute DROP name');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Controller\Api\Private\Infra;
use App\Repository\EsyWeb\WebsiteRepository;
use Cocur\Slugify\Slugify;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WebsiteController extends AbstractController
{
#[Route('/api/private/infra/websites/deploy', name: 'api_private_infra_website_deploy', methods: ['GET'])]
public function websiteDeploy(WebsiteRepository $websiteRepository)
{
return new Response($this->renderView('api/private/infra/website/deploy.twig', [
'websites' =>$websiteRepository->findBy(['state'=>'validate'], ['id' => 'ASC'])
]),200,['Content-Type' => 'text/plain']);
}
}

View File

@@ -3,7 +3,10 @@
namespace App\Entity;
use AllowDynamicProperties;
use App\Entity\EsyWeb\Website;
use App\Repository\ComputeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[AllowDynamicProperties] #[ORM\Entity(repositoryClass: ComputeRepository::class)]
@@ -29,6 +32,20 @@ class Compute
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
/**
* @var Collection<int, Website>
*/
#[ORM\OneToMany(targetEntity: Website::class, mappedBy: 'server')]
private Collection $websites;
public function __construct()
{
$this->websites = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -93,4 +110,46 @@ class Compute
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
/**
* @return Collection<int, Website>
*/
public function getWebsites(): Collection
{
return $this->websites;
}
public function addWebsite(Website $website): static
{
if (!$this->websites->contains($website)) {
$this->websites->add($website);
$website->setServer($this);
}
return $this;
}
public function removeWebsite(Website $website): static
{
if ($this->websites->removeElement($website)) {
// set the owning side to null (unless already changed)
if ($website->getServer() === $this) {
$website->setServer(null);
}
}
return $this;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Entity\EsyWeb;
use App\Entity\Compute;
use App\Entity\Customer;
use App\Entity\CustomerDns;
use App\Entity\Revendeur;
@@ -52,6 +53,9 @@ class Website
#[ORM\OneToMany(targetEntity: WebsiteKey::class, mappedBy: 'websitre')]
private Collection $websiteKeys;
#[ORM\ManyToOne(inversedBy: 'websites')]
private ?Compute $server = null;
public function __construct()
{
$this->websiteDns = new ArrayCollection();
@@ -212,4 +216,16 @@ class Website
return $this;
}
public function getServer(): ?Compute
{
return $this->server;
}
public function setServer(?Compute $server): static
{
$this->server = $server;
return $this;
}
}

View File

@@ -15,7 +15,8 @@ use Symfony\Component\HttpKernel\KernelEvents;
class ApiSubscriber
{
// Define the API prefix once for easier maintenance
private const API_PRIVATE_PREFIX = '/api/private';
private const API_PRIVATE_PREFIX_ESYWEB = '/api/private/esyweb';
private const API_PRIVATE_PREFIX = '/api/private/infra';
/**
* Handles exceptions specifically for the private API routes.
@@ -25,23 +26,41 @@ class ApiSubscriber
$request = $event->getRequest();
// 1. Quick exit if the route is not part of the private API
if (!str_starts_with($request->getPathInfo(), self::API_PRIVATE_PREFIX)) {
return;
if (str_starts_with($request->getPathInfo(), self::API_PRIVATE_PREFIX_ESYWEB)) {
$throwable = $event->getThrowable();
// 2. Handle only NotFoundHttpException
if ($throwable instanceof NotFoundHttpException) {
// Set error response
$response = new JsonResponse([
'state' => 'error',
'message' => sprintf('Route `%s` not exist', $request->getPathInfo()),
]);
$event->setResponse($response);
$event->stopPropagation();
}
}
if (str_starts_with($request->getPathInfo(), self::API_PRIVATE_PREFIX)) {
$throwable = $event->getThrowable();
// 2. Handle only NotFoundHttpException
if ($throwable instanceof NotFoundHttpException) {
// Set error response
$response = new JsonResponse([
'state' => 'error',
'message' => sprintf('Route `%s` not exist', $request->getPathInfo()),
]);
$event->setResponse($response);
$event->stopPropagation();
}
}
$throwable = $event->getThrowable();
// 2. Handle only NotFoundHttpException
if ($throwable instanceof NotFoundHttpException) {
// Set error response
$response = new JsonResponse([
'state' => 'error',
'message' => sprintf('Route `%s` not exist', $request->getPathInfo()),
]);
$event->setResponse($response);
$event->stopPropagation();
}
}
/**
@@ -57,31 +76,55 @@ class ApiSubscriber
$request = $event->getRequest();
$pathInfo = $request->getPathInfo();
if (!str_starts_with($pathInfo, self::API_PRIVATE_PREFIX)) {
return;
}
if (str_starts_with($pathInfo, self::API_PRIVATE_PREFIX_ESYWEB)) {
// 2. Define required headers and their respective error messages
$requiredHeaders = [
'EsyWebKey' => 'Missing Header `EsyWebKey`',
'EsyWebDns' => 'Missing Header `EsyWebDns`',
'EsyWebApiKey' => 'Missing Header `EsyWebApiKey`',
];
// 3. Loop through required headers and check for existence (removes deep nesting)
foreach ($requiredHeaders as $headerName => $errorMessage) {
if (!$request->headers->has($headerName)) {
$this->setAccessDeniedResponse($event, $errorMessage);
// 2. Define required headers and their respective error messages
$requiredHeaders = [
'EsyWebKey' => 'Missing Header `EsyWebKey`',
'EsyWebDns' => 'Missing Header `EsyWebDns`',
'EsyWebApiKey' => 'Missing Header `EsyWebApiKey`',
];
// 3. Loop through required headers and check for existence (removes deep nesting)
foreach ($requiredHeaders as $headerName => $errorMessage) {
if (!$request->headers->has($headerName)) {
$this->setAccessDeniedResponse($event, $errorMessage);
return;
}
}
// 4. Validate the 'EsyWebKey' against the environment variable
// Use environment variables via Symfony's container/services, but for a quick fix,
// using $_ENV is kept from the original code (better to inject as a service parameter).
if ($request->headers->get('EsyWebKey') !== ($_ENV['APP_SECRET'] ?? '')) {
$this->setAccessDeniedResponse($event, 'Header `EsyWebKey` Is Invalid');
return;
}
}
if (str_starts_with($pathInfo, self::API_PRIVATE_PREFIX)) {
// 4. Validate the 'EsyWebKey' against the environment variable
// Use environment variables via Symfony's container/services, but for a quick fix,
// using $_ENV is kept from the original code (better to inject as a service parameter).
if ($request->headers->get('EsyWebKey') !== ($_ENV['APP_SECRET'] ?? '')) {
$this->setAccessDeniedResponse($event, 'Header `EsyWebKey` Is Invalid');
return;
// 2. Define required headers and their respective error messages
$requiredHeaders = [
'EsyWebKey' => 'Missing Header `EsyWebKey`',
];
// 3. Loop through required headers and check for existence (removes deep nesting)
foreach ($requiredHeaders as $headerName => $errorMessage) {
if (!$request->headers->has($headerName)) {
$this->setAccessDeniedResponse($event, $errorMessage);
return;
}
}
// 4. Validate the 'EsyWebKey' against the environment variable
// Use environment variables via Symfony's container/services, but for a quick fix,
// using $_ENV is kept from the original code (better to inject as a service parameter).
if ($request->headers->get('EsyWebKey') !== ($_ENV['APP_SECRET'] ?? '')) {
$this->setAccessDeniedResponse($event, 'Header `EsyWebKey` Is Invalid');
return;
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Form\Artemis\EsyWeb;
use App\Entity\Compute;
use App\Entity\Customer;
use App\Entity\EsyWeb\Website;
use App\Entity\Revendeur;
@@ -33,6 +34,13 @@ class WebsiteType extends AbstractType
'label' => 'Nom du site internet',
'required' => true
])
->add('server',EntityType::class,[
'label' => 'Serveur',
'class' => Compute::class,
'choice_label'=> function (Compute $compute) {
return $compute->getName();
}
])
->add('customer',EntityType::class,[
'label' => 'Client',
'attr' =>[

View File

@@ -73,6 +73,7 @@ class ComputeEngineClient
$compute->setInternalIp($network->getNetworkIP());
$compute->setExternalIp($accessConfig->getNatIP());
$compute->setStatus('down');
$compute->setName($instance->getName());
}
$this->entityManager->persist($compute);
@@ -101,7 +102,6 @@ class ComputeEngineClient
$instance = $this->client->get($request);
$compute->setStatus($instance->getStatus());
$compute->name = $instance->getName();
return $compute;
});

View File

@@ -53,7 +53,7 @@ class Client
public function servers()
{
return $this->cache->get('list_server_ovh',function () {
return $this->cache->get('list_server_ovh', callback: function () {
$list = [];
$servers = $this->ovhClient->get('/dedicated/server');
foreach ($servers as $server) {
@@ -68,6 +68,7 @@ class Client
$compute->setInternalIp($detail['ip']);
$compute->setExternalIp($detail['ip']);
$compute->setStatus('down');
$compute->setName($server);
}
$this->em->persist($compute);
$list[] = $compute;
@@ -84,7 +85,6 @@ class Client
$c = explode("|", $compute->getInstanceId());
$detail = $this->ovhClient->get('/dedicated/server/' . $c[1]);
$compute->setStatus($detail['state'] == "ok" ? "RUNNING" : "DOWN");
$compute->name = $c[1];
$compute->type = $c[0];
return $compute;
});

View File

@@ -9,6 +9,9 @@ use App\Entity\CustomerDevis;
use App\Entity\CustomerDns;
use App\Entity\CustomerOrder;
use App\Entity\CustomerSplit;
use App\Entity\EsyWeb\Website;
use App\Entity\EsyWeb\WebsiteKey;
use Cocur\Slugify\Slugify;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -22,9 +25,25 @@ class TwigOrderExtensions extends AbstractExtension
new TwigFilter('countEmail',[$this,'countEmail']),
new TwigFilter('skFormat',[$this,'skFormat']),
new TwigFilter('noCompletedEch',[$this,'noCompletedEch']),
new TwigFilter('slugify',[$this,'slugify']),
new TwigFilter('mainKey',[$this,'mainKey']),
];
}
public function mainKey(Website $website): ?string
{
/** @var WebsiteKey $apiKey */
$apiKey = $website->getWebsiteKeys()->filter(function (WebsiteKey $websiteKey) {
return $websiteKey->getType() == "api_key";
})->first();
return $apiKey->getApiKey();
}
public function slugify(string $title): string
{
$s = new Slugify();
return $s->slugify($title);
}
public function noCompletedEch(CustomerSplit $customerSplit)
{
$nbEch = 0;

View File

@@ -0,0 +1,4 @@
[website_deploy]
{% for website in websites %}
{{ website.mainDns}} ansible_connection=ssh ansible_user=bot ansible_python_interpreter=/usr/bin/python3 path=/var/www/{{ website.title|slugify }} website_id={{ website.id }} api_key={{ website|mainKey }}
{% endfor %}

View File

@@ -20,6 +20,9 @@
<div class="flex-1">
{{ form_row(form.revendeur) }}
</div>
<div class="flex-1">
{{ form_row(form.server) }}
</div>
</div>
<button type="submit" class="w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold px-4 py-2 rounded">Crée le site internet</button>