Continuos Integration – temat znany i lubiany, chociaż w wielu firmach temat traktowany po macoszemu (zwłaszcza tych mniejszych). Jeżeli zapyta się ktoś o dostępne na rynku rozwiązania, to bez zastanowienia odpowiemy: Jenkins, GitLab CI, Travis CI. Ostatnio zainteresowało mnie bardziej rozwiązanie udostępniane przez GitLaba i chciałem przetestować jego działanie w praktyce. Odkąd znam trochę Dockera to nie potrafię tworzyć projektów deweloperskich bez niego, dlatego chciałem spróbować oba światy połączyć i zobaczyć czy zaiskrzy. Na szczęście się to udało, co chcę wam dzisiaj pokazać.

Stworzymy dzisiaj bardzo prostą (jak zwykle, ale tak najłatwiej pokazać jakąś ideę!) aplikację, jak zwykle w PHP.

Przygotowanie lokalnego środowiska pod development

Stworzę sobie prostą konfigurację docker-compose, aby móc serwować projekt PHP na Dockerze. Tworzę plik docker-compose:

version: '3'
services:
  image-server-php:
    image: php:7.4.0RC5-apache
    container_name: php7.4.0
    volumes:
      - ./:/var/www/html/
    ports:
      - "80:80"
    stdin_open: true
    tty: true

Operuję na obrazie PHP w wersji 7.4 (nie mogę się doczekać stabilnej wersji 😊) . Cały bieżący katalog podmontuję do katalogu var/www/html w kontenerze.

Teraz tworzę przykładowy skrypt zwracający prostego JSONa (takie mega proste API). Zrobię to dlatego, że będziemy to weryfikować odpowiedź serwera za pomocą Cypressa :):

<?php

declare(strict_types=1);

echo (new class{
    private array $data = [
        'id' => 1,
        'code' => 'DESK-101A',
        'name' => 'desktop',
    ];

    public function getData(): string
    {
        return json_encode($this->data);
    }
})->getData();

Teraz z terminala uruchamiam:

docker-compose up -d 

i sprawdzam IP maszyny Dockera, aby móc wejść na stronę:

docker-machine ip

w końcu wchodzę w przeglądarce pod jej adres. Widzę teraz wynik:

{"id":1,"code":"DESK-101A","name":"desktop"} 

Aplikacja działa jak należy, teraz dodam drugi serwis odpowiedzialny za testy w Cypressie. Dodaję serwis do pliku docker-compose:

version: '3'
services:
  image-server-php:
    image: php:7.4.0RC5-apache
    container_name: php7.4.0
    volumes:
      - ./:/var/www/html/
    ports:
      - "80:80"
    stdin_open: true
    tty: true
  image-server-cypress:
    image: cypress/base:12.13.0
    container_name: cypress12.13.0
    volumes:
      - ./:/var/www/html/
    environment:
      - CYPRESS_CACHE_FOLDER=/var/www/html/.cache/cypress
    stdin_open: true
    tty: true

Jak widzisz powyżej, podmontowałem ten sam katalog do Cypressa jak i PHP. Dzięki temu oba kontenery będą widzieć pliki projektu. Dodatkowo skonfigurowałem zmienną środowiskową tak, aby przenieść katalog z cache Cypressa do workspace projektu (przyda się to podczas konfigurowania cache w Gitlab CI).

Robię znowu docker-compose up -d.

Teraz oba kontenery stoją. Dodaję test integracyjny w katalogu test o nazwie test.spec.js:

context('Request validation', () => {
    it('Should return json', () => {
        cy.request('/').then(response => {
            expect(response.status).to.eq(200)
            expect(response.headers['content-type']).to.eq('text/json')
        })
    })
})

Dodaję konfigurację dla Cypressa w głównym katalogu projektu (plik cypress.json):

{
  "baseUrl": "http://image-server-php",
  "integrationFolder": "test"
}

Dzięki temu Cypress będzie wiedział, że domyślnie ma łączyć się z hostem pod adresem image-server-php (adresem wewnętrznym jest nazwa serwisu z pliku docker-compose.yml), a testy są przechowywane w katalogu test.

Teraz możemy sprobować odpalić testy:

docker exec -i -w /var/www/html cypress12.13.0 node_modules/.bin/cypress run 

Jednak cypress nie jest zainstalowany i to nie działa! Wynika to z tego, że obraz dockerowy Cypressa zawiera wszystkie zależności wymagane przez cypressa, ale jego samego trzeba doinstalować osobno. Zrobimy to teraz:

docker exec -i -w /var/www/html cypress12.13.0 npm install cypress --save-dev
docker exec -i -w /var/www/html cypress12.13.0 node_modules/.bin/cypress cypress install
docker exec -i -w /var/www/html cypress12.13.0 node_modules/.bin/cypress run

otrzymałem wynik negatywny testu. Z treści widać, że oczekiwano nagłówka Content Type text/json, a jest text/html:

Running:  test.spec.js                                                                    (1 of 1)
127   Request validation
128     1) Should return json
129   0 passing (804ms)
130   1 failing
131   1) Request validation Should return json:
132       AssertionError: expected 'text/html; charset=UTF-8' to equal 'text/json'
133       + expected - actual
134       -text/html; charset=UTF-8
135       +text/json

Tworzymy konfigurację CI

Nasz pipeline będzie składał się z dwóch zadań – budowy środowiska oraz testów integracyjnych. Tworzymy plik konfiguracji .gitlab-ci.yml:

image: tiangolo/docker-with-compose:latest

services:
  - docker:dind

stages:
  - build
  - test

cache:
  key: "$CI_COMMIT_REF_SLUG"
  untracked: true
  policy: pull
  paths:
    - .cache/cypress/

buildApplication:
  stage: build
  only:
    - master
  timeout: 10m
  cache:
    key: "$CI_COMMIT_REF_SLUG"
    untracked: true
    policy: pull-push
    paths:
      - .cache/cypress/
  script:
      - docker-compose pull
      - docker-compose up --no-start
      - docker-compose start
      - docker exec -i -w "/var/www/html" cypress12.13.0 npm install cypress
      - docker exec -i -w "/var/www/html" cypress12.13.0 ./node_modules/.bin/cypress install
      - docker-compose stop

makeIntegrationTests:
  stage: test
  only:
    - master
  dependencies:
    - buildApplication
  artifacts:
      paths:
        - cypress/screenshots/
        - cypress/videos/
      expire_in: 1 hour
  timeout: 10m
  script:
    - docker-compose pull
    - docker-compose up --no-start
    - docker-compose start
    - docker exec -i -w "/var/www/html" cypress12.13.0 ./node_modules/.bin/cypress run
    - docker-compose stop

Następnie robimy push do GitLaba i gotowe :D. Dzięki powyższej konfiguracji zostanie zbudowane podczas wykonywania pipeline środowisko Dockerowe i uruchomione testy integracyjne (ale uwaga - tylko na branchu master, co jest ustawione pod kluczem only).

Jak możesz zauważyć, podczas pipeline wykonywane są te same komendy co podczas pracy na lokalnym środowisku. Rozdzieliłem komendy na dwa zadania, ale tak na prawdę bez tego także by zadziałało.

Pliki z powyższym projektem możesz pobrać stąd i sobie poćwiczyć na własną rękę, co gorąco polecam.