Browse Source

Added all docker stuff to the project

Alejandro Celaya 4 years ago
parent
commit
6433a67d52
11 changed files with 476 additions and 31 deletions
  1. 23 0
      .dockerignore
  2. 0 6
      .travis.yml
  3. 53 0
      Dockerfile
  4. 1 1
      README.md
  5. 1 3
      build.sh
  6. 1 1
      composer.json
  7. 0 20
      data/travis/trigger_docker_build.sh
  8. 204 0
      docker/README.md
  9. 175 0
      docker/config/shlink_in_docker.local.php
  10. 17 0
      docker/docker-entrypoint.sh
  11. 1 0
      phpcs.xml

+ 23 - 0
.dockerignore

@@ -0,0 +1,23 @@
+config/autoload/*local*
+data/infra
+data/cache/*
+data/log/*
+data/locks/*
+data/proxies/*
+data/migrations_template.txt
+data/GeoLite2-City.*
+data/database.sqlite
+data/shlink-tests.db
+**/.gitignore
+CHANGELOG.md
+composer.lock
+vendor
+docs
+indocker
+docker-*
+php*
+infection.json
+phpstan.neon
+**/test*
+build*
+.github

+ 0 - 6
.travis.yml

@@ -54,9 +54,3 @@ deploy:
     on:
       tags: true
       php: 7.2
-  - provider: script
-    script: bash data/travis/trigger_docker_build.sh
-    skip_cleanup: true
-    on:
-      tags: true
-      php: 7.2

+ 53 - 0
Dockerfile

@@ -0,0 +1,53 @@
+FROM php:7.3.8-cli-alpine3.10
+LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
+
+ENV SWOOLE_VERSION 4.3.3
+ENV COMPOSER_VERSION 1.9.0
+
+WORKDIR /etc/shlink
+
+RUN \
+    # Install mysl and calendar
+    docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
+    # Install sqlite
+    apk add --no-cache sqlite-libs sqlite-dev && \
+    docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
+    # Install postgres
+    apk add --no-cache postgresql-dev && \
+    docker-php-ext-install -j"$(nproc)" pdo_pgsql && \
+    # [Deprecated] Install intl
+    apk add --no-cache icu-dev && \
+    docker-php-ext-install -j"$(nproc)" intl && \
+    # Install zip and gd
+    apk add --no-cache libzip-dev zlib-dev libpng-dev && \
+    docker-php-ext-install -j"$(nproc)" zip gd
+
+# Install swoole
+# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
+RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
+    pecl install swoole-${SWOOLE_VERSION} && \
+    docker-php-ext-enable swoole && \
+    apk del .phpize-deps
+
+# Install shlink
+COPY . .
+RUN rm -rf ./docker && \
+    wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
+    php composer.phar install --no-dev --optimize-autoloader --no-progress --no-interaction && \
+    php composer.phar clear-cache && \
+    rm composer.phar
+
+# Add shlink to the path to ease running it after container is created
+RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
+
+# Expose swoole port
+EXPOSE 8080
+
+# Expose params config dir, since the user is expected to provide custom config from there
+VOLUME /etc/shlink/config/params
+
+# Copy config specific for the image
+COPY docker/docker-entrypoint.sh docker-entrypoint.sh
+COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
+
+ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

+ 1 - 1
README.md

@@ -225,7 +225,7 @@ Right now, it does not import cached info (like website previews), but it will.
 
 ## Using a docker image
 
-Starting with version 1.15.0, an official docker image is provided. You can find the docs on how to use it [here](https://hub.docker.com/r/shlinkio/shlink/).
+Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md).
 
 The idea is that you can just generate a container using the image and provide custom config via env vars.
 

+ 1 - 3
build.sh

@@ -19,7 +19,6 @@ mkdir -p "${builtcontent}"
 rsync -av * "${builtcontent}" \
     --exclude=bin/test \
     --exclude=data/infra \
-    --exclude=data/travis \
     --exclude=data/cache/* \
     --exclude=data/log/* \
     --exclude=data/locks/* \
@@ -33,8 +32,7 @@ rsync -av * "${builtcontent}" \
     --exclude=composer.lock \
     --exclude=vendor \
     --exclude=docs \
-    --exclude=indocker \
-    --exclude=docker* \
+    --exclude=*docker* \
     --exclude=php* \
     --exclude=infection.json \
     --exclude=phpstan.neon \

+ 1 - 1
composer.json

@@ -102,7 +102,7 @@
 
         "cs": "phpcs",
         "cs:fix": "phpcbf",
-        "stan": "phpstan analyse module/*/src/ module/*/config config --level=5 -c phpstan.neon",
+        "stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=5 -c phpstan.neon",
 
         "test": [
             "@test:unit",

+ 0 - 20
data/travis/trigger_docker_build.sh

@@ -1,20 +0,0 @@
-#!/bin/bash
-set -e
-
-# Get latest commit in master, in plain text
-LATEST_MASTER_COMMIT=$(curl -H "Accept: application/vnd.github.sha" -X GET https://api.github.com/repos/shlinkio/shlink-docker-image/commits/master)
-
-# Create new tag and a ref to the tag, which will trigger image build on it
-curl -u acelaya:${GITHUB_OAUTH_KEY} \
-    -H "Content-Type: application/json" \
-    --data "{ \"tag\": \"${TRAVIS_TAG}\", \"message\": \"${TRAVIS_TAG}\", \"object\": \"${LATEST_MASTER_COMMIT}\", \"type\": \"commit\" }" \
-    -X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/tags
-curl -u acelaya:${GITHUB_OAUTH_KEY} \
-    -H "Content-Type: application/json" \
-    --data "{ \"ref\": \"refs/tags/${TRAVIS_TAG}\", \"sha\": \"${LATEST_MASTER_COMMIT}\" }" \
-    -X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/refs
-
-# Trigger image build for "latest
-curl -H "Content-Type: application/json" \
-    --data '{ "docker_tag": "latest" }' \
-    -X POST https://registry.hub.docker.com/u/shlinkio/shlink/trigger/${DOCKER_TRIGGER_TOKEN}/

+ 204 - 0
docker/README.md

@@ -0,0 +1,204 @@
+# Shlink Docker image
+
+[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
+[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
+
+This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
+
+It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which persists data in a local [sqlite](https://www.sqlite.org/index.html) database.
+
+## Usage
+
+Shlink docker image exposes port `8080` in order to interact with its HTTP interface.
+
+It also expects these two env vars to be provided, in order to properly generate short URLs at runtime.
+
+* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
+* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
+
+So based on this, to run shlink on a local docker service, you should run a command like this:
+
+```bash
+docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink
+```
+
+### Interact with shlink's CLI on a running container.
+
+Once the shlink container is running, you can interact with the CLI tool by running `shlink` with any of the supported commands.
+
+For example, if the container is called `shlink_container`, you can generate a new API key with:
+
+```bash
+docker exec -it shlink_container shlink api-key:generate
+```
+
+Or you can list all tags with:
+
+```bash
+docker exec -it shlink_container shlink tag:list
+```
+
+Or process remaining visits with:
+
+```bash
+docker exec -it shlink_container shlink visit:process
+```
+
+All shlink commands will work the same way.
+
+You can also list all available commands just by running this:
+
+```bash
+docker exec -it shlink_container shlink
+```
+
+## Use an external DB
+
+The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
+
+It is possible to use a set of env vars to make this shlink instance interact with an external MySQL or PostgreSQL database.
+
+* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql** or **postgres** to prevent the sqlite database to be used.
+* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
+* `DB_USER`: **[Mandatory]**. The username credential for the database server.
+* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
+* `DB_HOST`: **[Mandatory]**. The host name of the server running the database engine.
+* `DB_PORT`: [Optional]. The port in which the database service is running.
+    * Default value is based on the driver:
+        * **mysql** -> `3306`
+        * **postgres** -> `5432`
+
+> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions.
+
+Taking this into account, you could run shlink on a local docker service like this:
+
+```bash
+docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink
+```
+
+You could even link to a local database running on a different container:
+
+```bash
+docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink
+```
+
+> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
+
+## Supported env vars
+
+A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior.
+
+This is the complete list of supported env vars:
+
+* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
+* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
+* `SHORTCODE_CHARS`: A charset to use when building short codes. Only needed when using more than one shlink instance ([Multi instance considerations](#multi-instance-considerations)).
+* `DB_DRIVER`: **sqlite** (which is the default value), **mysql** or **postgres**.
+* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
+* `DB_USER`: The username credential to be used when using an external database driver.
+* `DB_PASSWORD`: The password credential to be used when using an external database driver.
+* `DB_HOST`: The host name of the database server  when using an external database driver.
+* `DB_PORT`: The port in which the database service is running when using an external database driver. Defaults to **3306**.
+* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
+* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
+* `LOCALE`: Defines the default language for error pages when a user accesses a short URL which does not exist. Supported values are **es** and **en**. Defaults to **en**.
+* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x (after following redirects) is returned when trying to shorten a URL. Defaults to `true`.
+* `NOT_FOUND_REDIRECT_TO`: If a URL is provided here, when a user tries to access an invalid short URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
+* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
+
+    This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
+
+    If more than one server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
+
+    In the future, these redis servers could be used for other caching operations performed by shlink.
+
+An example using all env vars could look like this:
+
+```bash
+docker run \
+    --name shlink \
+    -p 8080:8080 \
+    -e SHORT_DOMAIN_HOST=doma.in \
+    -e SHORT_DOMAIN_SCHEMA=https \
+    -e DB_DRIVER=mysql \
+    -e DB_NAME=shlink \
+    -e DB_USER=root \
+    -e DB_PASSWORD=123abc \
+    -e DB_HOST=something.rds.amazonaws.com \
+    -e DB_PORT=3306 \
+    -e DISABLE_TRACK_PARAM="no-track" \
+    -e DELETE_SHORT_URL_THRESHOLD=30 \
+    -e LOCALE=es \
+    -e VALIDATE_URLS=false \
+    -e "NOT_FOUND_REDIRECT_TO=https://www.google.com" \
+    -e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \
+    shlinkio/shlink
+```
+
+## Provide config via volumes
+
+Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.
+
+Mounting a volume at `config/params` you will make shlink load all the files on it with the `.config.json` suffix.
+
+The whole configuration should have this format, but it can be split into multiple files that will be merged:
+
+```json
+{
+    "disable_track_param": "my_param",
+    "delete_short_url_threshold": 30,
+    "locale": "es",
+    "short_domain_schema": "https",
+    "short_domain_host": "doma.in",
+    "validate_url": false,
+    "not_found_redirect_to": "https://my-landing-page.com",
+    "redis_servers": [
+        "tcp://172.20.0.1:6379",
+        "tcp://172.20.0.2:6379"
+    ],
+    "db_config": {
+        "driver": "pdo_mysql",
+        "dbname": "shlink",
+        "user": "root",
+        "password": "123abc",
+        "host": "something.rds.amazonaws.com",
+        "port": "3306"
+    }
+}
+```
+
+> This is internally parsed to how shlink expects the config. If you are using a version previous to 1.17.0, this parser is not present and you need to provide a config structure like the one [documented previously](https://github.com/shlinkio/shlink-docker-image/tree/v1.16.3#provide-config-via-volumes).
+
+Once created just run shlink with the volume:
+
+```bash
+docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink
+```
+
+## Multi instance considerations
+
+These are some considerations to take into account when running multiple instances of shlink.
+
+* The first time shlink is run, it generates a charset used to generate short codes, which is a shuffled base62 charset.
+
+    If you are using several shlink instances, you will probably want all of them to use the same charset.
+
+    You can get a shuffled base62 charset by going to [https://shlink.io/short-code-chars](https://shlink.io/short-code-chars), and then you just need to pass it to all shlink instances using the `SHORTCODE_CHARS` env var.
+
+    If you don't do this, each shlink instance will use a different charset. However this shouldn't be a problem in practice, since the chances to get a collision will be very low.
+
+* Some operations performed by Shlink should never be run more than once at the same time (like creating the database for the first time, or downloading the GeoLite2 database). For this reason, Shlink uses a locking system.
+
+    However, these locks are locally scoped to each Shlink instance by default.
+
+    You can (and should) make the locks to be shared by all Shlink instances by using a redis server/cluster. Just define the `REDIS_SERVERS` env var with the list of servers.
+
+## Versions
+
+Versions of this image match the shlink version it contains.
+
+For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
+
+The `latest` docker tag always holds the latest contents in master, and it's considered unestable and not suitable for production.
+
+> There are no official shlink images previous to v1.15.0.

+ 175 - 0
docker/config/shlink_in_docker.local.php

@@ -0,0 +1,175 @@
+<?php
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink;
+
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
+
+use function explode;
+use function file_exists;
+use function file_get_contents;
+use function file_put_contents;
+use function implode;
+use function Shlinkio\Shlink\Common\env;
+use function sprintf;
+use function str_shuffle;
+use function substr;
+use function sys_get_temp_dir;
+
+$helper = new class {
+    private const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+    private const DB_DRIVERS_MAP = [
+        'mysql' => 'pdo_mysql',
+        'postgres' => 'pdo_pgsql',
+    ];
+    private const DB_PORTS_MAP = [
+        'mysql' => '3306',
+        'postgres' => '5432',
+    ];
+
+    /** @var string */
+    private $charset;
+    /** @var string */
+    private $secretKey;
+
+    public function __construct()
+    {
+        [$this->charset, $this->secretKey] = $this->initShlinkKeys();
+    }
+
+    private function initShlinkKeys(): array
+    {
+        $keysFile = sprintf('%s/shlink.keys', sys_get_temp_dir());
+        if (file_exists($keysFile)) {
+            return explode(',', file_get_contents($keysFile));
+        }
+
+        $keys = [
+            env('SHORTCODE_CHARS', $this->generateShortcodeChars()),
+            env('SECRET_KEY', $this->generateSecretKey()),
+        ];
+
+        file_put_contents($keysFile, implode(',', $keys));
+        return $keys;
+    }
+
+    private function generateShortcodeChars(): string
+    {
+        return str_shuffle(self::BASE62);
+    }
+
+    private function generateSecretKey(): string
+    {
+        return substr(str_shuffle(self::BASE62), 0, 32);
+    }
+
+    public function getShortcodeChars(): string
+    {
+        return $this->charset;
+    }
+
+    public function getSecretKey(): string
+    {
+        return $this->secretKey;
+    }
+
+    public function getDbConfig(): array
+    {
+        $driver = env('DB_DRIVER');
+        if ($driver === null || $driver === 'sqlite') {
+            return [
+                'driver' => 'pdo_sqlite',
+                'path' => 'data/database.sqlite',
+            ];
+        }
+
+        $driverOptions = $driver !== 'mysql' ? [] : [
+            // PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
+            1002 => 'SET NAMES utf8',
+        ];
+        return [
+            'driver' => self::DB_DRIVERS_MAP[$driver],
+            'dbname' => env('DB_NAME', 'shlink'),
+            'user' => env('DB_USER'),
+            'password' => env('DB_PASSWORD'),
+            'host' => env('DB_HOST'),
+            'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
+            'driverOptions' => $driverOptions,
+        ];
+    }
+
+    public function getNotFoundConfig(): array
+    {
+        $notFoundRedirectTo = env('NOT_FOUND_REDIRECT_TO');
+
+        return [
+            'enable_redirection' => $notFoundRedirectTo !== null,
+            'redirect_to' => $notFoundRedirectTo,
+        ];
+    }
+};
+
+return [
+
+    'config_cache_enabled' => false,
+
+    'app_options' => [
+        'secret_key' => $helper->getSecretKey(),
+        'disable_track_param' => env('DISABLE_TRACK_PARAM'),
+    ],
+
+    'delete_short_urls' => [
+        'check_visits_threshold' => true,
+        'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', 15),
+    ],
+
+    'translator' => [
+        'locale' => env('LOCALE', 'en'),
+    ],
+
+    'entity_manager' => [
+        'connection' => $helper->getDbConfig(),
+    ],
+
+    'url_shortener' => [
+        'domain' => [
+            'schema' => env('SHORT_DOMAIN_SCHEMA', 'http'),
+            'hostname' => env('SHORT_DOMAIN_HOST', ''),
+        ],
+        'shortcode_chars' => $helper->getShortcodeChars(),
+        'validate_url' => (bool) env('VALIDATE_URLS', true),
+        'not_found_short_url' => $helper->getNotFoundConfig(),
+    ],
+
+    'logger' => [
+        'handlers' => [
+            'shlink_rotating_handler' => [
+                'level' => Logger::EMERGENCY, // This basically disables regular file logs
+            ],
+            'shlink_stdout_handler' => [
+                'class' => StreamHandler::class,
+                'level' => Logger::INFO,
+                'stream' => 'php://stdout',
+                'formatter' => 'dashed',
+            ],
+        ],
+
+        'loggers' => [
+            'Shlink' => [
+                'handlers' => ['shlink_stdout_handler'],
+            ],
+        ],
+    ],
+
+    'dependencies' => [
+        'aliases' => env('REDIS_SERVERS') === null ? [] : [
+            'lock_store' => 'redis_lock_store',
+        ],
+    ],
+
+    'redis' => [
+        'servers' => env('REDIS_SERVERS'),
+    ],
+
+];

+ 17 - 0
docker/docker-entrypoint.sh

@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+set -e
+
+cd /etc/shlink
+
+echo "Creating fresh database if needed..."
+php bin/cli db:create -n -q
+
+echo "Updating database..."
+php bin/cli db:migrate -n -q
+
+echo "Generating proxies..."
+php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
+
+# When restarting the container, swoole might think it is already in execution
+# This forces the app to be started every second until the exit code is 0
+until php vendor/zendframework/zend-expressive-swoole/bin/zend-expressive-swoole start; do sleep 1 ; done

+ 1 - 0
phpcs.xml

@@ -14,6 +14,7 @@
     <file>module</file>
     <file>data/migrations</file>
     <file>config</file>
+    <file>docker/config</file>
     <file>public/index.php</file>
 
     <!-- Paths to exclude -->