Migration de notre stack de développement vers Docker


tip

Il existe une version de cet article moins technique et plus orientée développeurs macOS écrite par Tristan Bessoussa, voir Un environnement de développement sain en 2021. Bonjour Docker, bye machines virtuelles.

Chez yProximité, agence web, on utilise les machines virtuelles depuis plusieurs années comme stack de développement sur tous nos différents projets web, afin de mettre en place un environnement de développement complet avec un serveur web nginx, PHP, Node.js, une base de données et Redis.

Pour cela, on utilise :

Ça fonctionne plutôt bien (quand ça fonctionne :stuck_out_tongue_winking_eye:), et ça a pour avantage d'avoir un environnement de développement totalement fonctionnel et isolé de la machine hôte.

En revanche, plusieurs années ont passé et l'équipe et moi-même avons rencontré plusieurs problèmes.

Les très nombreux problèmes avec les machines virtuelles

Problèmes systèmes

  1. Pour les utilisateurs d'Ubuntu 18.04 ou plus, le plugin Vagrant Landrush (qui permet de mapper un faux NDD vers l'IP d'une VM) ne fonctionne pas. On doit manuellement ajouter une entrée dans notre /etc/hosts.
  2. Il y a des versions de VirtualBox qui ne fonctionnent pas. Dès que je trouvais une version qui fonctionnait parfaitement (ex : 6.1.16 pour Ubuntu 20.10), je désactivais les mises à jour via echo "virtualbox-6.1 hold" | sudo dpkg --set-selections, dans la crainte qu'une mise à jour ne fasse plus fonctionner les VM.
  3. Des soucis d'optimisation d'espace disque. Avec une VM par projet, l'espace disque utilisé peut monter assez vite (~160 Go utilisé après 3 ans sans nettoyage).
  4. Des problèmes de consommation CPU / RAM, faire tourner une ou plusieurs VM, avec PhpStorm, avec Google Chrome, etc... le tout en même temps, ce n'est pas donné à tout le monde. Il faut avoir une très bonne machine capable d'encaisser la charge.
  5. Il y a des fichiers importants qu'il faut importer dans la VM (~/.ssh/config, ~/.composer/auth.json, ~/.gitconfig, ...), ça se fait automatiquement au boot de la VM, mais bon...
  6. Les machines virtuelles ne seront pas utilisables sur les nouveaux Mac tournant sour CPU ARM, Tristan Bessoussa en parle très bien sur son article.

Problèmes applicatifs

  1. Le watching de fichiers (le fait d'observer les modifications sur des fichiers en temps réel) qui ne fonctionne pas correctement à cause de l'utilisation de NFS :
    • pour faire fonctionner le dev-server de webpack, on a dû configurer le polling
    • pour faire fonctionner le watch de TailwindCSS, on a également dû configurer le polling via CHOKIDAR_USEPOLLING=1 (discussion GitHub)
  2. Pour nos git hooks ou tests Cypress (tout ce qui peut être lancé dans et en dehors de la VM en fait), il fallait passer par un petit script vagrant-wrapper.sh pour s'assurer que nos commandes soient bien exécutées dans la VM :
vagrant-wrapper.sh
 1#!/usr/bin/env bash
 2
 3# Permet d'exécuter une commande dans la VM, que l'on soit dans la VM ou non.
 4# Exemple : ./vagrant-wrapper.sh bin/phpstan analyse
 5
 6vagrant_wrapper() {
 7    local user_command=$@
 8
 9    # we assume that we are outside the VM if command `vagrant` is available
10    if [[ -x "$(command -v vagrant)" ]]; then
11        vagrant ssh -- "cd /srv/app && ${user_command}"
12    else
13        eval ${user_command}
14    fi
15}
16
17vagrant_wrapper $@
  1. Si on utilisait un certificat HTTPS local (ex : généré avec mkcert), il fallait relancer nginx après que la partition NFS (contenant notre certificat HTTPS) soit montée, sinon nginx ne se lançait pas car le certificat HTTPS était introuvable :
Vagrantfile
1Vagrant.configure(2) do |config|
2  # ...
3  config.trigger.after :up do |trigger|
4    trigger.name = "nginx"
5    trigger.info = "Starting nginx..."
6    trigger.run_remote = {inline: "if systemctl cat nginx >/dev/null 2>&1; then sudo systemctl start nginx; fi"}
7  end
8end
  1. Si l'un de nos projets dépend d'un autre (ex : projet A qui utilise l'API du projet B et qui est en HTTPS), alors il fallait importer le certificat racine de mkcert dans la VM (cette solution n'a été testée que sous Debian/Ubuntu, ne fonctionne sans doute pas sous macOS) :
Vagrantfile
 1Vagrant.configure(2) do |config|
 2  # ...
 3  Dir['/usr/local/share/ca-certificates/mkcert_*'].each do |path|
 4    filename = path.split('/').last
 5
 6    config.vm.provision 'file', run: 'always' do |file|
 7      file.source = path
 8      file.destination = "/home/#{config.ssh.username}/#{filename}" # file provisionner can't write in /usr/local/... due to permissions, we have to use a trigger
 9    end
10
11    # copy to /usr/local/..., apply permissions and update CA certificates
12    config.trigger.after [:up, :provision] do |trigger|
13      trigger.name = "mkcert"
14      trigger.info = "Copying mkcert's CA file..."
15      trigger.run_remote = {
16        inline: 'if [ -f "%{source}" ]; then mv "%{source}" "%{path}" && chown root:staff "%{path}" && update-ca-certificates; fi' % { source: "/home/#{config.ssh.username}/#{filename}", path: path }
17      }
18    end
19  end
20end

Des performances en retrait

  1. L'installation initiale (via le provisioning Ansible) est très longue, environ 15 minutes, tellement il y a de choses à installer et à configurer.
  2. La VM peut prendre plusieurs dizaines de secondes à boot.
  3. La partition NFS n'aide pas du tout, même si on a déjà réussi à diviser par 4 le temps des yarn install et composer install en utilisant cette configuration :
Vagrantfile
1Vagrant.configure(2) do |config|
2  # ...
3  config.vm.synced_folder '.', '/srv/app',
4    type: 'nfs',
5    mount_options: ['vers=3', 'tcp', 'rw', 'nolock', 'actimeo=1'],
6    linux__nfs_options: ['rw', 'all_squash', 'async']
7end

tl;dr

Trop de problèmes de lenteur, trop de problèmes nécessitant des tweaks et du temps investi pour les outrepasser...

Analyses et réflexions

Pour récapituler :

  • nos projets webs nécessitent un serveur web, Nginx
  • ils sont tous basés sur du PHP (mais avec des versions différentes)
  • certains ont besoin de Node.js (également avec des versions différentes) pour build des assets via webpack Encore
  • certains ont besoin d'une base de données MySQL, MariaDB ou PostgreSQL (encore avec des versions différentes aussi)
  • certains ont besoin de Redis

Réflexions

Je sais par expérience que :

  • il est facile d'installer nginx, mais ce n'est peut-être pas forcément l'idéal... à voir comment gérer les noms de domaines
  • il est très facile d'installer plusieurs versions de PHP, via :
  • il est également très facile d'installer plusieurs version de Node.js, via :
    • nvm pour macOS et Linux,
    • n pour macOS et Linux également
  • installer des serveurs de base de données et gérer différentes versions en même temps, c'est UNE HORREUR et je n'ai pas envie de bousiller ma machine
  • installer Redis globalement n'est peut-être pas la meilleure des idées non plus...

Avec ce constat, je me suis dit qu'on pouvait tenter une stack hybride :

  • PHP et Node.js installés sur la machine
  • les bases de données et Redis installés via Docker
  • il ne manque plus que le serveur web... Comment fait-on ?

Symfony CLI ✨

Symfony CLI est un outil écrit en Go et qui a notamment remplacé l'ancien Symfony WebServerBundle. On l'utilise déjà sur nos CI pour lancer un serveur web pour nos tests E2E avec Cypress.

Mais ce n'est pas tout. Symfony CLI est un outil surboosté aux vitamines avec d'incroyables fonctionnalités permettant de régler plusieurs problématiques :

tip

Ça signifie qu'il faut utiliser le binaire symfony pour exécuter des commandes/binaires avec la bonne version de PHP :

  • PHP via symfony php (ex : symfony php bin/phpstan analyze)
  • Composer via symfony composer (ex : symfony composer install)
  • La console Symfony avec le raccourci symfony console (ex : symfony console cache:clear)
  • et le plus exceptionnel, une intégration avec Docker qui permet de définir automatiquement des variables d'environnement en fonction des containers. Si on utilise un container pour une base de données, alors DATABASE_URL sera automatiquement définie et c'est PARFAIT pour nous ! :heart_eyes:

warning

Le binaire symfony utilisera toujours les variables d'environnement détectées via Docker et ignorera les variables d'environnement locales.

Cela veut dire que les variables d'environnement DATABASE_URL, REDIS_URL, etc... définies dans vos .env ou .env.local ne seront pas utilisées.

Docker

On a donc utilisé des containers Docker pour la base de données et Redis, exemple :

 1version: '3.6'
 2
 3volumes:
 4  db-data:
 5  redis-data:
 6
 7services:
 8  database:
 9    image: 'postgres:12-alpine'
10    ports: [5432]
11    environment:
12      POSTGRES_USER: 'app'
13      POSTGRES_PASSWORD: 'app'
14      POSTGRES_DB: 'app'
15      TZ: Etc/UTC
16      PGTZ: Etc/UTC
17    volumes:
18      - db-data:/var/lib/postgresql/data
19    healthcheck:
20      test: pg_isready
21      interval: 10s
22      timeout: 5s
23      retries: 5
24
25  redis:
26    image: 'redis:alpine'
27    ports: [6379]
28    environment:
29      TZ: Etc/UTC
30    volumes:
31      - redis-data:/data

Un coup de docker-compose up --detach pour démarrer les containers Docker, et c'est parti ! Pour stopper les containers, lancer simplement docker-compose stop.

En lançant symfony var:export --multiline, une liste de variables d'environnement semblable devrait s'afficher :

1export DATABASE_DATABASE=app
2export DATABASE_NAME=app
3export DATABASE_URL=postgres://app:app@127.0.0.1:49160/app?sslmode=disable&charset=utf8
4export REDIS_URL=redis://127.0.0.1:49234
5# ...

Si c'est le cas, félicitations ! Le binaire symfony a correctement détecté vos containers Docker et vous pouvez lancer symfony serve.

En résumé

En résumé, que faut-il pour bénéficier de ce nouvel environnement de développement ?

  • Avoir PHP installé localement
  • Avoir Node.js installé localement (ou non)
  • Utiliser Docker pour les bases de données et Redis
  • Utiliser le Symfony CLI en tant que serveur web, proxy pour le nom de domaine, le https, et l'intégration Docker

Mise en oeuvre sur un projet :

  • Créer un docker-compose.yaml à votre sauce
  • Puis lancer les commandes suivantes :
1symfony server:ca:install
2symfony proxy:start
3symfony proxy:domain:attach my-app
4docker-compose up --detach
5symfony serve

Enjoy !

Pour aller plus loin

Utiliser Manala et les recipes

Pour faciliter la maintenance, l'évolutivité, la configuration et la gestion de ces toutes étapes à travers nos différents projets, on utilise l'outil Manala permettant l'utilisation d'un système de recipes (recettes) et de générer automatiquement plusieurs fichiers à partir d'un seul point d'entrée : le fichier de configuration .manala.yaml.

Il y a des recipes officielles fournies par Manala, mais ça ne nous convenait pas forcément. On a donc créé notre propre repository de recipes, avec une recipe yprox.app-docker qui permet :

  • de définir la timezone du projet, qui sera injectée dans le php.ini et dans les containers Docker
  • de définir une configuration PHP, qui sera injectée dans le php.ini
  • de définir quelle base de données et quelle version utiliser, qui modifiera le docker-compose.yaml
  • de mettre à disposition quelques commandes :
    • make setup : à exécuter qu'une seule fois, pour setuper le projet, créer les containers Docker...
    • make up : pour lancer le proxy local Symfony et les containers Docker
    • make halt : pour stopper les containers Docker (quand la journée est finie :stuck_out_tongue: )
    • make destroy : pour supprimer les containers Docker et volumes associés

La recipe s'installe facilement :

1manala init -i yprox.app-docker --repository https://github.com/Yproximite/manala-recipes.git

Pour mettre à jour la recipe, simplement lancer un manala up 🎉.

Il ne me faut pas plus de 2 minutes pour mettre à jour la recipe sur 4 ou 5 projets, c'est un vrai gain de temps considérable !

Bonus : voir Mise en place de l'environnement de développement en ~1 minute pour la mise en place et utilisation de la recipe Manala yprox.app-docker.

Mise en place de l'environnement de développement en ~1 minute

Sur un projet existant avec du PHP, Node.js, PostgreSQL et Redis :

  • installation de la recipe Manala yprox.app-docker
  • migration du Makefile pour utiliser make setup et les $(symfony)
  • création des containers Docker
  • installation de l'application

Le tout en en ~1 minute :heart_eyes:

Intégration avec le CI ?

Puisqu'on a :

  • la version de PHP définie dans le .php-version
  • la version de Node.js définie dans le .nvmrc
  • et un docker-compose.yaml pour la base de données et Redis

Est-ce qu'il serait possible d'exploiter tout ça dans le CI ? La réponse est oui.

On utilise GitHub Actions comme CI, et il est plutôt facile de modifier nos workflows pour prendre en compte les éléments listés précédemment.

Installer PHP et Node.js aux bonnes versions

Le soucis étant que les actions shivammathur/setup-php et actions/setup-node ne prennent pas en compte les .php-version et .nvmrc, il va donc falloir trouver une solution.

J'ai pris pour habitude de définir la version de PHP et Node.js à utiliser en tant que variables d'environnement définies au niveau du worklfow :

 1# .github/workflows/ci.yml
 2name: CI
 3
 4on:
 5  pull_request:
 6    types: [opened, synchronize, reopened, ready_for_review]
 7
 8env:
 9  TZ: UTC
10  PHP_VERSION: 7.4
11  NODE_VERSION: 12.x
12
13jobs:
14  php:
15    runs-on: ubuntu-latest
16    steps:
17      - uses: actions/checkout@v2
18
19      - uses: shivammathur/setup-php@v2
20        with:
21          php-version: ${{ env.PHP_VERSION }}
22          coverage: none
23          extensions: iconv, intl
24          ini-values: date.timezone=${{ env.TZ }}
25          tools: symfony
26
27      - uses: actions/setup-node@v2
28        with:
29          node-version: ${{ env.NODE_VERSION }}
30
31  another_job:
32  # ...

Sachant qu'il est possible de définir des variables d'environnement à la volée, pourquoi est-ce qu'on n'utiliserait pas le contenu des fichiers .php-version et .nvmrc ?

C'est totallement possible en faisant ainsi :

   1# .github/workflows/ci.yml
   2name: CI
   3
   4on:
   5    pull_request:
   6        types: [opened, synchronize, reopened, ready_for_review]
   7
   8env:
   9    TZ: UTC
  10-    PHP_VERSION: 7.4
  11-    NODE_VERSION: 12.x
  12
  13jobs:
  14    php:
  15        runs-on: ubuntu-latest
  16        steps:
  17            - uses: actions/checkout@v2
  18
19 +            - run: echo "PHP_VERSION=$(cat .php-version | xargs)" >> $GITHUB_ENV 
20 +            - run: echo "NODE_VERSION=$(cat .nvmrc | xargs)" >> $GITHUB_ENV 
  21
  22            # Installation de PHP et Node.js

Lancer Docker

Une commande make setup@integration est disponible pour lancer Docker sur le CI.

.github/workflows/ci.yml
   1name: CI
   2
   3on:
   4    pull_request:
   5        types: [opened, synchronize, reopened, ready_for_review]
   6
   7env:
   8    TZ: UTC
   9
  10jobs:
  11    php:
  12        runs-on: ubuntu-latest
  13        steps:
  14            - uses: actions/checkout@v2
  15
  16            - run: echo "PHP_VERSION=$(cat .php-version | xargs)" >> $GITHUB_ENV
  17            - run: echo "NODE_VERSION=$(cat .nvmrc | xargs)" >> $GITHUB_ENV
  18
  19            # Installation de PHP et Node.js
  20
21 +            # Configure le Symfony CLI et lance les containers Docker 
22 +            - run: make setup@integration 

Exemple complet

On a créé une GitHub Action locale qui définit plusieurs variables d'environnement. De cette façon on peut l'utiliser dans plusieurs jobs et éviter de dupliquer pas mal de lignes :

 1# .github/actions/setup-environment/action.yml
 2name: Setup environment
 3description: Setup environment
 4runs:
 5  using: 'composite'
 6  steps:
 7    - run: echo "PHP_VERSION=$(cat .php-version | xargs)" >> $GITHUB_ENV
 8      shell: bash
 9
10    - run: echo "NODE_VERSION=$(cat .nvmrc | xargs)" >> $GITHUB_ENV
11      shell: bash
12
13    # Composer cache
14    - id: composer-cache
15      run: echo "::set-output name=dir::$(composer global config cache-files-dir)"
16      shell: bash
17
18    - run: echo "COMPOSER_CACHE_DIR=${{ steps.composer-cache.outputs.dir }}" >> $GITHUB_ENV
19      shell: bash
20
21    # Yarn cache
22    - id: yarn-cache-dir
23      run: echo "::set-output name=dir::$(yarn cache dir)"
24      shell: bash
25
26    - run: echo "YARN_CACHE_DIR=${{ steps.yarn-cache-dir.outputs.dir }}" >> $GITHUB_ENV
27      shell: bash
28
29    # Misc
30    - run: echo "IS_DEPENDABOT=${{ startsWith(github.head_ref, 'dependabot') == true }}" >> $GITHUB_ENV
31      shell: bash

Et un exemple de workflow pour PHP, nos assets JavaScript, et des tests E2E Cypress + auto-approve si c'est une PR Dependabot :

.github/workflows/ci.yml
  1name: CI
  2
  3on:
  4  pull_request:
  5    types: [opened, synchronize, reopened, ready_for_review]
  6
  7env:
  8  TZ: UTC
  9
 10  COMPOSER_ALLOW_SUPERUSER: '1' # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
 11  # À décommenter si vous utilisez Packagist.com
 12  #COMPOSER_AUTH: '{"http-basic":{"repo.packagist.com":{"username":"token","password":"${{ secrets.PACKAGIST_AUTH_TOKEN }}"}}}'
 13
 14jobs:
 15  php:
 16    runs-on: ubuntu-latest
 17    steps:
 18      - uses: actions/checkout@v2
 19
 20      - uses: ./.github/actions/setup-environment
 21
 22      - uses: shivammathur/setup-php@v2
 23        with:
 24          php-version: ${{ env.PHP_VERSION }}
 25          coverage: none
 26          extensions: iconv, intl
 27          ini-values: date.timezone=${{ env.TZ }}
 28          tools: symfony
 29
 30      - uses: actions/setup-node@v2
 31        with:
 32          node-version: ${{ env.NODE_VERSION }}
 33
 34      - uses: actions/cache@v2
 35        with:
 36          path: ${{ env.COMPOSER_CACHE_DIR }}
 37          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
 38          restore-keys: ${{ runner.os }}-composer-
 39
 40      - uses: actions/cache@v2
 41        with:
 42          path: ${{ env.YARN_CACHE_DIR }}
 43          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
 44          restore-keys: ${{ runner.os }}-yarn-
 45
 46      # Check des dépendances
 47      - run: symfony composer validate
 48      - run: symfony security:check
 49
 50      # Installe l'environnement et l'application
 51      - run: make setup@integration
 52
 53      # Préparation des tests
 54      - run: symfony console cache:clear
 55      - run: APP_ENV=test symfony console doctrine:schema:validate # force APP_ENV=test because only the test database is created
 56      - run: symfony console api:swagger:export > /dev/null # Check if ApiPlatform is correctly configured
 57
 58      # Lint des fichiers Twig, Yaml et XLIFF
 59      - run: symfony console lint:twig templates
 60      - run: symfony console lint:yaml config --parse-tags
 61      - run: symfony console lint:xliff translations
 62
 63      # Outils d'analyse de code statique
 64      - run: symfony php bin/php-cs-fixer.phar fix --verbose --diff --dry-run
 65      - run: symfony php bin/phpcs
 66      - run: symfony php bin/phpstan analyse
 67      - run: APP_ENV=test symfony php bin/phpunit.phar # See https://github.com/symfony/symfony-docs/pull/15228
 68      - run: symfony php bin/phpspec run
 69
 70  javascript:
 71    runs-on: ubuntu-latest
 72    steps:
 73      - uses: actions/checkout@v2
 74
 75      - uses: ./.github/actions/setup-environment
 76
 77      - uses: shivammathur/setup-php@v2
 78        with:
 79          php-version: ${{ env.PHP_VERSION }}
 80          coverage: none
 81          extensions: iconv, intl
 82          ini-values: date.timezone=${{ env.TZ }}
 83          tools: symfony
 84
 85      - uses: actions/setup-node@v2
 86        with:
 87          node-version: ${{ env.NODE_VERSION }}
 88
 89      - uses: actions/cache@v2
 90        with:
 91          path: ${{ env.COMPOSER_CACHE_DIR }}
 92          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
 93          restore-keys: ${{ runner.os }}-composer-
 94
 95      - uses: actions/cache@v2
 96        with:
 97          path: ${{ env.YARN_CACHE_DIR }}
 98          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
 99          restore-keys: ${{ runner.os }}-yarn-
100
101      - run: make setup@integration
102
103      # Check des types TypeScript
104      - run: yarn tsc --noEmit
105
106      # Lint des fichiers JS et CSS
107      - run: yarn lint:js --no-fix
108      - run: yarn lint:css --no-fix
109
110      # Build pour le développement et production
111      - run: yarn dev
112      - run: yarn prod
113
114  cypress:
115    runs-on: ubuntu-latest
116    name: cypress (${{ matrix.cypress.group }})
117    strategy:
118      fail-fast: false
119      matrix:
120        cypress:
121          # Ajouter plus d'entrées pour bénificier de la parallélisation
122          - group: default
123            spec: 'tests/cypress/**/*'
124
125    steps:
126      - uses: actions/checkout@v2
127
128      - uses: ./.github/actions/setup-environment
129
130      - uses: shivammathur/setup-php@v2
131        with:
132          php-version: ${{ env.PHP_VERSION }}
133          coverage: none
134          extensions: iconv, intl
135          ini-values: date.timezone=${{ env.TZ }}
136          tools: symfony
137
138      - uses: actions/setup-node@v2
139        with:
140          node-version: ${{ env.NODE_VERSION }}
141
142      - uses: actions/cache@v2
143        with:
144          path: ${{ env.COMPOSER_CACHE_DIR }}
145          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
146          restore-keys: ${{ runner.os }}-composer-
147
148      - uses: actions/cache@v2
149        with:
150          path: ${{ env.YARN_CACHE_DIR }}
151          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
152          restore-keys: ${{ runner.os }}-yarn-
153
154      - run: make setup@integration
155
156      # Démarrage du serveur Symfony
157      - run: APP_ENV=test symfony serve --port 8000 --daemon
158      - run: echo "CYPRESS_BASE_URL=https://localhost:8000" >> $GITHUB_ENV
159
160      - name: Run Cypress
161        if: ${{ env.IS_DEPENDABOT == 'false' && ! github.event.pull_request.draft }}
162        uses: cypress-io/github-action@v2
163        with:
164          spec: ${{ matrix.cypress.spec }}
165          # À décommenter si le projet est configuré sur le Cypress Dashboard
166          #record: true
167          #parallel: true
168          #group: ${{ matrix.cypress.group }}
169          #env:
170          #    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
171
172      - name: Run Cypress (for Dependabot or when pull request is draft)
173        if: ${{ env.IS_DEPENDABOT == 'true' || github.event.pull_request.draft }}
174        uses: cypress-io/github-action@v2
175
176  auto_approve:
177    runs-on: ubuntu-latest
178    needs: [php, javascript, cypress]
179    if: ${{ github.actor == 'dependabot[bot]' }}
180    steps:
181      - uses: hmarr/auto-approve-action@v2.0.0
182        with:
183          github-token: ${{ secrets.GITHUB_TOKEN }}