Travis@Home: Jak testovat více verzí PHP na localhostu pomocí Dockeru

Testy open source knihoven v Travisu běží často i 10 minut, což výrazně prodlužuje dobu přípravy Pull Requestu, pokud jako já, děláte často drobné a hloupé chyby. Dlouhá doba 1 iterace ohrožuje motivaci do takových knihoven přispívat a snižuje efektivitu práce. Pokusil jsem se to vyřešit s pomocí Dockeru na localhostu ve Windows 10.

Problém

Vždycky, když jsem se snažil přispět nějakým Pull Requestem do open source repozitářů jako je Kdyby nebo Contributte nebo dalších projektů kolem Nette, často jsem narážel na chyby v CI, konkrétně v Travisu.

Tyhle balíčky testují svůj kód obvykle proti několika verzím PHP, většinou je součástí i statiská analýza (PHPStan) a kontrola Code standardů. Pod WIN 10 jsem často něco z toho vůbec nespustil nebo mi to házelo divné chyby nebo jsem otestoval nanejvýš 1 PHP verzi.

Běžný cyklus přípravy PR pak vypadal tak, že jsem něco upravil, spustil u sebe na localhostu test pro 1 verzi PHP a případně další analýzy, které mi vůbec chodily. Změny jsem commitnul, pushnul do svého forku na Githubu, čekal 6-10 minut, než doběhnou všechny testy v Travisu, abych pak zjistil, že jsem udělal nějakou drobnou chybičku v coding standards. Takže změna, commit, push a dalších 6-10 minut…

Pokud takhle vypadá vaše první zkušenost s CI, vůbec bych se nedivil, kdybyste automatické testování začali nenávidět.

Rychlý cyklus iterací a rychlá zpětná vazba jsou podle mě zásadní pro efektivní vývoj čehokoliv. Pokusil jsem se to tedy napravit s pomocí Dockeru, který mi konečně před čtvrt rokem začal fungovat i pod Windows 10 (proč předtím nefungoval, na to jsem nikdy nepřišel).

Analýza

V tomto článku se pokusím zreplikovat CI pro balíček Kdyby/Translation u sebe na localhostu pod Windows 10. Balíček Kdyby/Translation dělá CI testy v následujících prostředích:

  • PHP 7.1
  • PHP 7.1low (s příznakem –prefer-lowest)
  • PHP 7.2
  • PHP 7.2low
  • PHP 7.3 + PHPStan + phpcs (coding standards) + coverage
  • PHP 7.3low

Dlouhý běh CI Travis je způsoben tím, že se při každém pushi instaluje vše od začátku. To je super pro účely CI, ale při rutinním testování v průběhu vývoje to strašně zdržuje.

V drtivé většině případů se obsah adresáře vendor nijak nemění a upravuji jen zdrojové kódy (například adresáře app, src, tests). Ideální by tedy bylo, kdybych pro každou verzi vytvořil vlastní docker kontejner. Do něj namapuji zdrojové kódy projektu z jedné společně sdílené složky. Ale složku vendor si pro každý kontejner namapuji zvlášť někam vedle.

Díky tomu můžu v každém kontejneru (= pro každou verzi PHP) udělat composer update jen jednou a pak už jen spouštět testy.

Protože projektů mám hodně, chtěl bych to ideálně nějak zobecnit. Kontejnery budu jednak vytvářet pomocí docker-compose a jednak si k obsluze vytvořím BAT soubory.

Aktuální setting tedy bude vypadat takto:

  • Projekt mám v HTDOCS na cestě D:\www\kdyby\Translation
  • Složka s BAT skripty a definicí kontejnerů C:\docker

Složku C:\docker vložím do Windows PATH, abych mohl odkudkoliv spouštět své BAT soubory.

Vytvoření podadresáře projects

V C:\docker si vytvořím podadresář projects. Sem budu ukládat obsahy adresářů vendor pro jednotlivé verze PHP.

Vytvoření Dockerfile pro každou verzi PHP

V podsložkách C:\docker\php7.1, C:\docker\php7.2 a C:\docker\php7.3 si připravím Dockerfile:

FROM php:7.1-cli

RUN apt-get update && \
    apt-get install -y --no-install-recommends git zip

RUN curl --silent --show-error https://getcomposer.org/installer | php

RUN mv composer.phar /usr/local/bin/composer

RUN apt-get install unzip

RUN docker-php-ext-install mysqli pdo_mysql

RUN apt-get install -y zlib1g-dev libzip-dev

RUN docker-php-ext-configure zip --with-libzip

RUN docker-php-ext-install zip

RUN pecl install apcu
RUN docker-php-ext-enable apcu

Na prvním řádku vždy jen upravím verzi PHP a ostatní zůstane stejné.

Vytvoření docker-compose.yml

V adresáři C:\docker si pak vytvořím soubor docker-compose.yml

version: '3'

services:
  mysql:
    container_name: mysql
    image: mariadb/server:10.3
    environment:
      - MYSQL_ALLOW_EMPTY_PASSWORD='yes'
      - MYSQL_ROOT_PASSWORD=
    ports:
      - "3308:3306"
  php7.1:
    build: './php7.1'
    stdin_open: true
    container_name: php7.1
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.1:/var/www/vendor/'
  php7.1low:
    build: './php7.1'
    stdin_open: true
    container_name: php7.1low
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.1-lowest:/var/www/vendor/'
  php7.2:
    build: './php7.2'
    stdin_open: true
    container_name: php7.2
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.2:/var/www/vendor/'
  php7.2low:
    build: './php7.2'
    stdin_open: true
    container_name: php7.2low
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.2-lowest:/var/www/vendor/'
  php7.3:
    build: './php7.3'
    stdin_open: true
    container_name: php7.3
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.3:/var/www/vendor/'
  php7.3low:
    build: './php7.3'
    stdin_open: true
    container_name: php7.3low
    depends_on:
      - mysql
    volumes:
      - '${MY_COMPOSER_DIR}:/var/www'
      - './projects/${MY_COMPOSER_PROJECT}/php7.3-lowest:/var/www/vendor/'

Krátký komentář: přidal jsem tam rovnou i mysql, která se také může hodit (například pro testování modelů s databází). Přemapoval jsem si její port na 3308, protože 3307 i 3306 využívá Wampserver, který mám často spuštěný, tak aby se netloukli.

A dále vytvářím zvlášť každé prostředí (3 verze PHP, každá vždy jako standard a low). Parametrem build se odkazuji do Dockerfile souborů vytvořených v podadresářích v předchozím kroku.

Každý kontejner si pojmenuji a přidám mu závislost na mysql.

V sekci volumes dělám 2 věci:

  • obsah proměnné prostředí MY_COMPOSER_DIR namapuji na cestu /var/www uvnitř kontejneru. K vytvoření MY_COMPOSER_DIR se dostanu za chvíli, v modelovém příkladě to bude složka s celým projektem (v mém případě tedy D:\www\kdyby\Translation)
  • V druhém řádku do /var/www/vendor namapuji obsah podadresáře C:\docker\projects\MY_COMPOSER_PROJECT – to je druhá proměnná prostředí, k níž se za chvíli dostanu

Vytvořím si BAT soubor phpdev.bat pro spuštění projektu

Dalo mi to docela dost ladění, ale nakonec jsem vytvořil soubor phpdev.bat v této podobě:

@echo off

set "MWDIR=%~dp0"
set "MY_COMPOSER_DIR="
set "MY_COMPOSER_PROJECT="

:parseArgs
:: asks for the -foo argument and store the value in the variable FOO

call:getArgWithValue "-dir" "MY_COMPOSER_DIR" "%~1" "%~2" && shift && shift && goto :parseArgs
call:getArgWithValue "-name" "MY_COMPOSER_PROJECT" "%~1" "%~2" && shift && shift && goto :parseArgs

:: =====================================================================

if "%MY_COMPOSER_DIR%"=="" (
  echo Use argument -dir to specify directory
  exit /b 1
)

for %%f in (%MY_COMPOSER_DIR%) do set lastDir=%%~nxf
if "%MY_COMPOSER_PROJECT%"=="" (
  echo Argument -name not specified, using %lastDir%
  SET "MY_COMPOSER_PROJECT=%lastDir%"
)

if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.1" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.1"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.1-lowest" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.1-lowest"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.2" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.2"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.2-lowest" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.2-lowest"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.3" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.3"
if not exist "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.3-lowest" mkdir "%MWDIR%/projects/%MY_COMPOSER_PROJECT%/php7.3-lowest"

docker-compose -f %MWDIR%docker-compose.yml up --build

goto:eof

:: =====================================================================
:: This function sets a variable from a cli arg with value
:: 1 cli argument name
:: 2 variable name
:: 3 current Argument Name
:: 4 current Argument Value
:getArgWithValue
if "%~3"=="%~1" (
  if "%~4"=="" (
    REM unset the variable if value is not provided
    set "%~2="
    exit /B 1
  )
  set "%~2=%~4"
  exit /B 0
)
exit /B 1
goto:eof


:: =====================================================================
:: This function sets a variable to value "TRUE" from a cli "flag" argument
:: 1 cli argument name
:: 2 variable name
:: 3 current Argument Name
:getArgFlag
if "%~3"=="%~1" (
  set "%~2=TRUE"
  exit /B 0
)
exit /B 1
goto:eof

Soubor dělá několik jednoduchých věcí. Za prvé přijímá z příkazové řádky až 2 parametry: dir a name. Parametr name je nepovinný, pokud není uveden, použije se adresář z cesty dir. Jelikož celý adresář C:\docker jsem si zadal do PATH, mohu teď odkudkoliv z příkazové řádky napsat:

phpdev -dir=d:\www\kdyby\Translation

Skript si za name dosadí Translation.

Vytvoří v C:\docker\projects podadresář Translation a v něm podadresáře php7.1, php7.1-lowest, php7.2, php7.2-lowest, php7.3, php7.3-lowest. Do nich pak budeme ukládat obsahy složky vendor pro jednotlivé kontejnery.

Nastaví proměnné prostředí MY_COMPOSER_DIR = d:\www\kdyby\Translation a MY_COMPOSER_PROJECT = Translation.

A spustí příkaz:

docker-compose docker-compose.yml up --build

Ten sestaví a spustí všechny kontejnery v dockeru (6 verzí PHP prostředí a 1 pro MySQL).

Vytvořím si BAT soubor pro composer update

Dále si vytvořím soubor update.bat s tímto obsahem

@echo off

setlocal ENABLEDELAYEDEXPANSION

SET versions=php7.1 php7.1low php7.2 php7.2low php7.3 php7.3low
SET input=%*
IF "%~1"=="" SET input=%versions%
IF "%~1"=="all" SET input=%versions%

(for %%a in (%input%) do (
   SET ver=%%a
   SET stub=!ver:~-3!

   IF !stub!==low (
	SET cm=--prefer-lowest
   ) ELSE (
	SET cm=
   )

   @echo on
   echo "Updating ... !ver!"
   echo docker exec -it -w /var/www/ !ver! composer update --prefer-dist --prefer-stable !cm!
   docker exec -it -w /var/www/ !ver! composer update --prefer-dist --prefer-stable !cm!
   @echo off
))

Ten v každém PHP kontejneru spustí z /var/www příkaz:

composer update --prefer-dist --prefer-stable

pro kontejnery php7.1, php7.2, php7.3, případně

composer update --prefer-dist --prefer-stable --prefer-lowest

pro kontejnery php7.1low, php7.2low, php7.3low.

Po prvním spuštění všech prostředí (a poté jen po změně souboru composer.json v projektu) pak stačí odkudkoliv zavolat jen příkaz

update

Pokud bych chtěl aktualizovat jen některé prostředí, mohu je zadat v příkazové řádce:

update php7.1 php7.3 php7.3low

Vytvořím si BAT soubor pro spouštění testů

No a konečně se dostávám k testům. Vytvořím si soubor test.bat s tímto obsahem:

@echo off

setlocal ENABLEDELAYEDEXPANSION

SET versions=php7.1 php7.1low php7.2 php7.2low php7.3 php7.3low
SET input=%*
IF "%~1"=="" SET input=%versions%
IF "%~1"=="all" SET input=%versions%

(for %%a in (%input%) do (
   SET ver=%%a

   @echo on
   echo "Testing ... !ver!"
   docker exec -it -w /var/www/ !ver! vendor/bin/tester tests -C
   @echo off

   IF !ver!==php7.3 (
	docker exec -it -w /var/www/ !ver! vendor/bin/phpstan.phar analyse -l 7 -c phpstan.neon src tests/KdybyTests
        docker exec -it -w /var/www/ !ver! php vendor/bin/phpcs --standard=ruleset.xml --encoding=utf-8 -sp src tests
	docker exec -it -w /var/www/ !ver! vendor/bin/tester --coverage ./coverage.xml --coverage-src ./src -s -p phpdbg -C ./tests/KdybyTests/
   )
))

Ten vezme každé zadané prostředí a spustí na příslušném kontejneru v cestě /var/www (to je ten parametr -w) příkaz

vendor/bin/tester tests -C

Pro verzi php7.3 pak spustí ještě nějaké další testy, které jsou nastavené i v původním Travis.yml balíčku Kdyby/Translation. Konkrétně se spustí phpstan na složkách src a tests/KdybyTests, pak se spustí kontrola coding standards a nakonec se spustí analýza test coverage.

Testy pak spustím jednoduše z příkazové řádky Windows:

test

na všech kontejnerech, nebo mohu opět vybrat jen některá prostředí:

test php7.1 php7.3 php7.3low

Odměnou budiž uspokojivý zelený svit všech testů.

Úspěch.

Zdrojové kódy

Kompletní kódy celého projektu najdete ke stažení na mém Githubu.

Otázky k dořešení

  • Náhrada BAT souborů – Psát skripty ve Windows Batch je za trest. Cykly ani podmínky se nechovají, jak bych intuitivně očekával, blbě se to debugguje a navíc to není přenositelné do Linuxu. Příště bych asi raději zvolil pro těch pár skriptů Python.
  • Dynamické čtení travis.yml – Momentálně mám celý stack kontejnerů napsaný natvrdo, což je postačující pro všechny balíčky Kdyby, ale musel bych to celé přepisovat, kdybych na tom chtěl testovat třeba PR do contributte. Přitom definiční soubor travis.yml je dosti primitivní: dal by se rozparsovat, připravit podle toho docker-compose i jednotlivé skripty pro volání testů. V BAT by to pochopitelně byl porod, ale v Pythonu bych si na to troufnul. Bohužel psaní pull requestů mě neživí, takže na to zatím nebyl čas.
  • Namapovat composer.lock pro každý kontejner zvlášť – Současné řešení mapuje composer.lock ze společného adresáře. Správně by měl být namapován samostatně. Do sekce volumes by tedy měl přijít ještě tento jeden řádek.
  • Paralelní spouštění kontejnerů – V příkladu výše trvalo kompletní spuštění 70 sekund. To pořád není žádná hitparáda. V Pythonu by se dalo volání jednotlivých kontejnerů paralelizovat pomocí balíčku Multiprocessing a srazit to na nějakých 10-15 sekund (za předpokladu, že máte aspoň 8 jader procesoru).
  • Ztráta práv dockeru ke sdíleným diskům – Docker pod WIN10 má jeden nepříjemný bug, který je nahlášen mnoho měsíců, ale zatím nevyřešen. Když si ho dlouho nevšímáte, přestože na počítači pracujete, tak ztratí práva k nasdíleným diskům. Je pak potřeba v Settings > Shared drives disky odebrat + Apply a znovu přidat + Apply. Bohužel někdy to nerozchodí, je třeba shodit všechny kontejnery a spustit phpdev znovu. Je to hodně otravné, ale nenašel jsem, co s tím.