From ee39f4c78976e3a517089eb0b7e53b68f12fda30 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Thu, 18 Apr 2024 07:23:00 +0000
Subject: [PATCH] fixes #6, fixes #8

---
 config/nginx-php.conf                         |  11 ++
 lib/Commands/Compile.php                      | 139 ++++++++++++------
 lib/Commands/Docker/Build.php                 |  32 ++++
 lib/Commands/Keys/Create.php                  |  47 ++++++
 lib/Config.php                                |   2 +-
 lib/Creators/NginxConfiguration.php           |  18 +--
 lib/DockerComposeCommand.php                  |  36 ++++-
 .../GetCompiledDockerComposeYMLPath.php       |  17 ---
 lib/Traits/GetCompiledPath.php                |  10 ++
 studip-docker                                 |   6 +-
 web/index.php                                 |   2 +-
 11 files changed, 235 insertions(+), 85 deletions(-)
 create mode 100644 config/nginx-php.conf
 create mode 100644 lib/Commands/Keys/Create.php
 delete mode 100644 lib/Traits/GetCompiledDockerComposeYMLPath.php
 create mode 100644 lib/Traits/GetCompiledPath.php

diff --git a/config/nginx-php.conf b/config/nginx-php.conf
new file mode 100644
index 0000000..f1329d4
--- /dev/null
+++ b/config/nginx-php.conf
@@ -0,0 +1,11 @@
+location ~ \.php(?:$|/) {
+    include fastcgi_params;
+    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
+    fastcgi_intercept_errors on;
+    fastcgi_pass $fastcgi_backend;
+    fastcgi_index index.php;
+    fastcgi_param SERVER_NAME $http_host;
+    fastcgi_param DOCUMENT_ROOT $site_document_root;
+    fastcgi_param PATH_INFO $fastcgi_path_info;
+    fastcgi_param SCRIPT_FILENAME $request_filename;
+}
diff --git a/lib/Commands/Compile.php b/lib/Commands/Compile.php
index 3ead649..f05ad95 100644
--- a/lib/Commands/Compile.php
+++ b/lib/Commands/Compile.php
@@ -4,14 +4,18 @@ namespace Studip\Dockerized\Commands;
 use Studip\Dockerized\Config;
 use Studip\Dockerized\Creators\DockerComposeConfiguration;
 use Studip\Dockerized\Creators\NginxConfiguration;
+use Studip\Dockerized\Traits\GetCompiledPath;
 use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
 
-final class Compile extends \Symfony\Component\Console\Command\Command
+final class Compile extends Command
 {
+    use GetCompiledPath;
+
     protected function configure()
     {
         $this->setName('compile');
@@ -21,10 +25,14 @@ final class Compile extends \Symfony\Component\Console\Command\Command
             p',
             InputOption::VALUE_OPTIONAL,
             'Path to store the compiled files',
-            realpath(__DIR__ . '/../../compiled')
+            $this->getCompiledPath()
         );
         $this->addOption('nginx', null, InputOption::VALUE_NEGATABLE, 'Compile nginx configuration', true);
         $this->addOption('docker', null, InputOption::VALUE_NEGATABLE, 'Compile docker configuration', true);
+
+        $this->addOption('build', 'b', InputOption::VALUE_NEGATABLE, 'Whether to build the containers');
+        $this->addOption('force', 'F', InputOption::VALUE_NONE, 'Force compilation');
+        $this->addOption('start', 's', InputOption::VALUE_NONE, 'Start container after compilation (requires "build")');
     }
 
     protected function initialize(InputInterface $input, OutputInterface $output)
@@ -48,25 +56,41 @@ final class Compile extends \Symfony\Component\Console\Command\Command
     protected function execute(InputInterface $input, OutputInterface $output)
     {
         $compiledPath = realpath($input->getOption('path'));
+        $force = $input->getOption('force');
         $io = new SymfonyStyle($input, $output);
 
-        $writeConfigFile = function (string $filename, string $content) use ($compiledPath, $io): void {
-            file_put_contents(
-                "{$compiledPath}/{$filename}",
-                $content
-            );
-            $io->success('Config file ' . $filename . ' written');
+        $writeConfigFile = function (string $filename, string $content) use ($compiledPath, $force, $io): bool {
+            $filepath = "{$compiledPath}/{$filename}";
+
+            if (
+                !file_exists($filepath)
+                || $content !== file_get_contents($filepath)
+                || $force
+            ) {
+                file_put_contents($filepath, $content);
+                $io->success('Config file ' . $filename . ' written');
+
+                return true;
+            }
+
+            return false;
         };
 
+        $changed = 0;
         if ($input->getOption('nginx')) {
-            $writeConfigFile(
+            $changed += (int) $writeConfigFile(
                 'nginx.conf',
                 $this->createNginxConfiguration()
             );
+
+            $changed += (int) $writeConfigFile(
+                'nginx-sites.conf',
+                $this->createNginxSites()
+            );
         }
 
         if ($input->getOption('docker')) {
-            $writeConfigFile(
+            $changed += (int) $writeConfigFile(
                 'docker-compose.yml',
                 $this->createDockerComposerConfiguration(
                     realpath(__DIR__ . '/../..'),
@@ -75,6 +99,23 @@ final class Compile extends \Symfony\Component\Console\Command\Command
             );
         }
 
+        if ($changed === 0) {
+            $io->comment('No changes');
+        }
+        if ($input->getOption('build')) {
+            $this->getApplication()->doRun(
+                new ArrayInput(['command' => 'docker:build']),
+                $output
+            );
+
+            if ($input->getOption('start')) {
+                $this->getApplication()->doRun(
+                    new ArrayInput(['command' => 'docker:start']),
+                    $output
+                );
+            }
+        }
+
         return Command::SUCCESS;
     }
 
@@ -89,8 +130,8 @@ final class Compile extends \Symfony\Component\Console\Command\Command
 
         // Declare volumes
         $volumes = [
-            [realpath(__DIR__ . '/../../web'), '/var/www/html/studip-dockerized-config.app', 'ro'],
-            [realpath(__DIR__ . '/../../config.json'), '/var/www/html/studip-dockerized-config.json', 'ro'],
+            [realpath($cwd . '/web'), '/var/www/html/studip-dockerized-config.app', 'ro'],
+            [realpath($cwd . '/config.json'), '/var/www/html/studip-dockerized-config.json', 'ro'],
             ...array_map(
                 fn(string $path, array $definition) => [$definition['source'], '/var/www/html/' . $path],
                 array_keys(Config::getInstance()->get('sites') ?? []),
@@ -110,15 +151,18 @@ final class Compile extends \Symfony\Component\Console\Command\Command
             ),
         ]);
 
-        $creator->addServiceVolume(
-            'nginx',
-            "{$compiledPath}/nginx.conf",
-            '/etc/nginx/conf.d/default.conf',
-            'ro'
-        );
+        $mounts = [
+            "{$compiledPath}/nginx.conf" => 'conf.d/nginx.conf',
+            "{$compiledPath}/nginx-sites.conf" => 'sites.conf',
+            "{$cwd}/config/nginx-php.conf" => 'php.conf',
+            "{$compiledPath}/ssl.crt" => "ssl.crt",
+            "{$compiledPath}/ssl.key" => "ssl.key",
+        ];
+        foreach ($mounts as $source => $target) {
+            $creator->addServiceVolume('nginx', $source, "/etc/nginx/{$target}", 'ro');
+        }
         $creator->addServiceVolume('nginx', realpath("{$cwd}/logs/nginx"), '/var/log/nginx');
 
-
         foreach ($volumes as $volume) {
             $creator->addServiceVolume('nginx', ...$volume);
         }
@@ -176,26 +220,40 @@ final class Compile extends \Symfony\Component\Console\Command\Command
     {
         $creator = new NginxConfiguration();
 
-        // Register event handler to add the nginx-php configuration to each location
-        $creator->on(NginxConfiguration::EVENT_LOCATION_ADD_BEFORE, function ($location, &$config) use ($creator) {
-            $config[] = $creator->write('location ~ \.php(?:$|/)', [
-                'include fastcgi_params',
-                'fastcgi_split_path_info ^(.+?\.php)(/.*)$',
-                'fastcgi_intercept_errors on',
-                'fastcgi_pass $fastcgi_backend',
-                'fastcgi_index index.php',
-                'fastcgi_param SERVER_NAME $http_host',
-                'fastcgi_param DOCUMENT_ROOT $site_document_root',
-                'fastcgi_param PATH_INFO $fastcgi_path_info',
-                'fastcgi_param SCRIPT_FILENAME $request_filename',
+        // Add upstreams and servers
+        foreach (Config::getInstance()->get('php') as $version => $port) {
+            $index = $this->getPHPVersionIndex($version, 'php');
+
+            $creator->addUpstream("{$index}_backend", [
+                "server {$index}:9000"
             ]);
-        });
+
+            $creator->addServer([
+                'listen ' . $port . ' default_server ssl',
+                'server_name _',
+
+                'ssl_certificate /etc/nginx/ssl.crt',
+                'ssl_certificate_key /etc/nginx/ssl.key',
+
+                'root /var/www/html',
+                'set $fastcgi_backend ' . $index . '_backend',
+                'include sites.conf',
+            ]);
+        }
+
+        return $creator->dump();
+    }
+
+    private function createNginxSites(): string
+    {
+        $creator = new NginxConfiguration();
 
         // Add default location
         $creator->addLocation('/sites', [
             'alias /var/www/html/studip-dockerized-config.app',
             'index index.php index.html index.html',
             'set $site_document_root /var/www/html/studip-dockerized-config.app',
+            'include php.conf',
         ]);
 
         // Add configured locations
@@ -209,22 +267,7 @@ final class Compile extends \Symfony\Component\Console\Command\Command
                 'alias ' . $p,
                 'index index.php index.html index.html',
                 'set $site_document_root ' . $p,
-            ]);
-        }
-
-        // Add upstreams and servers
-        foreach (Config::getInstance()->get('php') as $version => $port) {
-            $index = $this->getPHPVersionIndex($version, 'php');
-
-            $creator->addUpstream("{$index}_backend", [
-                "server {$index}:9000"
-            ]);
-
-            $creator->addServer([
-                'listen ' . $port . ' default_server',
-                'server_name _',
-                'root /var/www/html',
-                'set $fastcgi_backend ' . $index . '_backend',
+                'include php.conf',
             ]);
         }
 
diff --git a/lib/Commands/Docker/Build.php b/lib/Commands/Docker/Build.php
index 080e444..3ba1047 100644
--- a/lib/Commands/Docker/Build.php
+++ b/lib/Commands/Docker/Build.php
@@ -2,6 +2,10 @@
 namespace Studip\Dockerized\Commands\Docker;
 
 use Studip\Dockerized\DockerComposeCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
 
 final class Build extends DockerComposeCommand
 {
@@ -12,6 +16,34 @@ final class Build extends DockerComposeCommand
     }
     protected function getDockerComposeCommand(): array
     {
+        $dockerRunning = $this->isDockerRunning();
+        if ($dockerRunning) {
+            $this->createDockerComposeCommand(['down']);
+        }
+
         return ['up', '--no-start'];
     }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $dockerRunning = $this->isDockerRunning();
+        if ($dockerRunning) {
+            $this->getApplication()->doRun(
+                new ArrayInput(['command' => 'docker:stop']),
+                $output
+            );
+        }
+
+        $result = parent::execute($input, $output);
+        if ($result !== Command::SUCCESS || !$dockerRunning) {
+            return $result;
+        }
+
+        $this->getApplication()->doRun(
+            new ArrayInput(['command' => 'docker:start']),
+            $output
+        );
+
+        return Command::SUCCESS;
+    }
 }
\ No newline at end of file
diff --git a/lib/Commands/Keys/Create.php b/lib/Commands/Keys/Create.php
new file mode 100644
index 0000000..dcec238
--- /dev/null
+++ b/lib/Commands/Keys/Create.php
@@ -0,0 +1,47 @@
+<?php /** @noinspection PhpExpressionResultUnusedInspection */
+
+namespace Studip\Dockerized\Commands\Keys;
+
+use Studip\Dockerized\Traits\GetCompiledPath;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\Process\Process;
+
+final class Create extends Command
+{
+    use GetCompiledPath;
+
+    protected function configure()
+    {
+        $this->setName('keys:create');
+        $this->setDescription('Creates necessary keys for ssl');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        $process = new Process([
+            'openssl', 'req',
+            '-batch',
+            '-x509',
+            '-nodes',
+            '-days', '365',
+            '-newkey', 'rsa:2048',
+            '-keyout', $this->getCompiledPath('ssl.key'),
+            '-out', $this->getCompiledPath('ssl.crt'),
+        ]);
+        $process->run();
+
+        if (!$process->isSuccessful()) {
+            $io->error(['Could not create keys', $process->getErrorOutput()]);
+            return Command::FAILURE;
+        }
+
+        $io->success('Keys created successfully');
+
+        return Command::SUCCESS;
+    }
+}
diff --git a/lib/Config.php b/lib/Config.php
index 147ed44..74cb7db 100644
--- a/lib/Config.php
+++ b/lib/Config.php
@@ -75,7 +75,7 @@ final class Config
 
     public function store(): void
     {
-        if (!is_writable($this->filename)) {
+        if (file_exists($this->filename) && !is_writable($this->filename)) {
             throw new \Exception("Config file '{$this->filename}' is not writable");
         }
 
diff --git a/lib/Creators/NginxConfiguration.php b/lib/Creators/NginxConfiguration.php
index fba16ee..9e4c710 100644
--- a/lib/Creators/NginxConfiguration.php
+++ b/lib/Creators/NginxConfiguration.php
@@ -70,19 +70,17 @@ final class NginxConfiguration
             ...array_map(
                 fn($server) => $this->write(
                     'server',
-                    array_merge(
-                        $server['config'],
-                        array_map(
-                            fn($location) => $this->write(
-                                "location {$location['modifier']} {$location['location']}",
-                                $location['config']
-                            ),
-                            $this->locations
-                        )
-                    )
+                    $server['config']
                 ),
                 $this->servers
             ),
+            ...array_map(
+                fn($location) => $this->write(
+                    "location {$location['modifier']} {$location['location']}",
+                    $location['config']
+                ),
+                $this->locations
+            ),
         ]));
         $result = (string) Scope::fromFile($tempName);
         unlink($tempName);
diff --git a/lib/DockerComposeCommand.php b/lib/DockerComposeCommand.php
index adc8636..f6cf466 100644
--- a/lib/DockerComposeCommand.php
+++ b/lib/DockerComposeCommand.php
@@ -1,7 +1,7 @@
 <?php
 namespace Studip\Dockerized;
 
-use Studip\Dockerized\Traits\GetCompiledDockerComposeYMLPath;
+use Studip\Dockerized\Traits\GetCompiledPath;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -9,17 +9,29 @@ use Symfony\Component\Process\Process;
 
 abstract class DockerComposeCommand extends Command
 {
-    use GetCompiledDockerComposeYMLPath;
+    use GetCompiledPath;
 
     abstract protected function getDockerComposeCommand(): array;
 
     protected function execute(InputInterface $input, OutputInterface $output)
     {
-        $process = new Process([
+        $process = $this->createDockerComposeCommand($this->getDockerComposeCommand());
+        $this->streamProcess($process, $output);
+        return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
+    }
+
+    protected function createDockerComposeCommand(array $command): Process
+    {
+        return new Process([
             'docker', 'compose',
-            '-f', self::GetCompiledDockerComposeYMLPath(),
-            ...$this->getDockerComposeCommand(),
+            '-f', self::GetCompiledPath() . '/docker-compose.yml',
+            ...$command,
         ]);
+
+    }
+
+    protected function streamProcess(Process $process, OutputInterface $output): void
+    {
         $process->setTty(true);
         $process->mustRun(function ($type, $buffer) use ($output) {
             if (Process::OUT === $type) {
@@ -28,7 +40,19 @@ abstract class DockerComposeCommand extends Command
                 $output->write('<error>' . $buffer . '</error>');
             }
         });
+    }
 
-        return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
+    protected function isDockerRunning(): bool
+    {
+        $process = $this->createDockerComposeCommand(['ps']);
+        $process->run();
+
+        if (!$process->isSuccessful()) {
+            return false;
+        }
+
+        $output = trim($process->getOutput());
+
+        return count(explode("\n", $output)) > 1;
     }
 }
\ No newline at end of file
diff --git a/lib/Traits/GetCompiledDockerComposeYMLPath.php b/lib/Traits/GetCompiledDockerComposeYMLPath.php
deleted file mode 100644
index 0c04b92..0000000
--- a/lib/Traits/GetCompiledDockerComposeYMLPath.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-namespace Studip\Dockerized\Traits;
-
-trait GetCompiledDockerComposeYMLPath
-{
-    protected static function GetCompiledDockerComposeYMLPath(): string
-    {
-        $filename = realpath(__DIR__ . '/../../compiled/docker-compose.yml');
-        if (!file_exists($filename)) {
-            throw new \Exception('docker-compose.yml was not yet compiled');
-        }
-        if (!is_readable($filename)) {
-            throw new \Exception('docker-compose.yml is not readable');
-        }
-        return $filename;
-    }
-}
\ No newline at end of file
diff --git a/lib/Traits/GetCompiledPath.php b/lib/Traits/GetCompiledPath.php
new file mode 100644
index 0000000..d0169b0
--- /dev/null
+++ b/lib/Traits/GetCompiledPath.php
@@ -0,0 +1,10 @@
+<?php
+namespace Studip\Dockerized\Traits;
+
+trait GetCompiledPath
+{
+    protected static function GetCompiledPath(?string $filename = null): string
+    {
+        return realpath(__DIR__ . '/../../compiled') . ($filename ? '/' . $filename : '');
+    }
+}
\ No newline at end of file
diff --git a/studip-docker b/studip-docker
index a1bb30e..aa12677 100755
--- a/studip-docker
+++ b/studip-docker
@@ -7,13 +7,13 @@ use Symfony\Component\Console\Application;
 
 const CONFIG_FILE = __DIR__ . '/config.json';
 
-\Studip\Dockerized\Config::load(CONFIG_FILE);
-
 $application = new Application('Stud.IP Dockerized', '1.0');
 
 $application->add(new Commands\Init());
 
 if (file_exists(CONFIG_FILE)) {
+    \Studip\Dockerized\Config::load(CONFIG_FILE);
+
     $application->add(new Commands\Compile());
 
     $application->add(new Commands\Sites\Add());
@@ -27,6 +27,8 @@ if (file_exists(CONFIG_FILE)) {
     $application->add(new Commands\Docker\Build());
     $application->add(new Commands\Docker\Start());
     $application->add(new Commands\Docker\Stop());
+
+    $application->add(new Commands\Keys\Create());
 }
 
 $application->run();
diff --git a/web/index.php b/web/index.php
index 8169b24..5fdebf2 100644
--- a/web/index.php
+++ b/web/index.php
@@ -34,7 +34,7 @@ th {
                 <td><?= htmlentities($path) ?></td>
             <?php foreach ($config['php'] as $version => $port): ?>
                 <td>
-                    <a href="http://localhost:<?= $port ?>/<?= htmlentities($path) ?>">
+                    <a href="https://localhost:<?= $port ?>/<?= htmlentities($path) ?>">
                         <?= htmlentities($version) ?>
                     </a>
                 </td>
-- 
GitLab