'Hello World' вам в облако

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Мир сходит с ума, заталкивая калькулятор для 2+2 в облака. Чем мы хуже? Давайте Hello World затолкаем в три микросервиса, напишем пару-тройку тестов, обеспечим пользователей документацией, нарисуем красивый пайплайн сборки и обеспечим деплой в условный облачный прод при успешном прохождении тестов. Итак, в данной статье будет показан пример того, как может быть построен процесс разработки продукта от спецификации до деплоя в прод. Инетересно? тогда прошу под кат


С чегоооооо начинается Роооо… ?


Нет не Родина, а продукт. Правильно, продукт начинается c идеи. Итак, идея такова:


  • нужен сервис, который отдаёт 'Hello World' по REST API
  • cлово 'Hello' отдаёт один микросервис, проектируемый, создаваемый и тестируемый командой_1
  • cлово 'World' отдаёт второй, который находится в ведении команды_2
  • команда_3 пишет интеграционный сервис для склеивания 'Hello' и 'World'

Toolset


  • OS (desktop) — Debian 9 Stretch
  • IDE — Intellij IDEA 2019.1
  • Git Repo — GitHub
  • CI — Concource 5.4.0
  • Maven Repo — Nexus
  • OpenJDK 11
  • Maven 3.6.0
  • Kubernetes 1.14 (1 master + 1 worker): calico network, nginx-ingress-controller

Важная заметка: статья не о красивом коде (codestyle, checkstyle, javadocs, SOLID и прочие умные слова) и вылизанных до идеала решениях (холиварить про идеальный Hello World можно бесконечно). Она о том, как собрать воедино код, спецификациии, пайплайн сборки и доставки всего собранного в прод, а вместо HelloWorld в реальности у вас может быть какой-нибудь высоконагруженный продукт с кучей сложных и крутых микросервисов, и описанный процесс можно применить к нему.

Из чего состоит сервис?


Сервис в виде конечного продукта должен содержать в себе:


  • спецификацию в виде yaml-документа стандарта OpenAPI и уметь отдавать её по запросу (GET /doc)
  • методы API в соответствии со спецификацией из первого пункта
  • README.md с примерами запуска и конфигурирования сервиса

Будем разбирать сервисы по порядку. Поехали!


'Hello' microservice


Specification


Спеки пишем в Swagger Editor'е и конвертируем им же в OpenAPI спеку. Swagger Editor запускается в докере одной командой, конвертация swagger-доки в openapi-доку делается нажатием одной кнопки в UI эдитора, которая шлёт запрос POST /api/convert на http://converter.swagger.io. Итоговая спецификация hello сервиса:


openapi: 3.0.1
info:
  title: Hello ;)
  description: Hello microservice
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/hello
tags:
  - name: hello
    description: Everything about saying 'Hello'
paths:
  /:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello
      summary: Get 'Hello' word
      operationId: getHelloWord
      responses:
        200:
          description: OK
  /doc:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello_doc
      summary: Get 'Hello' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

Implementation


Сервис с точки зрения кода, который надо писать, состоит из 3-х классов:


  • интерфейс с методами сервиса (названия методов указаны в спеке как operationId)
  • реализация интерфейса
  • vertx verticle для биндинга сервиса со спекой (методы api -> методы интерфейса из первого пункта) и для старта http-сервера

Структура файлов в src выглядит примерно так:


pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <properties>
        <main.verticle>io.bihero.hello.HelloVerticle</main.verticle>
        <vertx.version>3.8.1</vertx.version>
        <logback.version>1.2.3</logback.version>
        <junit-jupiter.version>5.3.1</junit-jupiter.version>
        <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version>
        <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version>
        <assertj-core.version>3.8.0</assertj-core.version>
        <allure.version>2.8.1</allure.version>
        <allure-maven.version>2.10.0</allure-maven.version>
        <aspectj.version>1.9.2</aspectj.version>
        <mockito.version>2.21.0</mockito.version>
        <rest-assured.version>3.0.0</rest-assured.version>
    </properties>

    <groupId>io.bihero</groupId>
    <artifactId>hello-microservice</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
```1.8</source>
                    <target>1.8</target>
                </configuration>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/main</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                    <execution>
                        <id>default-testCompile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/test</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${maven-surefire-plugin.version}</version>
                <configuration>
                    <properties>
                        <property>
                            <name>listener</name>
                            <value>io.qameta.allure.junit5.AllureJunit5</value>
                        </property>
                    </properties>
                    <includes>
                        <include>**/*Test*.java</include>
                    </includes>
                    <argLine>
                        -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true
                    </argLine>
                    <systemProperties>
                        <property>
                            <name>allure.results.directory</name>
                            <value>${project.basedir}/target/allure-results</value>
                        </property>
                        <property>
                            <name>junit.jupiter.extensions.autodetection.enabled</name>
                            <value>true</value>
                        </property>
                    </systemProperties>
                    <reportFormat>plain</reportFormat>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.aspectj</groupId>
                        <artifactId>aspectjweaver</artifactId>
                        <version>${aspectj.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.platform</groupId>
                        <artifactId>junit-platform-surefire-provider</artifactId>
                        <version>${junit-platform-surefire-provider.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.jupiter</groupId>
                        <artifactId>junit-jupiter-engine</artifactId>
                        <version>${junit-jupiter.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <version>${allure-maven.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-site-plugin</artifactId>
                <version>3.7.1</version>
                <dependencies>
                    <dependency>
                        <groupId>org.apache.maven.wagon</groupId>
                        <artifactId>wagon-webdav-jackrabbit</artifactId>
                        <version>2.8</version>
                    </dependency>
                </dependencies>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-project-info-reports-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <manifestEntries>
                                        <Main-Class>io.vertx.core.Launcher</Main-Class>
                                        <Main-Verticle>${main.verticle}</Main-Verticle>
                                    </manifestEntries>
                                </transformer>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                    <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource>
                                </transformer>
                            </transformers>
                            <artifactSet>
                            </artifactSet>
                            <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>**/version.txt</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
                <excludes>
                    <exclude>**/version.txt</exclude>
                </excludes>
            </resource>
        </resources>
    </build>
    <distributionManagement>
        <site>
            <id>reports</id>
            <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url>
        </site>
    </distributionManagement>
    <reporting>
        <excludeDefaults>true</excludeDefaults>
        <plugins>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <configuration>
                    <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory>
                    <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory>
                </configuration>
            </plugin>
        </plugins>
    </reporting>

    <dependencies>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-api-service</artifactId>
            <version>${vertx.version}</version>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-codegen</artifactId>
            <version>${vertx.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>

        <!-- test &ndash;&gt;-->
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-unit</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-junit5</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj-core.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.qameta.allure</groupId>
            <artifactId>allure-junit5</artifactId>
            <version>${allure.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-client</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

HelloService.java
package io.bihero.hello;

import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;
import io.vertx.ext.web.api.generator.WebApiServiceGen;

@WebApiServiceGen
public interface HelloService {

    static HelloService create(Vertx vertx) {
        return new DefaultHelloService(vertx);
    }

    void getHelloWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

    void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

}

DefaultHelloService.java
package io.bihero.hello;

import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;

public class DefaultHelloService implements HelloService {

    private final Vertx vertx;

    public DefaultHelloService(Vertx vertx) {
        this.vertx = vertx;
    }

    @Override
    public void getHelloWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        resultHandler.handle(Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer("Hello"))));
    }

    @Override
    public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        vertx.fileSystem().readFile("doc.yaml", buffResult ->
                resultHandler.handle(Future.succeededFuture(
                        OperationResponse.completedWithPlainText(buffResult.result()))
                ));
    }

}

HelloVerticle.java
package io.bihero.hello;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory;
import io.vertx.serviceproxy.ServiceBinder;

public class HelloVerticle extends AbstractVerticle {

    private HttpServer server;
    private MessageConsumer<JsonObject> consumer;

    @Override
    public void start(Promise<Void> promise) {
        startHelloService();
        startHttpServer().future().setHandler(promise);
    }

    /**
     * This method closes the http server and unregister all services loaded to Event Bus
     */
    @Override
    public void stop(){
        this.server.close();
        consumer.unregister();
    }

    private void startHelloService() {
        consumer = new ServiceBinder(vertx).setAddress("service.hello")
                .register(HelloService.class, HelloService.create(getVertx()));
    }

    /**
     * This method constructs the router factory, mounts services and handlers and starts the http server
     * with built router
     * @return
     */
    private Promise<Void> startHttpServer() {
        Promise<Void> promise = Promise.promise();
        OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> {
            if (openAPI3RouterFactoryAsyncResult.succeeded()) {
                OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result();

                // Mount services on event bus based on extensions
                routerFactory.mountServicesFromExtensions();

                // Generate the router
                Router router = routerFactory.getRouter();

                int port = config().getInteger("serverPort", 8080);
                String host = config().getString("serverHost", "localhost");

                server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host));
                server.requestHandler(router).listen(ar -> {
                    // Error starting the HttpServer
                    if (ar.succeeded()) promise.complete();
                    else promise.fail(ar.cause());
                });
            } else {
                // Something went wrong during router factory initialization
                promise.fail(openAPI3RouterFactoryAsyncResult.cause());
            }
        });
        return promise;
    }

}

В интерфейсе сервиса и его имплементации нет ничего необычного (за исключением аннотации @WebApiServiceGen, но про него можно почитать в документации), а вот код verticle-класса рассмотрим подробнее.


Интересны два метода, которые вызываются на старта вертикла:


  • startHelloService создает объект с имплеменатацией нашего сервиса и биндит его на адрес в event bus (вспомним параметр x-vertx-event-bus.address из спецификации выше)
  • startHttpServer создаёт router factory на основе спецификации сервиса, создаёт http-сервер и прицепляет созданный router к хэндлеру всех входящих http-запросов (если гурбо, то запрос GET / будет падать в event bus vertex'а с адресом service.hello (а туда мы забиндили реализацию сервиса io.bihero.hello.HelloService) и с именем метода сервиса getHelloWord)

Пора собрать джарник и пробовать запускать:


mvn clean package # собираем джарник
java -Dlogback.configurationFile=./src/conf/logback-console.xml -jar target/hello-microservice-fat.jar  -conf ./src/conf/config.json # запускаем сервис

В строке запуска интересны два параметра:


  • -Dlogback.configurationFile=./src/conf/logback-console.xml — путь до конфиг-файла для logback (в зависимостях проекта должны быть slf4j и logback как имплементация slf4j-api)
  • -conf ./src/conf/config.json — конфиг сервиса, там для нас важен порт, на котором будет открыт http REST API:
    {
    "type": "file",
    "format": "json",
    "scanPeriod": 5000,
    "config": {
    "path": "/home/slava/JavaProjects/hello-world-to-cloud/hellomicroservice/src/conf/config.json"
    },
    "serverPort": 8081,
    "serverHost": "0.0.0.0"
    }

Вывод maven'а нам особо не интересен, а вот как стартанул сервис, можно посмотреть (в настройках логгера для пакета io.netty выставлен level="INFO")


Как стартанул сервис
2019-10-03 20:52:45,159 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Loaded raw data: openapi: 3.0.1
info:
  title: Hello ;)
  description: Hello microservice
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/hello
tags:
  - name: hello
    description: Everything about saying 'Hello'
paths:
  /:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello
      summary: Get 'Hello' word
      operationId: getHelloWord
      responses:
        200:
          description: OK
  /doc:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello_doc
      summary: Get 'Hello' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}
2019-10-03 20:52:45,195 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Parsed rootNode: {"openapi":"3.0.1","info":{"title":"Hello ;)","description":"Hello microservice","version":"1.0.0"},"servers":[{"url":"https://demo1.bihero.io/api/hello"}],"tags":[{"name":"hello","description":"Everything about saying 'Hello'"}],"paths":{"/":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello"],"summary":"Get 'Hello' word","operationId":"getHelloWord","responses":{"200":{"description":"OK"}}}},"/doc":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello_doc"],"summary":"Get 'Hello' microservice documentation","operationId":"getDoc","responses":{"200":{"description":"OK"}}}}},"components":{}}
Oct 03, 2019 8:52:45 PM io.vertx.core.impl.launcher.commands.VertxIsolatedDeployer
INFO: Succeeded in deploying verticle

Ура! Сервис заработал, можно проверять:


curl http://127.0.0.1:8081/
Hello
curl -v http://127.0.0.1:8081/doc
openapi: 3.0.1
info:
  title: Hello ;)
  description: Hello microservice
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/hello
tags:
  - name: hello
    description: Everything about saying 'Hello'
paths:
  /:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello
      summary: Get 'Hello' word
      operationId: getHelloWord
      responses:
        200:
          description: OK
  /doc:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello_doc
      summary: Get 'Hello' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

Сервис отвечает словом Hello на запрос GET /, что соответствует спецификации, и умеет говорить о том, что он умеет делать, отдавая специфкацию по запросу GET /doc. Круто, идём в прод!


Что-то тут не так ...


Ранее я писал, что нам не особо важен вывод maven'а при сборке. Я наврал, вывод важен и очень. Нам нужно, чтобы maven запускал тесты и при падении тестов сборка падала. Сборка выше прошла, и это говорит о том, что либо тесты прошли, либо их нет. Тестов у нас, конечно же, нет, настала пора их написать (тут можно поспорить о методологиях, о том когда и как писать тесты, до или после имплементации, но мы вспомним про важную заметку вначала статьи и пойдём дальше — напишем парочку тестов).
Первый тест-класс является по своей природе юнит-тестом, проверяющим два конкретных метода нашего сервиса:


HelloServiceTest.java
package io.bihero.hello;

import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(VertxExtension.class)
public class HelloServiceTest {

    private HelloService helloService = HelloService.create(Vertx.vertx());

    @Test
    @DisplayName("Test 'getHelloWord' method returns 'Hello' word")
    public void testHelloMethod(VertxTestContext testContext) {
        helloService.getHelloWord(new OperationRequest(new JsonObject()), testContext.succeeding(it -> {
            assertThat(it.getStatusCode()).isEqualTo(200);
            assertThat(it.getPayload().toString()).isEqualTo("Hello");
            testContext.completeNow();
        }));
    }

    @Test
    @DisplayName("Test 'getDoc' method returns service documentation in OpenAPI format")
    public void testDocMethod(VertxTestContext testContext) {
        helloService.getDoc(new OperationRequest(new JsonObject()), testContext.succeeding(it -> {
            try {
                assertThat(it.getStatusCode()).isEqualTo(200);
                assertThat(it.getPayload().toString()).isEqualTo(IOUtils.toString(this.getClass()
                        .getResourceAsStream("../../../doc.yaml"), "UTF-8"));
                testContext.completeNow();
            } catch (IOException e) {
                testContext.failNow(e);
            }
        }));
    }

}

Второй тест — недоинтеграционный тест, проверяющий, что вертикл поднимается и отвечает на соответствующие http запросы ожидаемыми статусами и текстом:


HelloVerticleTest.java
package io.bihero.hello;

import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.codec.BodyCodec;
import io.vertx.junit5.Checkpoint;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;

@ExtendWith(VertxExtension.class)
public class HelloVerticleTest {

    @Test
    @DisplayName("Test that verticle is up and respond me by 'Hello' word and doc in OpenAPI format")
    public void testHelloVerticle(Vertx vertx, VertxTestContext testContext) {
        WebClient webClient = WebClient.create(vertx);

        Checkpoint deploymentCheckpoint = testContext.checkpoint();
        Checkpoint requestCheckpoint = testContext.checkpoint(2);

        HelloVerticle verticle = spy(new HelloVerticle());
        JsonObject config = new JsonObject().put("serverPort", 8081).put("serverHost", "0.0.0.0");
        doReturn(config).when(verticle).config();
        vertx.deployVerticle(verticle, testContext.succeeding(id -> {
            deploymentCheckpoint.flag();
            // test GET /
            webClient.get(8081, "localhost", "/")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        assertThat(resp.body()).isEqualTo("Hello");
                        assertThat(resp.statusCode()).isEqualTo(200);
                        requestCheckpoint.flag();
                    }));
            // test GET /doc
            webClient.get(8081, "localhost", "/doc")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        try {
                            assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass()
                                    .getResourceAsStream("../../../doc.yaml"), "UTF-8"));
                            assertThat(resp.statusCode()).isEqualTo(200);
                            requestCheckpoint.flag();
                        } catch (Exception e) {
                            requestCheckpoint.flag();
                            testContext.failNow(e);
                        }
                    }));
        }));
    }

}

Пора собирать сервис вместе с тестами:


mvn clean package

Нас очень интересует лог плагина surefire, выглядеть он будет примерно так (картинка кликабельна):



Здорово! Сервис собирается, тесты бегут и не падают (чуть позже поговорим о красоте того, как результаты тестов показывать начальству), пора задуматься о том, как мы будем его доставлять до пользователей (то есть до серверов). На дворе конец 2019-го, и, конечно же, бандлить приложение мы будем в виде docker-образа. Поехали!


Docker и все все все


Docker image для нашего первого сервиса будем собирать на основе adoptopenjdk/openjdk11. Добавим в образ наш собранный джарник со всеми необходимыми конфигами и пропишем в докерфайле команду для старта приложения в контейнере. Итоговый Dockerfile будет выглядеть так:


FROM adoptopenjdk/openjdk11:alpine-jre
COPY target/hello-microservice-fat.jar app.jar
COPY src/conf/config.json .
COPY src/conf/logback-console.xml .
COPY run.sh .
RUN chmod +x run.sh
CMD ["./run.sh"]

Скрипт run.sh выглядит так:


#!/bin/sh
java ${JVM_OPTS} -Dlogback.configurationFile=./logback-console.xml -jar app.jar -conf config.json

Переменная окружения JVM_OPTS нам на этом этапе пока не особо нужна, но чуть позже мы будем её активно менять и тюнить параметры виртуальной машины и наших сервисов. Пора собрать образ и запустить приложение в контейнере:


docker build -t="hellomicroservice" .
docker run -dit --name helloms hellomicroservice
# посмотрим в логи контейнера, что он там нам позапускал
docker logs -f helloms

# вывод docker logs
2019-10-05 14:55:46,059 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Loaded raw data: openapi: 3.0.1
info:
  title: Hello ;)
  description: Hello microservice
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/hello
tags:
  - name: hello
    description: Everything about saying 'Hello'
paths:
  /:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello
      summary: Get 'Hello' word
      operationId: getHelloWord
      responses:
        200:
          description: OK
  /doc:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello_doc
      summary: Get 'Hello' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}
2019-10-05 14:55:46,098 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Parsed rootNode: {"openapi":"3.0.1","info":{"title":"Hello ;)","description":"Hello microservice","version":"1.0.0"},"servers":[{"url":"https://demo1.bihero.io/api/hello"}],"tags":[{"name":"hello","description":"Everything about saying 'Hello'"}],"paths":{"/":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello"],"summary":"Get 'Hello' word","operationId":"getHelloWord","responses":{"200":{"description":"OK"}}}},"/doc":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello_doc"],"summary":"Get 'Hello' microservice documentation","operationId":"getDoc","responses":{"200":{"description":"OK"}}}}},"components":{}}
Oct 05, 2019 2:55:46 PM io.vertx.core.impl.launcher.commands.VertxIsolatedDeployer
INFO: Succeeded in deploying verticle

Достанем ip-адрес контейнера и проверим работу сервиса внутри контейнера:


docker inspect helloms | grep IPAddress
 "SecondaryIPAddresses": null,
            "IPAddress": "172.17.0.2",
                    "IPAddress": "172.17.0.2",

curl http://172.17.0.2:8081/ # тут ожидаем увидеть слово 'Hello' в ответе
curl http://172.17.0.2:8081/doc # тут ждём описание сервиса в формате OpenAPI

Итак, сервис запускается в контейнере. Но мы же не будем его руками вот так (docker run) запускать в production-окружении, для этого у нас есть прекрасный kubernetes. Чтобы запустить приложение в kubernetes, нам нужен шаблон, yml-файл, с описанием того, какие ресурсы (deployment, service, ingress, etc) мы будем запускать и на основе какого контейнера. Но, прежде чем мы начнём описывать темплейт для запуска приложения в k8s, пушнем ка собранный ранее образ на докерхаб:


docker tag hello bihero/hello
docker push bihero/hello

Пишем темплейт для запуска приложения в kubernetes (в рамках статьи мы не настоящие сварщики и не претендуем на "кошерность" темплейта):


apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    io.bihero.hello.service: bihero-hello
  name: bihero-hello
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  template:
    metadata:
      labels:
        io.bihero.hello.service: bihero-hello
    spec:
      containers:
        - image: bihero/hello:${HELLO_SERVICE_IMAGE_VERSION}
          name: bihero-hello
          ports:
            - containerPort: 8081
          imagePullPolicy: Always
          resources: {}
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    io.bihero.hello.service: bihero-hello
  name: bihero-hello
spec:
  ports:
    - name: "8081"
      port: 8081
      targetPort: 8081
  selector:
    io.bihero.hello.service: bihero-hello
status:
  loadBalancer: {}
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: bihero-hello
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/secure-backends: "false"
    nginx.ingress.kubernetes.io/ssl-passthrough: "false"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    kubernetes.io/tls-acme: "true"
  namespace: default
spec:
  tls:
    - hosts:
        - ${ID_DOMAIN}
      secretName: bihero
  rules:
    - host: ${ID_DOMAIN}
      http:
        paths:
          - path: /api/hello(/|$)(.*)
            backend:
              serviceName: bihero-hello
              servicePort: 8081

Кратко о том, что мы видим в шаблоне:


  • Deployment: тут описываем, из какого образа деплоимся и из какого количества инстансов создаём репликасет для нашего сервиса. Также важно обратить внимание на metadata.labels — по ним будем привязывать Service к Deployment
  • Service: привязываем сервис к деплойменту/репликасету. По сути сервис в k8s — это то, к чему уже можно слать http-запроcы внутри кластера (и да — обращаем внимание на selector)
  • Ingress: ингресс нужен для того, чтобы сервис выставить наружу, во внешний мир. Все запросы начинающиеся с /api/hello будем заворачивать на наш hello-сервис (https://domain.com/api/hello -> http://bihero-hello.service.internal.domain.local:8081/)

Также в шаблоне фигурируют два переменных окружения:


  • ${HELLO_SERVICE_IMAGE_VERSION} — тег docker-образа с сервисом, из которого будем собирать наш первый deployment
  • ${ID_DOMAIN} — домен, на котором развернём наши сервисы

Важное про https
В тестовом кластере уже имеется secret с именем bihero, созданный на основе wildcard-сертификата от LetsEncrypt. Если кратко, то команды выглядит так
kubectl create secret tls bihero --key keys/privkey.pem --cert keys/fullchain.pem


где privkey.pem и fullchain.pem — файлы, генерируемые letsencrypt'ом
Подробнее про создание secret'а для tls в k8s можно почитать пройдя по ссылке

Настала пора пробовать деплоиться в k8s :) Поехали!


export HELLO_SERVICE_IMAGE_VERSION=latest
export ID_DOMAIN=demo1.bihero.io
cat k8s.yaml | envsubst | kubectl apply -f -

В stdout должны увидеть вот это:


deployment.extensions/bihero-hello created
service/bihero-hello created
ingress.extensions/bihero-hello created

Ну что ж, проверим, что там нам kubernetes наворотил:


kubectl get po # да, вместо pod можно писать po, k8s вас поймёт


Как и полагается — 3 пода


Посмотрим подробности одного пода


kubectl describe po bihero-hello-5b4759d55b-bf4qc


Как там сервис поживает?


kubectl describe service bihero-hello


А ингресс?


kubectl describe ing bihero-hello


Здорово! Сервис бегает в k8s и так просится, чтобы его проверили парочкой запросов, согласно спеке.


curl https://demo1.bihero.io/api/hello
Hello

curl https://demo1.bihero.io/api/hello/doc
openapi: 3.0.1
info:
  title: Hello ;)
  description: Hello microservice
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/hello
tags:
  - name: hello
    description: Everything about saying 'Hello'
paths:
  /:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello
      summary: Get 'Hello' word
      operationId: getHelloWord
      responses:
        200:
          description: OK
  /doc:
    x-vertx-event-bus:
      address: service.hello
      timeout: 1000c
    get:
      tags:
        - hello_doc
      summary: Get 'Hello' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

А — Автоматизация


Фух… Дошли до самого вкусного и волнительного. Было сделано немало работы и каждый шаг сопровождался ручным запуском каких-то тулов, на каждом этапе своих. Пора задуматься о том, чтобы все шаги запускались автоматически и триггерили своим заверешением следующий шаг паплайна, а на финише был ровный и бесшовный апгрейд нашего сервиса в k8s кластере. Сказано, сделано!


Перед тем как начать пилить автоматизацию, давайте разложим всё по полочкам и нарисуем схему того, как будет бежать пайплайн на CI-сервере.


Что было сделано руками?


  1. Написали код, написали тесты к коду, прошли всё чеки (кодревью и прочее), закоммитили в git-репозиторий
  2. Запуск сборки (mvn), прогон тестов (surefire, allure) — на выходе получаем fat-jar с сервисом
  3. Сборка docker-образа (docker build)
  4. Push docker-образа на докерхаб (или корпоративный приватный docker registry) (docker push)
  5. Деплой сервиса в k8s (kubectl apply)

Что будет делать CI-сервер ?


Да всё то же самое, что и мы ручками делали (кроме написания кода и тестов), только по пути будет уведомлять нас о своих действиях и отчёты деплоить в нужные места. Алгоритм выглядит примерно так:

Опишем пайплайн по шагам:


  1. Пайплайн будет триггерить джобу сборки по коммиту в определенную ветку проекта, пусть это будет ветка master (напрямую в master мы, конечно же, не коммитим, туда коммиты попадают при merge'ах после merge request'ов и тщательного ревью)
  2. Уведомление команды разработчиков о том, что началась сборка сервиса из вышеуказанной dev-ветки (telegram-bot)
  3. Прогон тестов
  4. Проверяем, как поршли тесты
  5. Тесты прошли успешно — деплоим результат прогона тестов в maven repository (конкретно в нашем кейсе используется nexus blob store)
  6. Собираем fat-jar (mvn package, но с маленьким хаком, чтобы не компилить по новой код — мы это уже сделали на этапе прогона тестов)
  7. Собираем docker image из собранного джарника и необходимых конфигов. Тут стоить отметить, что данный шаг делает не только сборку образа, но и пушит его в репозиторий, на который ссылается наш образ как ресурс пайплайна (о ресурсах скоро узнаете). Пуш образа в registry триггерит деплой новой версии сервиса в k8s кластер
  8. Деплой новой версии сервиса в k8s кластер
  9. Уведомление команды сервиса о том, что сборка прошла и новая версия сервиса ушла в требуемый k8s кластер. Уведомление содержит ссылку на джобу с логами сборки и ссылку на результат прогона тестов
  10. Если на 4-м шаге мы понимаем, что тесты не прошли, то деплоим результаты прогона тестов в maven repository
  11. И уведомляем команду о том, что сборка новой версии сервиса упала со всеми необходимыми ссылками в уведомлении

Concourse CI

Вышеописанный пайплайн мы будем писать под CI-сервер Concourse. Особенности Concourse CI:


  • минималистичный UI (всё управление составом пайплайна через yaml-конфиги, которые могут лежать рядом с кодом, и через консольный тул под название fly): это и плюс и минус одновременно — очень удобно и гибко для разработчиков, которые всегда работают с консолью (mvn, docker, fly, kubectl), но неудобно для менеджерского состава, который хочет потыкать в кнопочки (но для них мы будем отчёты писать в tg-группу со ссылками на все необходимые для них ресурсы)
  • каждый степ сборки проходит в docker container'е, что даёт гибкость в настройке окружения для каждого степа (не надо на каждой worker-ноде шаманить с настройками, если что-то environment-зависимое захотели поменять в одном из шагов пайплайна) — собрал образ один раз, степ пайплайна подтянет его в момент старта, и дело в шляпе.

Итак, встречайте, пайплайн сбрки:


pipeline.yaml
resource_types:
  - name: telegram
    type: docker-image
    source:
      repository: vtutrinov/concourse-telegram-resource
      tag: latest
  - name: kubernetes
    type: docker-image
    source:
      repository: zlabjp/kubernetes-resource
      tag: 1.16
  - name: metadata
    type: docker-image
    source:
      repository: olhtbr/metadata-resource
      tag: 2.0.1
resources:
  - name: metadata
    type: metadata
  - name: sources
    type: git
    source:
      branch: master
      uri: git@github.com:bihero-io/hello-microservice.git
      private_key: ((deployer-private-key))
  - name: docker-image
    type: docker-image
    source:
      repository: bihero/hello
      username: ((docker-registry-user))
      password: ((docker-registry-password))
  - name: telegram
    type: telegram
    source:
      bot_token: ((telegram-ci-bot-token))
      chat_id: ((telegram-group-to-report-build))
      ci_url: ((ci_url))
      command: "/build_hello_ms"
  - name: kubernetes-demo
    type: kubernetes
    source:
      server: https://178.63.194.241:6443
      namespace: default
      kubeconfig: ((kubeconfig-demo))
jobs:
  - name: build-hello-microservice
    serial: true
    public: true
    plan:
      - in_parallel:
          - get: sources
            trigger: true
          - get: telegram
            trigger: true
          - put: metadata
      - put: telegram
        params:
          status: Build In Progress
      - task: unit-tests
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: sources
          outputs:
            - name: tested-workspace
          run:
            path: /bin/sh
            args:
              - -c
              - |
                output_dir=tested-workspace
                cp -R ./sources/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" clean test
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - task: tests-report
              config:
                platform: linux
                image_resource:
                  type: docker-image
                  source:
                    repository: ((docker-registry-uri))/bih/maven
                    tag: 3-jdk-11
                    username: ((docker-private-registry-user))
                    password: ((docker-private-registry-password))
                inputs:
                  - name: tested-workspace
                outputs:
                  - name: message
                run:
                  path: /bin/sh
                  args:
                    - -c
                    - |
                      output_dir=tested-workspace
                      mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy
                      version=$(cat $output_dir/target/classes/version.txt)
                      cat >message/msg <<EOL
                      <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a>
                      EOL
                caches:
                  - path: ~/.m2/
            - put: telegram
              params:
                status: Build Failed (unit-tests)
                message_file: message/msg
      - task: tests-report
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
          outputs:
            - name: message
            - name: tested-workspace
          run:
            path: /bin/sh
            args:
              - -c
              - |
                work_dir=tested-workspace
                mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy
                version=$(cat $work_dir/target/classes/version.txt)
                cat >message/msg <<EOL
                <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a>
                EOL
          caches:
            - path: ~/.m2/
      - task: package
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
            - name: metadata
          outputs:
            - name: app-packaged-workspace
            - name: metadata
          run:
            path: /bin/sh
            args:
              - -c
              - |
                output_dir=app-packaged-workspace
                cp -R ./tested-workspace/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests
                env
                tag="-"$(cat metadata/build_name)
                echo $tag >> ${output_dir}/target/classes/version.txt
                cat ${output_dir}/target/classes/version.txt > metadata/version
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - put: telegram
              params:
                status: Build Failed (package)
      - put: docker-image
        params:
          build: app-packaged-workspace
          tag_file: app-packaged-workspace/target/classes/version.txt
          tag_as_latest: true
        get_params:
          skip_download: true
      - task: make-k8s-app-template
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: bhgedigital/envsubst
          inputs:
            - name: sources
            - name: metadata
          outputs:
            - name: k8s
          run:
            path: /bin/sh
            args:
              - -c
              - |
                export DOMAIN=demo1.bihero.io
                export HELLO_SERVICE_IMAGE_VERSION=$(cat metadata/version)
                cat sources/k8s.yaml | envsubst > k8s/hello_app_template.yaml
                cat k8s/hello_app_template.yaml
      - put: kubernetes-demo
        params:
          kubectl: apply -f k8s/hello_app_template.yaml
      - put: telegram
        params:
          status: Build Success
          message_file: message/msg

Рассмотрим кратко содержимое пайплайна:


  1. Секция resource_types нужна для объявления кастомных типов ресурсов, с которыми мы хотим работать, собирая наш проект. В нашем кейсе это три типа (имена типов можно задавать любые, сама суть типа закладывается в docker-образе, которым описывается тип): telegram для отправки уведомлений в tg-группу и для триггера джобы по сборке по определённой команде, kubernetes для деплоя новой версии сервиса в k8s-кластер и metadata для обеспечения данных по билду (номер билда, дата сборки и т.д.) в тасках пайплайна
  2. Секция resources нужна для объявления ресурсов, с которыми мы будем работать в процессе билда. Это то самое место в пайплайне, где описываются репозитории с исходниками, docker-registry для деплоя собираемых docker-образов и другие ресурсы, необходимые для выполнения степов сборки проекта. Каждый ресурс может быть использован на каждом степе пайплайна как input-ресурс в соответствующем блоке, описывающем таск пайплайна
  3. Секция jobs описывает набор джоб, которые нужно выполнить для сборки проекта. У нас это одна джоба с набором тасков и put-инструкций для деплоя результатов сборки и уведомлений в tg-группу. Иструкциями — get объявляем входные ресурсы для билда (например, git-репозиторий), — put — выходные ресурсы (docker image) или ресурсы, генерируемые на первых шагах сборки проекта и используемые на последующих (metadata). Каждый task в джобе — команды внутри docker-контейнера на основе docker-image'а, конфигурируемого параметром image_resource таски
  4. Строки вида ((parameter-name)) — ссылки на параметры в отдельном файле, обычно в этом файле лежат секреты, явки пароли к ресурсам и прочие параметры, универсальные для всех имеющихся пайплайнов (например ссылка до docker-registry).

Деплой пайплайна с файлом параметров выглядит так:


fly -t bih sp -p hello-microservice -c pipeline.yaml -l credentials.yaml
# -t - target name
# sp - alias to set-pipeline
# -p - pipeline name
# -c - pipeline config file
# -l - file with parameters and credentials

Файл credentials.yaml может выглядеть так:


docker-registry-user: <dockerhub-user>
docker-registry-password: <dockerhub-password>
docker-registry-uri: <private-docker-registry-url>
docker-private-registry-user: <private-docker-registry-user>
docker-private-registry-password: <private-docker-registry-passwordl>
telegram-ci-bot-token: <telegram-bot-token>
telegram-group-to-report-build: <telegram-group-id>
ci_url: <ci-server-url>
deployer-private-key: |
  -----BEGIN OPENSSH PRIVATE KEY-----
  github-deploy-key
  -----END OPENSSH PRIVATE KEY-----
kubeconfig-demo: |
  apiVersion: v1
  clusters:
  - cluster:
      certificate-authority-data: <kube-cert-data>
      server: <kube-api-server-url>
    name: kubernetes
  contexts:
  - context:
      cluster: kubernetes
      user: kubernetes-admin
    name: kubernetes-admin@kubernetes
  current-context: kubernetes-admin@kubernetes
  kind: Config
  preferences: {}
  users:
  - name: kubernetes-admin
    user:
      client-certificate-data: <kube-client-cert-data>
      client-key-data: <kube-client-key-data>

Пишла пора запустить наш первый билд. Сделать мы это можем несколькими способами:


  1. Залогиниться на CI-сервере, выбрать необходимый нам пайплайн и джобу и нажать на кнопку с плюсиком:
  2. Сделать всё то же самое (что и в пункте 1), но только используя конcольную утилиту fly, которую можно скачать с того же CI-сервера:
    fly -t bih tj -j hello-microservice/build-hello-microservice -w
    # tj - alias for 'trigger-job'
    # -j - job (<piprlinr-name>/<job-name-in-pipeline>)
    # -w - watch
  3. Отправить сообщение /build_hello_ms в телеграм-группу, на которую указывает telegram-group-to-report-build в файле credentials.yaml
  4. Отправить коммит в master-ветку в гит (помним, что мы не про идеальную разработку сейчас говорим, а про процесс в целом: коммитить в master — это плохо, — но в обучающих целях можно ;) )

В процессе билда (в случае успешного его окончания) мы получим два уведомления в телеграм-группу:


  1. Уведомление о начале работы с джобой:
  2. Уведомление об успешном завершении сборки:

    Давайте посмотрим, как сборка выглядит в UI CI-сервера:

Ура! Сборка прошла, докер-образ собран, задеплоен, приложение в k8s обновлено и отчёты отправлены. Пора проверять задеплоенное:


  1. Образ на docker-hub'е
  2. Смотрим на список подов
  3. И смотрим на версию образа, из которого развёрнут контейнер в одном из подов из 2-го пункта
  4. Делаем запрос и смотрим на ответ:

curl https://demo1.bihero.io/api/hello -v
curl https://demo1.bihero.io/api/hello -v                                                                                                                             5350  14:59:04  
*   Trying 178.63.194.243...
* TCP_NODELAY set
* Connected to demo1.bihero.io (178.63.194.243) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use h2
* Server certificate:
*  subject: CN=*.bihero.io
*  start date: Nov  7 13:59:46 2019 GMT
*  expire date: Feb  5 13:59:46 2020 GMT
*  subjectAltName: host "demo1.bihero.io" matched cert's "*.bihero.io"
*  issuer: C=US; O=Let's Encrypt; CN=Let's Encrypt Authority X3
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55778f779520)
> GET /api/hello HTTP/1.1
> Host: demo1.bihero.io
> User-Agent: curl/7.52.1
> Accept: */*
> 
* Connection state changed (MAX_CONCURRENT_STREAMS updated)!
< HTTP/2 200 
< server: nginx/1.15.8
< date: Sun, 01 Dec 2019 11:59:06 GMT
< content-type: text/plain
< content-length: 5
< strict-transport-security: max-age=15724800; includeSubDomains
< 
* Curl_http_done: called premature == 0
* Connection #0 to host demo1.bihero.io left intact
Hello

Много всего было сделано, но давайте на забывать, для чего мы тут собрались. Продукт же пилим, и ещё целых два микросервиса не написаны. Дальше мы не будем подробно разжёвывать содержимое каждого оставшего сервиса, только лишь исходники и пайплайн сборки в спойлерах (разве что только для интеграционного сервиса замутим интеграционных тестов с testcontainers). А в конце будут выводы и внушительный TODO-лист (куда же без бэклога). Поехали!


'World' microservice


Service specification
openapi: 3.0.1
info:
  title: World ;)
  description: "'World' word microservice"
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/world
tags:
  - name: world
    description: Everything about 'World' word
paths:
  /:
    x-vertx-event-bus:
      address: service.world
      timeout: 1000
    get:
      tags:
        - world
      summary: Get 'World' word
      operationId: getWorldWord
      responses:
        200:
          description: OK
          content: {}
  /doc:
    x-vertx-event-bus:
      address: service.world
      timeout: 1000c
    get:
      tags:
        - world
      summary: Get 'World' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <properties>
        <main.verticle>io.bihero.world.WorldVerticle</main.verticle>
        <vertx.version>3.8.1</vertx.version>
        <logback.version>1.2.3</logback.version>
        <junit-jupiter.version>5.3.1</junit-jupiter.version>
        <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version>
        <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version>
        <assertj-core.version>3.8.0</assertj-core.version>
        <allure.version>2.8.1</allure.version>
        <allure-maven.version>2.10.0</allure-maven.version>
        <aspectj.version>1.9.2</aspectj.version>
        <mockito.version>2.21.0</mockito.version>
        <rest-assured.version>3.0.0</rest-assured.version>
    </properties>

    <groupId>io.bihero</groupId>
    <artifactId>world-microservice</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
```11</source>
                    <target>11</target>
                </configuration>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/main</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                    <execution>
                        <id>default-testCompile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/test</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${maven-surefire-plugin.version}</version>
                <configuration>
                    <properties>
                        <property>
                            <name>listener</name>
                            <value>io.qameta.allure.junit5.AllureJunit5</value>
                        </property>
                    </properties>
                    <includes>
                        <include>**/*Test*.java</include>
                    </includes>
                    <argLine>
                        -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true
                    </argLine>
                    <systemProperties>
                        <property>
                            <name>allure.results.directory</name>
                            <value>${project.basedir}/target/allure-results</value>
                        </property>
                        <property>
                            <name>junit.jupiter.extensions.autodetection.enabled</name>
                            <value>true</value>
                        </property>
                    </systemProperties>
                    <reportFormat>plain</reportFormat>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.aspectj</groupId>
                        <artifactId>aspectjweaver</artifactId>
                        <version>${aspectj.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.platform</groupId>
                        <artifactId>junit-platform-surefire-provider</artifactId>
                        <version>${junit-platform-surefire-provider.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.jupiter</groupId>
                        <artifactId>junit-jupiter-engine</artifactId>
                        <version>${junit-jupiter.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <version>${allure-maven.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-site-plugin</artifactId>
                <version>3.7.1</version>
                <dependencies>
                    <dependency>
                        <groupId>org.apache.maven.wagon</groupId>
                        <artifactId>wagon-webdav-jackrabbit</artifactId>
                        <version>2.8</version>
                    </dependency>
                </dependencies>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-project-info-reports-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <manifestEntries>
                                        <Main-Class>io.vertx.core.Launcher</Main-Class>
                                        <Main-Verticle>${main.verticle}</Main-Verticle>
                                    </manifestEntries>
                                </transformer>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                    <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource>
                                </transformer>
                            </transformers>
                            <artifactSet>
                            </artifactSet>
                            <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>**/version.txt</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
                <excludes>
                    <exclude>**/version.txt</exclude>
                </excludes>
            </resource>
        </resources>
    </build>
    <distributionManagement>
        <site>
            <id>reports</id>
            <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url>
        </site>
    </distributionManagement>
    <reporting>
        <excludeDefaults>true</excludeDefaults>
        <plugins>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <configuration>
                    <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory>
                    <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory>
                </configuration>
            </plugin>
        </plugins>
    </reporting>

    <dependencies>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-api-service</artifactId>
            <version>${vertx.version}</version>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-codegen</artifactId>
            <version>${vertx.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>

        <!-- test &ndash;&gt;-->
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-unit</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-junit5</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj-core.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.qameta.allure</groupId>
            <artifactId>allure-junit5</artifactId>
            <version>${allure.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-client</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>

WorldService.java
package io.bihero.world;

import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;
import io.vertx.ext.web.api.generator.WebApiServiceGen;

@WebApiServiceGen
public interface WorldService {

    static WorldService create(Vertx vertx) {
        return new DefaultWorldService(vertx);
    }

    void getWorldWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

    void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

}

DefaultWorldService.java
package io.bihero.world;

import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;

public class DefaultWorldService implements WorldService {

    private final Vertx vertx;

    public DefaultWorldService(Vertx vertx) {
        this.vertx = vertx;
    }

    public void getWorldWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        resultHandler.handle(Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer("World"))));
    }

    @Override
    public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        vertx.fileSystem().readFile("doc.yaml", buffResult ->
                resultHandler.handle(Future.succeededFuture(
                        OperationResponse.completedWithPlainText(buffResult.result()))
                ));
    }

}

WorldVerticle.java
package io.bihero.world;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory;
import io.vertx.serviceproxy.ServiceBinder;

public class WorldVerticle extends AbstractVerticle {

    HttpServer server;
    MessageConsumer<JsonObject> consumer;

    public void startWorldService() {
        consumer = new ServiceBinder(vertx).setAddress("service.world")
                .register(WorldService.class, WorldService.create(getVertx()));
    }

    /**
     * This method constructs the router factory, mounts services and handlers and starts the http server
     * with built router
     * @return
     */
    private Promise<Void> startHttpServer() {
        Promise<Void> promise = Promise.promise();
        OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> {
            if (openAPI3RouterFactoryAsyncResult.succeeded()) {
                OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result();

                // Mount services on event bus based on extensions
                routerFactory.mountServicesFromExtensions();

                // Generate the router
                Router router = routerFactory.getRouter();

                int port = config().getInteger("serverPort", 8080);
                String host = config().getString("serverHost", "localhost");

                server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host));
                server.requestHandler(router).listen(ar -> {
                    // Error starting the HttpServer
                    if (ar.succeeded()) promise.complete();
                    else promise.fail(ar.cause());
                });
            } else {
                // Something went wrong during router factory initialization
                promise.fail(openAPI3RouterFactoryAsyncResult.cause());
            }
        });
        return promise;
    }

    @Override
    public void start(Promise<Void> promise) {
        startWorldService();
        startHttpServer().future().setHandler(promise);
    }

    /**
     * This method closes the http server and unregister all services loaded to Event Bus
     */
    @Override
    public void stop(){
        this.server.close();
        consumer.unregister();
    }

}

WorldServiceTest.java
package io.bihero.world;

import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(VertxExtension.class)
public class WorldServiceTest {

    private WorldService worldService = WorldService.create(Vertx.vertx());

    @Test
    @DisplayName("Test 'getWorldWord' method returns 'World' word")
    public void testHelloMethod(VertxTestContext testContext) {
        worldService.getWorldWord(new OperationRequest(new JsonObject()), testContext.succeeding(it -> {
            assertThat(it.getStatusCode()).isEqualTo(200);
            assertThat(it.getPayload().toString()).isEqualTo("World");
            testContext.completeNow();
        }));
    }

    @Test
    @DisplayName("Test 'getDoc' method returns service documentation in OpenAPI format")
    public void testDocMethod(VertxTestContext testContext) {
        worldService.getDoc(new OperationRequest(new JsonObject()), testContext.succeeding(it -> {
            try {
                assertThat(it.getStatusCode()).isEqualTo(200);
                assertThat(it.getPayload().toString()).isEqualTo(IOUtils.toString(this.getClass()
                        .getResourceAsStream("../../../doc.yaml"), "UTF-8"));
                testContext.completeNow();
            } catch (IOException e) {
                testContext.failNow(e);
            }
        }));
    }

}

WorldVerticleTest.java
package io.bihero.world;

import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.codec.BodyCodec;
import io.vertx.junit5.Checkpoint;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;

@ExtendWith(VertxExtension.class)
public class WorldVerticleTest {

    @Test
    @DisplayName("Test that verticle is up and respond me by 'World' word and doc in OpenAPI format")
    public void testHelloVerticle(Vertx vertx, VertxTestContext testContext) {
        WebClient webClient = WebClient.create(vertx);

        Checkpoint deploymentCheckpoint = testContext.checkpoint();
        Checkpoint requestCheckpoint = testContext.checkpoint(2);

        WorldVerticle verticle = spy(new WorldVerticle());
        JsonObject config = new JsonObject().put("serverPort", 8082).put("serverHost", "0.0.0.0");
        doReturn(config).when(verticle).config();
        vertx.deployVerticle(verticle, testContext.succeeding(id -> {
            deploymentCheckpoint.flag();
            // test GET /
            webClient.get(8082, "localhost", "/")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        assertThat(resp.body()).isEqualTo("World");
                        assertThat(resp.statusCode()).isEqualTo(200);
                        requestCheckpoint.flag();
                    }));
            // test GET /doc
            webClient.get(8082, "localhost", "/doc")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        try {
                            assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass()
                                    .getResourceAsStream("../../../doc.yaml"), "UTF-8"));
                            assertThat(resp.statusCode()).isEqualTo(200);
                            requestCheckpoint.flag();
                        } catch (Exception e) {
                            requestCheckpoint.flag();
                            testContext.failNow(e);
                        }
                    }));
        }));
    }

}

Dockerfile
FROM adoptopenjdk/openjdk11:alpine-jre
COPY target/world-microservice-fat.jar app.jar
COPY src/conf/config.json .
COPY src/conf/logback-console.xml .
COPY run.sh .
RUN chmod +x run.sh
CMD ["./run.sh"]

run.sh
#!/bin/sh
java ${JVM_OPTS} -Dlogback.configurationFile=./logback-console.xml -jar app.jar -conf config.json

pipeline.yaml
resource_types:
  - name: telegram
    type: docker-image
    source:
      repository: vtutrinov/concourse-telegram-resource
      tag: latest
  - name: kubernetes
    type: docker-image
    source:
      repository: zlabjp/kubernetes-resource
      tag: 1.16
  - name: metadata
    type: docker-image
    source:
      repository: olhtbr/metadata-resource
      tag: 2.0.1
resources:
  - name: metadata
    type: metadata
  - name: sources
    type: git
    source:
      branch: master
      uri: git@github.com:bihero-io/worldmicroservice.git
      private_key: ((deployer-private-key))
  - name: docker-image
    type: docker-image
    source:
      repository: bihero/world
      username: ((docker-registry-user))
      password: ((docker-registry-password))
  - name: telegram
    type: telegram
    source:
      bot_token: ((telegram-ci-bot-token))
      chat_id: ((telegram-group-to-report-build))
      ci_url: ((ci_url))
      command: "/build_world_ms"
  - name: kubernetes-demo
    type: kubernetes
    source:
      server: ((k8s-api-server))
      namespace: default
      kubeconfig: ((kubeconfig-demo))
jobs:
  - name: build-world-microservice
    serial: true
    public: true
    plan:
      - in_parallel:
          - get: sources
            trigger: true
          - get: telegram
            trigger: true
          - put: metadata
      - put: telegram
        params:
          status: Build In Progress
      - task: unit-tests
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven-dind
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: sources
          outputs:
            - name: tested-workspace
          run:
            path: /bin/sh
            args:
              - -c
              - |
                output_dir=tested-workspace
                cp -R ./sources/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" clean test
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - task: tests-report
              config:
                platform: linux
                image_resource:
                  type: docker-image
                  source:
                    repository: ((docker-registry-uri))/bih/maven-dind
                    tag: 3-jdk-11
                    username: ((docker-private-registry-user))
                    password: ((docker-private-registry-password))
                inputs:
                  - name: tested-workspace
                outputs:
                  - name: message
                run:
                  path: /bin/sh
                  args:
                    - -c
                    - |
                      output_dir=tested-workspace
                      mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy
                      version=$(cat $output_dir/target/classes/version.txt)
                      cat >message/msg <<EOL
                      <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a>
                      EOL
                caches:
                  - path: ~/.m2/
            - put: telegram
              params:
                status: Build Failed (unit-tests)
                message_file: message/msg
      - task: tests-report
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven-dind
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
          outputs:
            - name: message
            - name: tested-workspace
          run:
            path: /bin/sh
            args:
              - -c
              - |
                work_dir=tested-workspace
                mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy
                version=$(cat $work_dir/target/classes/version.txt)
                cat >message/msg <<EOL
                <a href="https://nexus.dev.techedge.pro:8443/repository/reports/world-microservice/${version}/allure/">Allure report</a>
                EOL
          caches:
            - path: ~/.m2/
      - task: package
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven-dind
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
            - name: metadata
          outputs:
            - name: app-packaged-workspace
            - name: metadata
          run:
            path: /bin/sh
            args:
              - -c
              - |
                output_dir=app-packaged-workspace
                cp -R ./tested-workspace/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests
                tag="-"$(cat metadata/build_name)
                echo $tag >> ${output_dir}/target/classes/version.txt
                cat ${output_dir}/target/classes/version.txt > metadata/version
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - put: telegram
              params:
                status: Build Failed (package)
      - put: docker-image
        params:
          build: app-packaged-workspace
          tag_file: app-packaged-workspace/target/classes/version.txt
          tag_as_latest: true
        get_params:
          skip_download: true
      - task: make-k8s-app-template
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: bhgedigital/envsubst
          inputs:
            - name: sources
            - name: metadata
          outputs:
            - name: k8s
          run:
            path: /bin/sh
            args:
              - -c
              - |
                export DOMAIN=demo1.bihero.io
                export WORLD_SERVICE_IMAGE_VERSION=$(cat metadata/version)
                cat sources/k8s.yaml | envsubst > k8s/world_app_template.yaml
                cat k8s/world_app_template.yaml
      - put: kubernetes-demo
        params:
          kubectl: apply -f k8s/world_app_template.yaml
      - put: telegram
        params:
          status: Build Success
          message_file: message/msg

k8s app template
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    io.bihero.hello.service: bihero-world
  name: bihero-world
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  template:
    metadata:
      labels:
        io.bihero.hello.service: bihero-world
    spec:
      containers:
        - image: bihero/world:${WORLD_SERVICE_IMAGE_VERSION}
          name: bihero-world
          ports:
            - containerPort: 8082
          imagePullPolicy: Always
          resources: {}
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    io.bihero.hello.service: bihero-world
  name: bihero-world
spec:
  ports:
    - name: "8082"
      port: 8082
      targetPort: 8082
  selector:
    io.bihero.hello.service: bihero-world
status:
  loadBalancer: {}
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: bihero-world
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/secure-backends: "false"
    nginx.ingress.kubernetes.io/ssl-passthrough: "false"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    kubernetes.io/tls-acme: "true"
  namespace: default
spec:
  tls:
    - hosts:
        - ${DOMAIN}
      secretName: bihero
  rules:
    - host: ${DOMAIN}
      http:
        paths:
          - path: /api/world(/|$)(.*)
            backend:
              serviceName: bihero-world
              servicePort: 8082

'HelloWorld' microservice


Этот орешек оказался крепче, чем казалось изначально. Ну да ладно, мы и его раскололи. Основные сложности возникли при запуске интеграционных тестов с testcontainers, но обо всё по порядку.


Service specification
openapi: 3.0.1
info:
  title: Hello World ;)
  description: "Hello World microservice. Aggregate 'Hello World' by hellomicroservice and worldmicroservice"
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/helloworld
tags:
  - name: helloworld
    description: Everything about 'Hello World'
paths:
  /:
    x-vertx-event-bus:
      address: service.helloworld
      timeout: 1000
    get:
      tags:
        - helloworld
      summary: Aggregate 'Hello World'
      operationId: getHelloWorld
      responses:
        200:
          description: OK
          content: {}
  /doc:
    x-vertx-event-bus:
      address: service.helloworld
      timeout: 1000c
    get:
      tags:
        - world
      summary: Get 'Hello World' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <properties>
        <main.verticle>io.bihero.helloworld.HelloWorldVerticle</main.verticle>
        <vertx.version>3.8.1</vertx.version>
        <logback.version>1.2.3</logback.version>
        <junit-jupiter.version>5.3.1</junit-jupiter.version>
        <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version>
        <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version>
        <assertj-core.version>3.8.0</assertj-core.version>
        <allure.version>2.8.1</allure.version>
        <allure-maven.version>2.10.0</allure-maven.version>
        <aspectj.version>1.9.2</aspectj.version>
        <mockito.version>2.21.0</mockito.version>
        <rest-assured.version>3.0.0</rest-assured.version>
        <testcontainers.version>1.12.3</testcontainers.version>
    </properties>

    <groupId>io.bihero</groupId>
    <artifactId>hello-world-microservice</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
```11</source>
                    <target>11</target>
                </configuration>
                <executions>
                    <execution>
                        <id>default-compile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/main</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                    <execution>
                        <id>default-testCompile</id>
                        <configuration>
                            <annotationProcessors>
                                <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor>
                            </annotationProcessors>
                            <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory>
                            <compilerArgs>
                                <arg>-Acodegen.output=${project.basedir}/src/test</arg>
                            </compilerArgs>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>${maven-surefire-plugin.version}</version>
                <configuration>
                    <properties>
                        <property>
                            <name>listener</name>
                            <value>io.qameta.allure.junit5.AllureJunit5</value>
                        </property>
                    </properties>
                    <includes>
                        <include>**/*Test.java</include>
                    </includes>
                    <argLine>
                        -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true
                    </argLine>
                    <systemProperties>
                        <property>
                            <name>allure.results.directory</name>
                            <value>${project.basedir}/target/allure-results</value>
                        </property>
                        <property>
                            <name>junit.jupiter.extensions.autodetection.enabled</name>
                            <value>true</value>
                        </property>
                    </systemProperties>
                    <reportFormat>plain</reportFormat>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.aspectj</groupId>
                        <artifactId>aspectjweaver</artifactId>
                        <version>${aspectj.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.platform</groupId>
                        <artifactId>junit-platform-surefire-provider</artifactId>
                        <version>${junit-platform-surefire-provider.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.junit.jupiter</groupId>
                        <artifactId>junit-jupiter-engine</artifactId>
                        <version>${junit-jupiter.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <version>${allure-maven.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-site-plugin</artifactId>
                <version>3.7.1</version>
                <dependencies>
                    <dependency>
                        <groupId>org.apache.maven.wagon</groupId>
                        <artifactId>wagon-webdav-jackrabbit</artifactId>
                        <version>2.8</version>
                    </dependency>
                </dependencies>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-project-info-reports-plugin</artifactId>
                <version>3.0.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.3</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <manifestEntries>
                                        <Main-Class>io.vertx.core.Launcher</Main-Class>
                                        <Main-Verticle>${main.verticle}</Main-Verticle>
                                    </manifestEntries>
                                </transformer>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                    <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource>
                                </transformer>
                            </transformers>
                            <artifactSet>
                            </artifactSet>
                            <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
                <includes>
                    <include>**/version.txt</include>
                </includes>
            </resource>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>false</filtering>
                <excludes>
                    <exclude>**/version.txt</exclude>
                </excludes>
            </resource>
        </resources>
    </build>
    <distributionManagement>
        <site>
            <id>reports</id>
            <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url>
        </site>
    </distributionManagement>
    <reporting>
        <excludeDefaults>true</excludeDefaults>
        <plugins>
            <plugin>
                <groupId>io.qameta.allure</groupId>
                <artifactId>allure-maven</artifactId>
                <configuration>
                    <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory>
                    <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory>
                </configuration>
            </plugin>
        </plugins>
    </reporting>

    <dependencies>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-api-service</artifactId>
            <version>${vertx.version}</version>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-web-client</artifactId>
            <version>${vertx.version}</version>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-codegen</artifactId>
            <version>${vertx.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>

        <!-- test &ndash;&gt;-->
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-unit</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.vertx</groupId>
            <artifactId>vertx-junit5</artifactId>
            <version>${vertx.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit-jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj-core.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.qameta.allure</groupId>
            <artifactId>allure-junit5</artifactId>
            <version>${allure.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
        </dependency>
    </dependencies>

</project>

HelloWorldService.java
package io.bihero.helloworld;

import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;
import io.vertx.ext.web.api.generator.WebApiServiceGen;

@WebApiServiceGen
public interface HelloWorldService {

    static HelloWorldService create(Vertx vertx, JsonObject config) {
        return new DefaultHelloWorldService(vertx, config);
    }

    void getHelloWorld(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

    void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler);

}

DefaultHelloWorldService.java
package io.bihero.helloworld;

import io.vertx.core.*;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.api.OperationRequest;
import io.vertx.ext.web.api.OperationResponse;
import io.vertx.ext.web.client.WebClient;

public class DefaultHelloWorldService implements HelloWorldService {

    private final Vertx vertx;

    private final JsonObject config;

    private final WebClient webClient;

    public DefaultHelloWorldService(Vertx vertx, JsonObject config) {
        this.vertx = vertx;
        this.config = config;
        this.webClient = WebClient.create(this.vertx);
    }

    @Override
    public void getHelloWorld(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        getHelloWord().compose(this::getHelloWorld).setHandler(v ->
                resultHandler.handle(
                        Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer(v.result())))
                ));
    }

    @Override
    public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) {
        vertx.fileSystem().readFile("doc.yaml", buffResult ->
                resultHandler.handle(Future.succeededFuture(
                        OperationResponse.completedWithPlainText(buffResult.result()))
                ));
    }

    private Future<String> getHelloWord() {
        Future<String> future = Future.future();
        webClient.get(config.getInteger("hello-service-port"), config.getString("hello-service-host"), "/").send(ar ->
                future.handle(Future.succeededFuture(ar.result().bodyAsString())));
        return future;
    }

    private Future<String> getHelloWorld(String helloWord) {
        Future<String> future = Future.future();
        webClient.get(config.getInteger("world-service-port"), config.getString("world-service-host"), "/").send(ar ->
                future.handle(Future.succeededFuture(helloWord + " " + ar.result().bodyAsString())));
        return future;
    }

}

HelloWorldVerticle.java
package io.bihero.helloworld;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory;
import io.vertx.serviceproxy.ServiceBinder;

public class HelloWorldVerticle extends AbstractVerticle {

    HttpServer server;
    MessageConsumer<JsonObject> consumer;

    public void startWorldService() {
        consumer = new ServiceBinder(vertx).setAddress("service.helloworld")
                .register(HelloWorldService.class, HelloWorldService.create(vertx, config()));
    }

    /**
     * This method constructs the router factory, mounts services and handlers and starts the http server
     * with built router
     * @return
     */
    private Promise<Void> startHttpServer() {
        Promise<Void> promise = Promise.promise();
        OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> {
            if (openAPI3RouterFactoryAsyncResult.succeeded()) {
                OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result();

                // Mount services on event bus based on extensions
                routerFactory.mountServicesFromExtensions();

                // Generate the router
                Router router = routerFactory.getRouter();

                int port = config().getInteger("serverPort", 8080);
                String host = config().getString("serverHost", "localhost");

                server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host));
                server.requestHandler(router).listen(ar -> {
                    // Error starting the HttpServer
                    if (ar.succeeded()) promise.complete();
                    else promise.fail(ar.cause());
                });
            } else {
                // Something went wrong during router factory initialization
                promise.fail(openAPI3RouterFactoryAsyncResult.cause());
            }
        });
        return promise;
    }

    @Override
    public void start(Promise<Void> promise) {
        startWorldService();
        startHttpServer().future().setHandler(promise);
    }

    /**
     * This method closes the http server and unregister all services loaded to Event Bus
     */
    @Override
    public void stop(){
        this.server.close();
        consumer.unregister();
    }

}

HelloWorldServiceTest.java
package io.bihero.helloworld;

import io.vertx.core.DeploymentOptions;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.codec.BodyCodec;
import io.vertx.junit5.Checkpoint;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.spy;

@Testcontainers
@ExtendWith(VertxExtension.class)
public class HelloWorldServiceTest {

    @Container
    private static final GenericContainer helloServiceContainer = new GenericContainer("bihero/hello")
            .withExposedPorts(8081);

    @Container
    private static final GenericContainer worldServiceContainer = new GenericContainer("bihero/world")
            .withExposedPorts(8082);

    @Test
    @DisplayName("Test 'helloworld' microservice respond by 'Hello World' string and doc in OpenAPI format")
    public void testHelloWorld(Vertx vertx, VertxTestContext testContext) {
        WebClient webClient = WebClient.create(vertx);

        Checkpoint deploymentCheckpoint = testContext.checkpoint();
        Checkpoint requestCheckpoint = testContext.checkpoint(2);

        HelloWorldVerticle verticle = spy(new HelloWorldVerticle());
        JsonObject config = new JsonObject().put("serverPort", 8083)
                                            .put("serverHost", "0.0.0.0")
                                            .put("hello-service-host", helloServiceContainer.getContainerIpAddress())
                                            .put("world-service-host", worldServiceContainer.getContainerIpAddress())
                                            .put("hello-service-port", helloServiceContainer.getMappedPort(8081))
                                            .put("world-service-port", worldServiceContainer.getMappedPort(8082));
        DeploymentOptions deploymentOptions = new DeploymentOptions().setConfig(config);
        vertx.deployVerticle(verticle, deploymentOptions, testContext.succeeding(id -> {
            deploymentCheckpoint.flag();
            // test GET /
            webClient.get(8083, "localhost", "/")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        assertThat(resp.body()).isEqualTo("Hello World");
                        assertThat(resp.statusCode()).isEqualTo(200);
                        requestCheckpoint.flag();
                    }));
            // test GET /doc
            webClient.get(8083, "localhost", "/doc")
                    .as(BodyCodec.string())
                    .send(testContext.succeeding(resp -> {
                        try {
                            assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass()
                                    .getResourceAsStream("../../../doc.yaml"), "UTF-8"));
                            assertThat(resp.statusCode()).isEqualTo(200);
                            requestCheckpoint.flag();
                        } catch (Exception e) {
                            requestCheckpoint.flag();
                            testContext.failNow(e);
                        }
                    }));
        }));
    }

}

Dockerfile для интеграционного сервиса немножечко отличается от двух сервисов выше — конфиг для сервиса мы кладём не в / как обычно, а в /usr/local, чтобы иметь возможность переопределять его ConfigMap'ом при запуске сервиса в k8s


Dockerfile

FROM adoptopenjdk/openjdk11:alpine-jre
COPY target/hello-world-microservice-fat.jar app.jar
COPY src/conf/config.json /usr/local/config.json
COPY src/conf/logback-console.xml.
COPY run.sh.
RUN chmod +x run.sh
CMD ["./run.sh"]


Итак, подошли к пайпалйну сборки и тут стоит пояснить, как вообще CI крутится и как там таски запускаются. Concource в той конфигурации, на основе которой писалась эта статья, имеет несколько worker-нод и всё они запущены docker-compose'ом (рядом ещё крутятся ui-нода и postgresql). Таски в джобах — это тоже отдельно стартующие docker-контейнеры, то есть мы уже имеем docker в docker'е. А ещё мы очень хотим интегарционные тесты запускать с помощью testcontainers (в нашем кейсе сервисы hello и world запускаем при помощи этого крутого тула). Чувствуете чем пахент? Правилько: докер в докере в докере! И для этого нам нужен модный образ с docker'ом, maven'ом и 11-ой джавой на борту. Встречаем, Dockerfile:


FROM alpine:3.7

ENV DOCKER_CHANNEL=stable \
    DOCKER_VERSION=17.12.1-ce \
    DOCKER_COMPOSE_VERSION=1.19.0 \
    DOCKER_SQUASH=0.2.0
# Install Docker, Docker Compose, Docker Squash
RUN apk --update --no-cache add \
        bash \
        curl \
        device-mapper \
        py-pip \
        iptables \
        util-linux \
        ca-certificates \
        maven \
        openjdk11 --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \
        && \
    apk upgrade && \
    curl -fL "https://download.docker.com/linux/static/${DOCKER_CHANNEL}/x86_64/docker-${DOCKER_VERSION}.tgz" | tar zx && \
    mv /docker/* /bin/ && chmod +x /bin/docker* && \
    pip install docker-compose==${DOCKER_COMPOSE_VERSION} && \
    curl -fL "https://github.com/jwilder/docker-squash/releases/download/v${DOCKER_SQUASH}/docker-squash-linux-amd64-v${DOCKER_SQUASH}.tar.gz" | tar zx && \
    mv /docker-squash* /bin/ && chmod +x /bin/docker-squash* && \
    rm -rf /var/cache/apk/* && \
    rm -rf /root/.cache

COPY repository /root/.m2/repository # тут мы кладём в образ вендор-зависимости, чтобы не тянуть и при каждом билде с централа
COPY settings.xml /root/.m2/settings.xml # конфиг для maven'а с кредами к приватному репозиторию
COPY entrypoint.sh /bin/entrypoint.sh # волшебный баш-скрипт, который даёт нам возможность стартовать докер в таске
ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk/
ENTRYPOINT ["entrypoint.sh"]

В пайплайне сборки на шаг запуска тестов будем заходить через entrypoint.sh, который обеспечит нам запуск докера перед запуском самих тестов:


entrypoint.sh
#!/usr/bin/env bash

# Inspired by concourse/docker-image-resource:
# https://github.com/concourse/docker-image-resource/blob/master/assets/common.sh

set -o errexit -o pipefail -o nounset

# Waits DOCKERD_TIMEOUT seconds for startup (default: 60)
DOCKERD_TIMEOUT="${DOCKERD_TIMEOUT:-60}"
# Accepts optional DOCKER_OPTS (default: --data-root /scratch/docker)
DOCKER_OPTS="${DOCKER_OPTS:-}"

# Constants
DOCKERD_PID_FILE="/tmp/docker.pid"
DOCKERD_LOG_FILE="/tmp/docker.log"

sanitize_cgroups() {
  local cgroup="/sys/fs/cgroup"

  mkdir -p "${cgroup}"
  if ! mountpoint -q "${cgroup}"; then
    if ! mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup "${cgroup}"; then
      echo >&2 "Could not make a tmpfs mount. Did you use --privileged?"
      exit 1
    fi
  fi
  mount -o remount,rw "${cgroup}"

  # Skip AppArmor
  # See: https://github.com/moby/moby/commit/de191e86321f7d3136ff42ff75826b8107399497
  export container=docker

  # Mount /sys/kernel/security
  if [[ -d /sys/kernel/security ]] && ! mountpoint -q /sys/kernel/security; then
    if ! mount -t securityfs none /sys/kernel/security; then
      echo >&2 "Could not mount /sys/kernel/security."
      echo >&2 "AppArmor detection and --privileged mode might break."
    fi
  fi

  sed -e 1d /proc/cgroups | while read sys hierarchy num enabled; do
    if [[ "${enabled}" != "1" ]]; then
      # subsystem disabled; skip
      continue
    fi

    grouping="$(cat /proc/self/cgroup | cut -d: -f2 | grep "\\<${sys}\\>")"
    if [[ -z "${grouping}" ]]; then
      # subsystem not mounted anywhere; mount it on its own
      grouping="${sys}"
    fi

    mountpoint="${cgroup}/${grouping}"

    mkdir -p "${mountpoint}"

    # clear out existing mount to make sure new one is read-write
    if mountpoint -q "${mountpoint}"; then
      umount "${mountpoint}"
    fi

    mount -n -t cgroup -o "${grouping}" cgroup "${mountpoint}"

    if [[ "${grouping}" != "${sys}" ]]; then
      if [[ -L "${cgroup}/${sys}" ]]; then
        rm "${cgroup}/${sys}"
      fi

      ln -s "${mountpoint}" "${cgroup}/${sys}"
    fi
  done

  # Initialize systemd cgroup if host isn't using systemd.
  # Workaround for https://github.com/docker/for-linux/issues/219
  if ! [[ -d /sys/fs/cgroup/systemd ]]; then
    mkdir "${cgroup}/systemd"
    mount -t cgroup -o none,name=systemd cgroup "${cgroup}/systemd"
  fi
}

# Setup container environment and start docker daemon in the background.
start_docker() {
  echo >&2 "Setting up Docker environment..."
  mkdir -p /var/log
  mkdir -p /var/run

  sanitize_cgroups

  # check for /proc/sys being mounted readonly, as systemd does
  if grep '/proc/sys\s\+\w\+\s\+ro,' /proc/mounts >/dev/null; then
    mount -o remount,rw /proc/sys
  fi

  local docker_opts="${DOCKER_OPTS:-}"

  # Pass through `--garden-mtu` from gardian container
  if [[ "${docker_opts}" != *'--mtu'* ]]; then
    local mtu="$(cat /sys/class/net/$(ip route get 8.8.8.8|awk '{ print $5 }')/mtu)"
    docker_opts+=" --mtu ${mtu}"
  fi

  # Use Concourse's scratch volume to bypass the graph filesystem by default
  if [[ "${docker_opts}" != *'--data-root'* ]] && [[ "${docker_opts}" != *'--graph'* ]]; then
    docker_opts+=' --data-root /scratch/docker'
  fi

  rm -f "${DOCKERD_PID_FILE}"
  touch "${DOCKERD_LOG_FILE}"

  echo >&2 "Starting Docker..."
  dockerd ${docker_opts} &>"${DOCKERD_LOG_FILE}" &
  echo "$!" > "${DOCKERD_PID_FILE}"
}

# Wait for docker daemon to be healthy
# Timeout after DOCKERD_TIMEOUT seconds
await_docker() {
  local timeout="${DOCKERD_TIMEOUT}"
  echo >&2 "Waiting ${timeout} seconds for Docker to be available..."
  local start=${SECONDS}
  timeout=$(( timeout + start ))
  until docker info &>/dev/null; do
    if (( SECONDS >= timeout )); then
      echo >&2 'Timed out trying to connect to docker daemon.'
      if [[ -f "${DOCKERD_LOG_FILE}" ]]; then
        echo >&2 '---DOCKERD LOGS---'
        cat >&2 "${DOCKERD_LOG_FILE}"
      fi
      exit 1
    fi
    if [[ -f "${DOCKERD_PID_FILE}" ]] && ! kill -0 $(cat "${DOCKERD_PID_FILE}"); then
      echo >&2 'Docker daemon failed to start.'
      if [[ -f "${DOCKERD_LOG_FILE}" ]]; then
        echo >&2 '---DOCKERD LOGS---'
        cat >&2 "${DOCKERD_LOG_FILE}"
      fi
      exit 1
    fi
    sleep 1
  done
  local duration=$(( SECONDS - start ))
  echo >&2 "Docker available after ${duration} seconds."
}

# Gracefully stop Docker daemon.
stop_docker() {
  if ! [[ -f "${DOCKERD_PID_FILE}" ]]; then
    return 0
  fi
  local docker_pid="$(cat ${DOCKERD_PID_FILE})"
  if [[ -z "${docker_pid}" ]]; then
    return 0
  fi
  echo >&2 "Terminating Docker daemon."
  kill -TERM ${docker_pid}
  local start=${SECONDS}
  echo >&2 "Waiting for Docker daemon to exit..."
  wait ${docker_pid}
  local duration=$(( SECONDS - start ))
  echo >&2 "Docker exited after ${duration} seconds."
}

start_docker
trap stop_docker EXIT
await_docker

# do not exec, because exec disables traps
if [[ "$#" != "0" ]]; then
  "$@"
else
  bash --login
fi

pipeline.yaml
resource_types:
  - name: telegram
    type: docker-image
    source:
      repository: vtutrinov/concourse-telegram-resource
      tag: latest
  - name: kubernetes
    type: docker-image
    source:
      repository: zlabjp/kubernetes-resource
      tag: 1.16
  - name: metadata
    type: docker-image
    source:
      repository: olhtbr/metadata-resource
      tag: 2.0.1
resources:
  - name: metadata
    type: metadata
  - name: sources
    type: git
    source:
      branch: master
      uri: git@github.com:bihero-io/helloworldmicroservice.git
      private_key: ((deployer-private-key))
  - name: docker-image
    type: docker-image
    source:
      repository: bihero/helloworld
      username: ((docker-registry-user))
      password: ((docker-registry-password))
  - name: telegram
    type: telegram
    source:
      bot_token: ((telegram-ci-bot-token))
      chat_id: ((telegram-group-to-report-build))
      ci_url: ((ci_url))
      command: "/build_helloworld_ms"
  - name: kubernetes-demo
    type: kubernetes
    source:
      server: ((k8s-api-server))
      namespace: default
      kubeconfig: ((kubeconfig-demo))
jobs:
  - name: build-helloworld-microservice
    serial: true
    public: true
    plan:
      - in_parallel:
          - get: sources
            trigger: true
          - get: telegram
            trigger: true
          - put: metadata
      - put: telegram
        params:
          status: Build In Progress
      - task: tests
        privileged: true
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/dind  # тут мы указываем образ, собранный на основе докерфайла, где мы ставим докер, maven и 11-ю джаву
              tag: latest
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: sources
          outputs:
            - name: tested-workspace
          run:
            path: entrypoint.sh
            args:
              - bash
              - -ceux
              - |
                # вот тут мы уже имеем запущенный докер внутри таски и можем запускать тесты с testcontainers
                output_dir=tested-workspace
                cp -R ./sources/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" clean test
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - task: tests-report
              config:
                platform: linux
                image_resource:
                  type: docker-image
                  source:
                    repository: ((docker-registry-uri))/bih/maven-dind
                    tag: 3-jdk-11
                    username: ((docker-private-registry-user))
                    password: ((docker-private-registry-password))
                inputs:
                  - name: tested-workspace
                outputs:
                  - name: message
                run:
                  path: /bin/sh
                  args:
                    - -c
                    - |
                      output_dir=tested-workspace
                      mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy
                      version=$(cat $output_dir/target/classes/version.txt)
                      cat >message/msg <<EOL
                      <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-world-microservice/${version}/allure/">Allure report</a>
                      EOL
                caches:
                  - path: ~/.m2/
            - put: telegram
              params:
                status: Build Failed (unit-tests)
                message_file: message/msg
      - task: tests-report
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven-dind
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
          outputs:
            - name: message
            - name: tested-workspace
          run:
            path: /bin/sh
            args:
              - -c
              - |
                work_dir=tested-workspace
                mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy
                version=$(cat $work_dir/target/classes/version.txt)
                cat >message/msg <<EOL
                <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-world-microservice/${version}/allure/">Allure report</a>
                EOL
          caches:
            - path: ~/.m2/
      - task: package
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: ((docker-registry-uri))/bih/maven-dind
              tag: 3-jdk-11
              username: ((docker-private-registry-user))
              password: ((docker-private-registry-password))
          inputs:
            - name: tested-workspace
            - name: metadata
          outputs:
            - name: app-packaged-workspace
            - name: metadata
          run:
            path: /bin/sh
            args:
              - -c
              - |
                output_dir=app-packaged-workspace
                cp -R ./tested-workspace/* "${output_dir}/"
                mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests
                tag="-"$(cat metadata/build_name)
                echo $tag >> ${output_dir}/target/classes/version.txt
                cat ${output_dir}/target/classes/version.txt > metadata/version
          caches:
            - path: ~/.m2/
        on_failure:
          do:
            - put: telegram
              params:
                status: Build Failed (package)
      - put: docker-image
        params:
          build: app-packaged-workspace
          tag_file: app-packaged-workspace/target/classes/version.txt
          tag_as_latest: true
        get_params:
          skip_download: true
      - task: make-k8s-app-template
        config:
          platform: linux
          image_resource:
            type: docker-image
            source:
              repository: bhgedigital/envsubst
          inputs:
            - name: sources
            - name: metadata
          outputs:
            - name: k8s
          run:
            path: /bin/sh
            args:
              - -c
              - |
                export DOMAIN=demo1.bihero.io
                export HELLO_WORLD_SERVICE_IMAGE_VERSION=$(cat metadata/version)
                cat sources/k8s.yaml | envsubst > k8s/helloworld_app_template.yaml
                cat k8s/helloworld_app_template.yaml
      - put: kubernetes-demo
        params:
          kubectl: apply -f k8s/helloworld_app_template.yaml
      - put: telegram
        params:
          status: Build Success
          message_file: message/msg

Подошли к деплою интеграционного сервиса в k8s. И тут возникает необходимость знать адреса сервисов hello и world внутри k8s-кластера. По дефолту все сервисы внутри k8s имет адреса типа <service-name>..default.svc.cluster.local, вот ими и воспользуемся, не будем же мы ходить до сервисов, которые крутятся рядом через внешний API. Сказано, сделано :


конечная версия темплейта для деплоя интеграционного сервиса в k8s
apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-world-config
data:
  config.json: |
    {
      "type": "file",
      "format": "json",
      "scanPeriod": 5000,
      "config": {
        "path": "/config.json"
      },
      "serverPort": 8083,
      "serverHost": "0.0.0.0",
      "hello-service-host": "bihero-hello.default.svc.cluster.local",
      "hello-service-port": 8081,
      "world-service-host": "bihero-world.default.svc.cluster.local",
      "world-service-port": 8082
    }
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    io.bihero.hello.service: bihero-helloworld
  name: bihero-helloworld
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
  template:
    metadata:
      labels:
        io.bihero.hello.service: bihero-helloworld
    spec:
      containers:
        - image: bihero/helloworld:${HELLO_WORLD_SERVICE_IMAGE_VERSION}
          name: bihero-helloworld
          ports:
            - containerPort: 8083
          imagePullPolicy: Always
          resources: {}
          volumeMounts: # в /usr/local заменяем дефолтный конфиг на занчение из ConfigMap'а сверху
            - mountPath: /usr/local/
              name: hello-world-config
      restartPolicy: Always
      volumes:
        - name: hello-world-config
          configMap:
            name: hello-world-config
---
apiVersion: v1
kind: Service
metadata:
  labels:
    io.bihero.hello.service: bihero-helloworld
  name: bihero-helloworld
spec:
  ports:
    - name: "8083"
      port: 8083
      targetPort: 8083
  selector:
    io.bihero.hello.service: bihero-helloworld
status:
  loadBalancer: {}
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: bihero-helloworld
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/secure-backends: "false"
    nginx.ingress.kubernetes.io/ssl-passthrough: "false"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    kubernetes.io/tls-acme: "true"
  namespace: default
spec:
  tls:
    - hosts:
        - ${DOMAIN}
      secretName: bihero
  rules:
    - host: ${DOMAIN}
      http:
        paths:
          - path: /api/helloworld(/|$)(.*)
            backend:
              serviceName: bihero-helloworld
              servicePort: 8083

Ну, и как обычно — коммитимся, пушимся, билдимся, деплоимся, тестируемся:


curl https://demo1.bihero.io/api/helloworld
Hello World

curl https://demo1.bihero.io/api/helloworld/doc                 
openapi: 3.0.1
info:
  title: Hello World ;)
  description: "Hello World microservice. Aggregate 'Hello World' by hellomicroservice and worldmicroservice"
  version: 1.0.0
servers:
  - url: https://demo1.bihero.io/api/helloworld
tags:
  - name: helloworld
    description: Everything about 'Hello World'
paths:
  /:
    x-vertx-event-bus:
      address: service.helloworld
      timeout: 1000
    get:
      tags:
        - helloworld
      summary: Aggregate 'Hello World'
      operationId: getHelloWorld
      responses:
        200:
          description: OK
          content: {}
  /doc:
    x-vertx-event-bus:
      address: service.helloworld
      timeout: 1000c
    get:
      tags:
        - world
      summary: Get 'Hello World' microservice documentation
      operationId: getDoc
      responses:
        200:
          description: OK
components: {}

Ура! Работает! Можем релизиться и в прод, но для полноты картины…


TODO'шечки (backlog)


  1. Много бойлерплейта в помниках — унести всё общее в parent pom и собирать все сервисы продукта на основе него.
  2. Сейчас собранные в пайплайнах docker-образы тегаются и сразу пушатся в docker-hub, включая снэпшотные образы — сделать так, чтобы туда пушились только релизные образы, всё снэпшотное в private registry.
  3. Сделать "нормальное" версионирование (maven-release-plugin? concource semver-resource ?), возможно хранить версии в отдельном репозитории, и триггерить релизные сборки при изменении в репозитории, отвечающем за хранение версий продукта.
  4. Решить проблему рассинхронизации API между сервисами в условиях НЕ-монорепозитория в гите (когда это три сервиса типа HelloWorld, то проблемы нет, но когда будет несколько десятков сложных сервисов, то наступит АД). Если кто-то занет железобетонные способы, то пишите в комментариях — буду рад узнать и обсудить :)

Список в голове был большой, но забылся по пути, если вспомнится, то дополню, ну или дополняйте в комментариях :)


И исходники


https://github.com/bihero-io/hello-microservice
https://github.com/bihero-io/worldmicroservice
https://github.com/bihero-io/helloworldmicroservice

Источник: https://habr.com/ru/post/465149/


Интересные статьи

Интересные статьи

Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...
В 2019 году люди знакомятся с брендом, выбирают и, что самое главное, ПОКУПАЮТ через интернет. Сегодня практически у любого бизнеса есть свой сайт — от личных блогов, зарабатывающих на рекламе, до инт...
В Челябинске проходят митапы системных администраторов Sysadminka, и на последнем из них я делал доклад о нашем решении для работы приложений на 1С-Битрикс в Kubernetes. Битрикс, Kubernetes, Сep...
Тема статьи навеяна результатами наблюдений за методикой создания шаблонов различными разработчиками, чьи проекты попадали мне на поддержку. Порой разобраться в, казалось бы, такой простой сущности ка...
Согласно многочисленным исследованиям поведения пользователей на сайте, порядка 25% посетителей покидают ресурс, если страница грузится более 4 секунд.