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