Автоматический деплой Opencart с помощью GitHub Actions
Вы когда-нибудь забывали внести изменения в базу данных после публикации нового функционала? А еще эти кеши модификаторов, которые нужно не забыть обновить. В этой статье я опишу, как автоматизировать процесс деплоя проекта и избавить себя от рутины.
Данная статья предполагает, что вы используете Git как систему контроля версий, GitHub как хранилище вашего репозитория и также у вас есть SSH доступ к серверу, на котором можно запустить composer. В качестве основы для нашей автоматизации мы будем использовать GitHub Actions, который позволяет запускать различные процессы внутри контейнеров.
Что нам понадобится сделать:
- консольные скрипты для очистки модификаторов и кеша системы
- систему миграций для БД
Консольные команды
Конечно, можно использовать исходный код опенкарта и на основе него написать скрипты, но это не наш метод. Мы напишем собственные велосипеды и за основу возьмем Symfony Console.
Устанавливаем нужные зависимости:
composer require symfony/console
Создаем директорию src для нашего приложения (лучше всего размещать такой код на уровень выше вашего index.php)
В composer.json, в разделе автозагрузки добавим код для задания пространства имен нашего приложения:
"autoload": { "psr-4": { "App\\": "путь к папке/src/", } }, "require": { ...список ваших зависимостей..., }
Создадим папки в директории src для будущего кода: Commands, Models, Services, Traits
Структура папки src
Создадим сервисы для очистки директорий кеша
Базовый класс
<?php declare(strict_types=1); namespace App\Services\Clear; use FilesystemIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; class BaseClearService { protected const SKIP_FILE = 'index.html'; public function clear(string $dir): void { $directories = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS); $files = new RecursiveIteratorIterator($directories, RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isFile() && $file->getFilename() === self::SKIP_FILE) { continue; } $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); } } }
Сервис очистка системного кеша
<?php declare(strict_types=1); namespace App\Services\Clear; class CacheClearService extends BaseClearService { public function process(): void { $this->clear(DIR_CACHE); } }
Сервис очистки старых модификаторов
<?php declare(strict_types=1); namespace App\Services\Clear; class ModificationClearService extends BaseClearService { public function process(): void { $this->clear(DIR_MODIFICATION); } }
ModificationService - мне было лень переписывать код, взял из опенкарта как есть.
<?php namespace App\Services; use App\Model\Modification; use DOMDocument; class ModificationService { public function process(): void { $xml = []; $xml[] = file_get_contents(DIR_SYSTEM . 'modification.xml'); $files = glob(DIR_SYSTEM . '*.ocmod.xml'); if ($files) { foreach ($files as $file) { $xml[] = file_get_contents($file); } } $results = Modification::query()->where('status', 1)->get(); foreach ($results as $result) { $xml[] = $result->xml; } $modification = []; foreach ($xml as $xml) { if (empty($xml)) { continue; } $dom = new DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = false; $dom->loadXml($xml); $recovery = []; if (isset($modification)) { $recovery = $modification; } $files = $dom->getElementsByTagName('modification') ->item(0) ->getElementsByTagName('file'); foreach ($files as $file) { $operations = $file->getElementsByTagName('operation'); $files = explode('|', $file->getAttribute('path')); foreach ($files as $file) { $path = ''; if ((substr($file, 0, 7) === 'catalog')) { $path = DIR_CATALOG . substr($file, 8); } if ((substr($file, 0, 5) === 'admin')) { $path = DIR_APPLICATION . substr($file, 6); } if ((substr($file, 0, 6) === 'system')) { $path = DIR_SYSTEM . substr($file, 7); } if ($path) { $files = glob($path, GLOB_BRACE); if ($files) { foreach ($files as $file) { if (substr($file, 0, strlen(DIR_CATALOG)) == DIR_CATALOG) { $key = 'catalog/' . substr($file, strlen(DIR_CATALOG)); } if (substr($file, 0, strlen(DIR_APPLICATION)) == DIR_APPLICATION) { $key = 'admin/' . substr($file, strlen(DIR_APPLICATION)); } if (substr($file, 0, strlen(DIR_SYSTEM)) == DIR_SYSTEM) { $key = 'system/' . substr($file, strlen(DIR_SYSTEM)); } if (!isset($modification[$key])) { $content = file_get_contents($file); $modification[$key] = preg_replace('~\r?\n~', "\n", $content); $original[$key] = preg_replace('~\r?\n~', "\n", $content); } foreach ($operations as $operation) { $error = $operation->getAttribute('error'); $ignoreif = $operation->getElementsByTagName('ignoreif')->item(0); if ($ignoreif) { if ($ignoreif->getAttribute('regex') !== 'true') { if (strpos($modification[$key], $ignoreif->textContent) !== false) { continue; } } else { if (preg_match($ignoreif->textContent, $modification[$key])) { continue; } } } $status = false; // Search and replace if ($operation->getElementsByTagName('search')->item(0)->getAttribute( 'regex' ) !== 'true') { $search = $operation->getElementsByTagName('search')->item(0)->textContent; $trim = $operation->getElementsByTagName('search')->item(0)->getAttribute( 'trim' ); $index = $operation->getElementsByTagName('search')->item(0)->getAttribute( 'index' ); if (!$trim || $trim === 'true') { $search = trim($search); } // Add $add = $operation->getElementsByTagName('add')->item(0)->textContent; $trim = $operation->getElementsByTagName('add')->item(0)->getAttribute('trim'); $position = $operation->getElementsByTagName('add')->item(0)->getAttribute( 'position' ); $offset = $operation->getElementsByTagName('add')->item(0)->getAttribute( 'offset' ); if ($offset == '') { $offset = 0; } if ($trim === 'true') { $add = trim($add); } if ($index !== '') { $indexes = explode(',', $index); } else { $indexes = array(); } $i = 0; $lines = explode("\n", $modification[$key]); for ($line_id = 0; $line_id < count($lines); $line_id++) { $line = $lines[$line_id]; $match = false; if (stripos($line, $search) !== false) { if (!$indexes) { $match = true; } elseif (in_array($i, $indexes)) { $match = true; } $i++; } if ($match) { switch ($position) { default: case 'replace': $new_lines = explode("\n", $add); if ($offset < 0) { array_splice( $lines, $line_id + $offset, abs($offset) + 1, array(str_replace($search, $add, $line)) ); $line_id -= $offset; } else { array_splice( $lines, $line_id, $offset + 1, array(str_replace($search, $add, $line)) ); } break; case 'before': $new_lines = explode("\n", $add); array_splice($lines, $line_id - $offset, 0, $new_lines); $line_id += count($new_lines); break; case 'after': $new_lines = explode("\n", $add); array_splice($lines, ($line_id + 1) + $offset, 0, $new_lines); $line_id += count($new_lines); break; } $status = true; } } $modification[$key] = implode("\n", $lines); } else { $search = trim( $operation->getElementsByTagName('search')->item(0)->textContent ); $limit = $operation->getElementsByTagName('search')->item(0)->getAttribute( 'limit' ); $replace = trim($operation->getElementsByTagName('add')->item(0)->textContent); if (!$limit) { $limit = -1; } $match = []; preg_match_all($search, $modification[$key], $match, PREG_OFFSET_CAPTURE); if ($limit > 0) { $match[0] = array_slice($match[0], 0, $limit); } if ($match[0]) { $status = true; } $modification[$key] = preg_replace( $search, $replace, $modification[$key], $limit ); } if (!$status) { // Abort applying this modification completely. if ($error == 'abort') { $modification = $recovery; break 5; } // Skip current operation or break elseif ($error == 'skip') { continue; } // Break current operations else { break; } } } } } } } } } foreach ($modification as $key => $value) { // Only create a file if there are changes if ($original[$key] != $value) { $path = ''; $directories = explode('/', dirname($key)); foreach ($directories as $directory) { $path = $path . '/' . $directory; if (!is_dir(DIR_MODIFICATION . $path)) { if (!mkdir($concurrentDirectory = DIR_MODIFICATION . $path, 0777) && !is_dir( $concurrentDirectory )) { throw new \RuntimeException( sprintf('Directory "%s" was not created', $concurrentDirectory) ); } } } $handle = fopen(DIR_MODIFICATION . $key, 'w'); fwrite($handle, $value); fclose($handle); } } } }
Первый сервис очищает директорию, а второй создает модифицируемые файлы. Для создания файлов нам нужно из базы получить список модификаторов. Для этой цели можно использовать разные подходы, но т.к. я использую в других проектах Laravel, я задействую для работы с базой Eloquent .
Установим пакет:
composer require illuminate/database
Создаем файл модели модификаций:
<?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; class Modification extends Model { protected $table = 'modification'; protected $primaryKey = 'modification_id'; public $timestamps = false; }
Файлы для работы с базой данных
<?php namespace App\Traits; trait Singleton { public static $instance; public static function getInstance(): self { if (empty(self::$instance)) { self::$instance = new static(); } return self::$instance; } private function __clone() { } private function __wakeup() { } }
<?php namespace App\Models; use App\Traits\Singleton; use Illuminate\Database\Capsule\Manager as Capsule; class Database { use Singleton; public function __construct() { $capsule = new Capsule; $capsule->addConnection( [ 'driver' => 'mysql', 'host' => DB_HOSTNAME, 'database' => DB_DATABASE, 'username' => DB_USERNAME, 'password' => DB_PASSWORD, 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', 'prefix' => 'oc_', ] ); $capsule->setAsGlobal(); $capsule->bootEloquent(); } }
Теперь непосредственно создадим классы консольных команд для очистки системного кеша
<?php declare(strict_types=1); namespace App\Commands; use App\Services\CacheClearService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class CacheClearCommand extends Command { protected function configure(): void { $this->setName('cache:clear') ->setDescription('Clear system cache'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { (new CacheClearService())->process(); } catch (\Throwable $e) { $output->writeln('<error>Directory error</error>'); return Command::FAILURE; } $output->writeln('<info>Modification cache cleared successfully</info>'); return Command::SUCCESS; } }
И для очистки кеша модификаторов
<?php declare(strict_types=1); namespace App\Commands\Opencart; use App\Models\Database; use App\Services\ModificationClearService; use App\Services\ModificationService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ModificationClearCommand extends Command { protected function configure(): void { $this->setName('cache:modification') ->setDescription('Clear modification cache'); } protected function execute(InputInterface $input, OutputInterface $output): int { try { Database::getInstance(); (new ModificationClearService())->process(); (new ModificationService())->process(); } catch (\Throwable $e) { $output->writeln('<error>Directory deleting error</error>'); return Command::FAILURE; } $output->writeln('<info>Modification cache cleared successfully</info>'); return Command::SUCCESS; } }
Соберем все в общее консольное приложение
<?php declare(strict_types=1); namespace App\Commands; use App\Commands\CacheClearCommand; use App\Commands\ModificationClearCommand; use Exception; use Symfony\Component\Console\Application; class AppConsole { /** * @throws Exception */ public function run(): void { $app = new Application(); $app->add(new ModificationClearCommand()); $app->add(new CacheClearCommand()); $app->run(); } }
И создадим файл console в корне, который будет запускать саму консоль
#!/usr/bin/env php <?php require dirname(__DIR__) . '/путь к/vendor/autoload.php'; require __DIR__ . '/путь к/admin/config.php'; use App\Commands\AppConsole; (new AppConsole())->run();
Делаем его исполняемым: chmod +x console
Теперь при запуске команды php console мы получим список доступных команд:
php console cache:clear - очистка системного кеша
php console cache:modification - очистка модификаторов
Таким образом можно создать другие команды: для сжатия картинок, для бэкапов базы и т.д.
Миграции баз данных
Данный подход позволяет фиксировать все изменения базы в PHP-коде. Следовательно, мы можем в Git хранить всю историю изменений базы данных.
Мы будем использовать библиотеку phinx. Установим ее:
composer require robmorgan/phinx
Далее запускаем команду для создания конфигурационного файла phinx.php
./vendor/bin/phinx init
Создадим папку db/migrations - тут будут храниться файлы миграций.
В конфиге phinx.php прописываем путь для папки, где будут лежать файлы миграций и параметры подключения к БД
<?php return [ 'paths' => [ 'migrations' => '%%PHINX_CONFIG_DIR%%/путь до папки db/migrations', 'seeds' => '%%PHINX_CONFIG_DIR%%/db/seeds' ], 'environments' => [ 'default_migration_table' => 'phinxlog', 'default_environment' => 'development', 'development' => [ 'adapter' => 'mysql', 'host' => 'host db', 'name' => 'opencart', 'user' => 'admin', 'pass' => '1234567', 'port' => '3306', 'table_prefix' => 'oc_', 'charset' => 'utf8mb4', ], ], 'version_order' => 'creation' ];
Ключ environments позволяет прописывать различные подключения к базе на проде или тестовом сервере и потом при запуске указывать, для какого окружения нужно запускать миграции.
Чтобы создать миграцию, запустите команду create и укажите название класса миграции, в названии которого будет отображена суть (для какой таблицы мы создаем миграцию).
Например, создадим миграцию для таблицы с постами:
./vendor/bin/phinx create PostsTableMigration
После чего в папке db/migrations появится файл с временной меткой в названии файла и именем миграции с snake case. Заполним файл:
<?php declare(strict_types=1); use Phinx\Migration\AbstractMigration; final class DocsMigration extends AbstractMigration { public function change(): void { $table = $this->table('docs'); $table->addColumn('route', 'string') ->addColumn('description', 'text') ->addColumn('created', 'datetime', ['default' => 'CURRENT_TIMESTAMP']) ->addIndex(['route'], ['unique' => true]) ->create(); } }
Тут все просто, указываем название таблицы, колонки и их типы, задаем индексы. Для каждой таблицы автоматически создается автоинкрементное поле id. Полную инструкцию по написанию миграций можно найти на оф. сайте: https://book.cakephp.org/phinx/0/en/migrations.html
Для запуска миграций воспользуемся командой:
./vendor/bin/phinx migrate
После запуска данного скрипта в таблице phinxlog (настраивается в параметре default_migration_table из файла phinx.php) появится запись о примененной миграции.
GitHub Actions
Остался последний и самый главный этап - настройка на стороне GitHub.
Создаем в корне проекта файл (указываю полный путь) .github/workflows/deploy.yml. Здесь будут указаны все задачи, которые должны будут запускаться внутри контейнеров на стороне GitHub.
name: Deploy on: push: branches: [ main ] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} port: ${{ secrets.PORT }} script: | cd /путь к корню проекта ./.scripts/deploy.sh ./vendor/bin/phinx migrate
Тут мы указываем на что будет реагировать наш Action. В данном случае на пуш в ветку main
on: push: branches: [ main ]
jobs - непосредственно описываем задачи
deploy - имя задачи
appleboy/ssh-action@master - инструмент для запуска удаленных команд по SSH. Ниже указаны параметры для аутентификации и список выполняемых команд:
- cd - переходим в корень проекта
- ./.scripts/deploy.sh - запускаем баш-скрипт с нашими командами (опишем ниже)
- ./vendor/bin/phinx migrate - запускаем миграции
appleboy/ssh-action@master - для аутентификации можно использовать ssh-юзера и пароль, но я указал настройки для входа по ключу.
Для этого зайдем по SSH на сервер, куда мы будем деплоить проект и создадим связку ключей:
ssh-keygen -t rsa -b 4096 -C "[email protected]"
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
Скопируем приватный ключ
cat ~/.ssh/id_rsa
Теперь нужно заполнить секретные ключи в GitHub.
Переходим в наш репозиторий: Settings -> Secrets and variables -> Actions и добавляем наши секреты:
HOST - хост сервера
PORT - порт сервера
KEY - скопированный ранее приватный ключ
USERNAME - имя пользователя на сервере
Создаем баш-скрипт .scripts/deploy.sh для запуска консольных команд, которые мы создавали в начале статьи.
#!/bin/bash set -e echo "Deployment started ..." git pull php console cache:modification php console cache:clear echo "Deployment finished!"
Тут мы забираем все изменения из git-репозитория и чистим кеши нашими консольными скриптами. Сюда можно добавить команду для включения режима обслуживания на время деплоя, но это оставлю вам для самостоятельного выполнения.
Разрешаем запуск данного скрипта
chmod +x deploy.sh
Окончательная структура директорий
Теперь мы можем запушить новые изменения в репозиторий и увидим, как во вкладке Actions на GitHub появится новая задача. Мы можем нажать на нее и посмотреть детально на все происходящие процессы в контейнерах.
Если произойдет ошибка, то вам на почту придет письмо, что Action завершился неудачно.
Сюда также можно добавить проверки на codestyle, запуск тестов и построить полноценное CI/CD, но это пока не про Opencart...
- 2
0 коментарів
Recommended Comments
Немає коментарів для відображення
Створіть аккаунт або увійдіть для коментування
Ви повинні бути користувачем, щоб залишити коментар
Створити обліковий запис
Зареєструйтеся для отримання облікового запису. Це просто!
Зареєструвати аккаунтВхід
Уже зареєстровані? Увійдіть тут.
Вхід зараз