Deploy to a classic Webhosting with GitLab CI

2024-03-24 · christian · continuous integration, gitlab, webspace

Some time ago I helped with making and deploying a small Website. The Website was made in Symfony 6.x and is deployed to a Webspace without any SSH access. Just FTP.

For quality resons I still wanted to have separate staging and production deployments via GitLab CI. This would allow users easily make changes on the Website via Merge Requests.

Migration File

Since the Webhosting does not provide any SSH access, we have to run Database Migrations with a small helper PHP script. The Pipeline will replace __ACCESS-TOKEN__ with a random password and __PHP_BINARY__ with the full path to the PHP CLI.

Then the Pipeline will execute the migration script with curl.

<?php

if (!( isset($_POST['access_token']) && $_POST['access_token']==="__ACCESS_TOKEN__" &&
    $_POST['access_token']!==str_replace("-", "_", "__ACCESS-TOKEN__") ))
{
    header("HTTP/1.1 403 Forbidden");
    exit();
}

error_reporting(-1);
ini_set('display_errors', 'on');

echo "\n\nStart Migration Script\n\n";

chdir(__DIR__."/../");
echo "Current working directory: ".getcwd()."\n";

function symfony($scmd)
{
    $php = "__PHP_BINARY__";
    $cmd = '/bin/bash -c "'.$php.' bin/console '.$scmd.' 2>&1"';
    echo "Command: ".$cmd."\n";
    var_dump(system($cmd));
    echo "\n";
}

// run symfony commands
echo "Clear cache for prod:\n";
symfony('cache:clear --env=prod');

echo "Warm up cache for prod:\n";
symfony('cache:warmup --env=prod');

echo "Start database migrations:\n";
symfony('doctrine:migrations:migrate --allow-no-migration --no-interaction');

// delete itself
unlink(__FILE__);
echo "Done\n";

Pipeline File

The Pipeline is using a Debian Bookworm image. It installs all required tools like curl, pwgen, lftp and the PHP CLI. Then the required PHP Packages will be installed by composer.

For uploading the PHP files to the Webspace, lftp is used.

stages:
  - deploy

#
# -> Templates
#

.tpl:docker:
  image: debian:bookworm-slim
  before_script:
    # install php and other tools
    - |
      export DEBIAN_FRONTEND=noninteractive
      APTOPTS="--yes --no-install-recommends -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold"

      apt-get update
      apt-get $APTOPTS upgrade
      apt-get $APTOPTS dist-upgrade
      apt-get $APTOPTS install unzip curl wget ca-certificates gnupg2 lftp jq pwgen \
          php8.2-cli php8.2-bcmath php8.2-curl php8.2-gd php8.2-gmp php8.2-imap \
          php8.2-intl php8.2-mbstring php8.2-mysql php8.2-odbc \
          php8.2-opcache php8.2-sqlite3 php8.2-xml php8.2-zip

    # install composer
    # https://getcomposer.org/doc/faqs/how-to-install-composer-programmatically.md
    - |
      EXPECTED_CHECKSUM="$(php -r 'copy("https://composer.github.io/installer.sig", "php://stdout");')"
      php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
      ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"

      if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]
      then
          >&2 echo 'ERROR: Invalid installer checksum'
          rm composer-setup.php
          exit 1
      fi

      php composer-setup.php --quiet
      RESULT=$?
      rm composer-setup.php

      mv composer.phar /usr/bin
      ln -s /usr/bin/composer.phar /usr/bin/composer

    # install application dependencies
    - |
      export APP_ENV=prod
      export APP_DEBUG=0
      composer install --no-dev --optimize-autoloader
      php bin/console cache:clear
  tags:
    - docker

.tpl:deploy:
  extends: .tpl:docker
  stage: deploy
  script:
    # app settings
    - 'echo "DATABASE_URL=''$var_databaseurl''" > .env.local'
    - 'echo "MANAGER_PASSWORD=''$MANAGER_PASSWORD''" >> .env.local'
    - cat .env.local
    - composer dump-env prod

    - 'export MIGRATION_TOKEN=$(pwgen -1 32 1)'
    - sed -i "s|__ACCESS_TOKEN__|$MIGRATION_TOKEN|g" public/migrate.php
    - sed -i "s|__PHP_BINARY__|/usr/local/pd-admin2/php-8.2.15/bin/php-cli|g" public/migrate.php

    # upload
    - 'lftp -e "mirror --reverse --delete --only-newer --no-symlinks --parallel=5 --exclude=.git . $var_ftpfolder/" -u "$var_ftpuser,$var_ftppassword" ftp.example.com'

    # run database migrations
    - 'echo "Migration Token: $MIGRATION_TOKEN"'
    - 'echo "Start migration"'
    - 'curl -X POST -F access_token=$MIGRATION_TOKEN ${var_url}/migrate.php'

    # cleanup
    - rm -f .env.local*

#
# -> Jobs
#

deploy:app:demo:
  extends: .tpl:deploy
  only:
    - demo
  variables:
    var_env: dev
    var_databaseurl: "$DEMO_DATABASE_URL"
    var_ftpuser: ftpusername
    var_ftppassword: "$DEMO_FTP_PASSWD"
    var_ftpfolder: /website-demo
    var_domainname: demo.example.com
    var_url: http://demo.example.com

deploy:app:live:
  extends: .tpl:deploy
  only:
    - live
  variables:
    var_env: prod
    var_databaseurl: "$PROD_DATABASE_URL"
    var_ftpuser: ftpusername
    var_ftppassword: "$PROD_FTP_PASSWD"
    var_ftpfolder: /website-prod
    var_domainname: example.com
    var_url: https://example.com

The following Pipeline Variables have to be defined:

  • MANAGER_PASSWORD: Hashed password for the management backend
  • DEMO_DATABASE_URL: Database URI for staging
  • DEMO_FTP_PASSWD: FTP password for staging
  • PROD_DATABASE_URL: Database URI for production
  • PROD_FTP_PASSWD: FTP password for production

The code is deployed to production if something was pushed to the live branch. If code was pushed to the demo branch, the staging environment is getting updated.

Now back from 2006 to the present. 🙂

Seriously, why are there still providers which only offering FTP?


More


serverless.industries BG by Carl Lender (CC BY 2.0) Imprint & Privacy
d8784b23 2024-06-08 14:35
Mastodon via chaos.social Mastodon via einbeck.social