Перейти до вмісту
Пошук в
  • Детальніше...
Шукати результати, які ...
Шукати результати в ...

Автоматический деплой Opencart с помощью GitHub Actions


ozzzi

822 перегляди

Вы когда-нибудь забывали внести изменения в базу данных после публикации нового функционала? А еще эти кеши модификаторов, которые нужно не забыть обновить. В этой статье я опишу, как автоматизировать процесс деплоя проекта и избавить себя от рутины.


Данная статья предполагает, что вы используете 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

directories.thumb.png.7a01f4e49bc93ff562e0a7e47fd58968.png

 

 

Создадим сервисы для очистки директорий кеша

 

Базовый класс
 

<?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.

secret-key.thumb.png.6ef272e838939b58dff61e2e4b5a9b9a.png

 

Переходим в наш репозиторий: 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

 

Окончательная структура директорий

 

all-directories.thumb.png.02c4b75ed903714b5ca3677bd2b898ae.png


Теперь мы можем запушить новые изменения в репозиторий и увидим, как во вкладке Actions на GitHub появится новая задача. Мы можем нажать на нее и посмотреть детально на все происходящие процессы в контейнерах.

 

actions.thumb.png.b5341646b450e0daa8950ce6f276c586.png

 

Если произойдет ошибка, то вам на почту придет письмо, что Action завершился неудачно.

 

Сюда также можно добавить проверки на codestyle, запуск тестов и построить полноценное CI/CD, но это пока не про Opencart...

  • +1 2

0 коментарів


Recommended Comments

Немає коментарів для відображення

Створіть аккаунт або увійдіть для коментування

Ви повинні бути користувачем, щоб залишити коментар

Створити обліковий запис

Зареєструйтеся для отримання облікового запису. Це просто!

Зареєструвати аккаунт

Вхід

Уже зареєстровані? Увійдіть тут.

Вхід зараз
  • Зараз на сторінці   0 користувачів

    • Ні користувачів, які переглядиють цю сторінку
×
×
  • Створити...

Important Information

На нашому сайті використовуються файли cookie і відбувається обробка деяких персональних даних користувачів, щоб поліпшити користувальницький інтерфейс. Щоб дізнатися для чого і які персональні дані ми обробляємо перейдіть за посиланням . Якщо Ви натиснете «Я даю згоду», це означає, що Ви розумієте і приймаєте всі умови, зазначені в цьому Повідомленні про конфіденційність.