Qu’est-ce que GitOps ?

Noter. trad.: Après une publication récente matériel concernant les méthodes pull et push dans GitOps, nous avons constaté un intérêt pour ce modèle en général, mais il y avait très peu de publications en russe sur ce sujet (il n'y en a tout simplement aucune sur Habré). C'est pourquoi nous sommes heureux de proposer à votre attention la traduction d'un autre article - bien qu'il y ait presque un an ! – de Weaveworks, dont le responsable a inventé le terme « GitOps ». Le texte explique l'essence de l'approche et les principales différences par rapport aux approches existantes.

Il y a un an, nous avons publié introduction à GitOps. À l'époque, nous avions expliqué comment l'équipe Weaveworks avait lancé un SaaS entièrement basé sur Kubernetes et développé un ensemble de bonnes pratiques prescriptives pour le déploiement, la gestion et la surveillance dans un environnement cloud natif.

L'article s'est avéré populaire. D'autres personnes ont commencé à parler de GitOps et à publier de nouveaux outils pour git push, développement de, secrets, fonctions, Intégration continue et ainsi de suite. Apparu sur notre site Internet un grand nombre publications et cas d’utilisation de GitOps. Mais certains se posent encore des questions. En quoi le modèle diffère-t-il du modèle traditionnel ? infrastructure comme code et livraison continue (livraison continue) ? Est-il nécessaire d'utiliser Kubernetes ?

Nous avons vite réalisé qu'une nouvelle description était nécessaire, proposant :

  1. Un grand nombre d'exemples et d'histoires ;
  2. Définition spécifique de GitOps ;
  3. Comparaison avec la livraison continue traditionnelle.

Dans cet article, nous avons essayé de couvrir tous ces sujets. Il fournit une introduction mise à jour à GitOps et une perspective développeur et CI/CD. Nous nous concentrons principalement sur Kubernetes, même si le modèle peut être généralisé.

Rencontrez GitOps

Imaginez Alice. Elle dirige Family Insurance, qui propose une assurance maladie, automobile, habitation et voyage aux personnes trop occupées pour comprendre elles-mêmes les tenants et les aboutissants des contrats. Son entreprise a démarré comme un projet parallèle alors qu'Alice travaillait dans une banque en tant que data scientist. Un jour, elle s'est rendu compte qu'elle pouvait utiliser des algorithmes informatiques avancés pour analyser plus efficacement les données et formuler des forfaits d'assurance. Les investisseurs ont financé le projet, et désormais son entreprise rapporte plus de 20 millions de dollars par an et connaît une croissance rapide. Actuellement, elle emploie 180 personnes occupant différents postes. Cela comprend une équipe technologique qui développe, maintient le site Web, la base de données et analyse la clientèle. L'équipe de 60 personnes est dirigée par Bob, le directeur technique de l'entreprise.

L'équipe de Bob déploie des systèmes de production dans le cloud. Leurs applications principales s'exécutent sur GKE, tirant parti de Kubernetes sur Google Cloud. De plus, ils utilisent divers outils de données et d’analyse dans leur travail.

Family Insurance n'avait pas pour objectif d'utiliser des conteneurs, mais s'est laissée prendre par l'enthousiasme des Docker. L'entreprise a rapidement découvert que GKE permettait de déployer facilement et sans effort des clusters pour tester de nouvelles fonctionnalités. Jenkins pour CI et Quay ont été ajoutés pour organiser le registre de conteneurs, des scripts ont été écrits pour Jenkins qui poussaient de nouveaux conteneurs et configurations vers GKE.

Un certain temps a passé. Alice et Bob ont été déçus des performances de l'approche choisie et de son impact sur l'entreprise. L’introduction de conteneurs n’a pas amélioré la productivité autant que l’équipe l’espérait. Parfois, les déploiements étaient interrompus et il n'était pas clair si les modifications du code étaient à blâmer. Il s’est également avéré difficile de suivre les modifications de configuration. Il était souvent nécessaire de créer un nouveau cluster et d'y déplacer des applications, car c'était le moyen le plus simple d'éliminer le désordre qu'était devenu le système. Alice avait peur que la situation ne s'aggrave au fur et à mesure du développement de l'application (d'ailleurs, un nouveau projet basé sur le machine learning se préparait). Bob avait automatisé la majeure partie du travail et ne comprenait pas pourquoi le pipeline était toujours instable, ne évoluait pas bien et nécessitait une intervention manuelle périodique ?

Ensuite, ils ont découvert GitOps. Cette décision s’est avérée être exactement ce dont ils avaient besoin pour avancer en toute confiance.

Alice et Bob entendent parler de Git, DevOps et de l'infrastructure en tant que workflows de code depuis des années. La particularité de GitOps est qu'il apporte un ensemble de bonnes pratiques, à la fois définitives et normatives, pour mettre en œuvre ces idées dans le contexte de Kubernetes. Ce thème s'est levé à plusieurs reprisesy compris Blog des tissages.

Family Insurance décide de mettre en œuvre GitOps. L'entreprise dispose désormais d'un modèle d'exploitation automatisé compatible avec Kubernetes et combinant accélérer avec stabilitéparce qu'ils:

  • constaté que la productivité de l'équipe doublait sans que personne ne devienne fou ;
  • arrêté de servir des scripts. Au lieu de cela, ils peuvent désormais se concentrer sur de nouvelles fonctionnalités et améliorer les méthodes d'ingénierie - par exemple, en introduisant des déploiements Canary et en améliorant les tests ;
  • nous avons amélioré le processus de déploiement afin qu'il tombe rarement en panne ;
  • a eu la possibilité de restaurer des déploiements après des échecs partiels sans intervention manuelle ;
  • acheté d'occasionоUne plus grande confiance dans les systèmes de livraison. Alice et Bob ont découvert qu'ils pouvaient diviser l'équipe en équipes de microservices travaillant en parallèle ;
  • peut apporter 30 à 50 modifications au projet chaque jour grâce aux efforts de chaque groupe et essayer de nouvelles techniques ;
  • il est facile d'attirer de nouveaux développeurs vers le projet, qui ont la possibilité de déployer des mises à jour en production à l'aide de pull request en quelques heures ;
  • réussir facilement l'audit dans le cadre de SOC2 (pour la conformité des prestataires de services aux exigences de gestion sécurisée des données ; en savoir plus, par exemple, ici - environ. trad.).

Qu'est-il arrivé?

GitOps, c'est deux choses :

  1. Modèle opérationnel pour Kubernetes et cloud natif. Il fournit un ensemble de bonnes pratiques pour le déploiement, la gestion et la surveillance des clusters et des applications conteneurisés. Définition élégante sous la forme une diapositive à partir de Luis Faceira:
  2. Le chemin vers la création d’un environnement de gestion d’applications centré sur les développeurs. Nous appliquons le workflow Git aux opérations et au développement. Veuillez noter qu'il ne s'agit pas seulement de Git push, mais d'organiser l'ensemble des outils CI/CD et UI/UX.

Quelques mots sur Git

Si vous n'êtes pas familier avec les systèmes de contrôle de version et le workflow basé sur Git, nous vous recommandons fortement de vous renseigner à leur sujet. Travailler avec des branches et des pull request peut sembler de la magie noire au premier abord, mais les avantages en valent la peine. Ici bon article pour commencer.

Comment fonctionne Kubernetes

Dans notre histoire, Alice et Bob se sont tournés vers GitOps après avoir travaillé un certain temps avec Kubernetes. En effet, GitOps est étroitement lié à Kubernetes : il s'agit d'un modèle opérationnel d'infrastructure et d'applications basé sur Kubernetes.

Qu'est-ce que Kubernetes offre aux utilisateurs ?

Voici quelques caractéristiques principales :

  1. Dans le modèle Kubernetes, tout peut être décrit sous forme déclarative.
  2. Le serveur API Kubernetes prend cette déclaration en entrée, puis tente continuellement de mettre le cluster dans l'état décrit dans la déclaration.
  3. Les déclarations suffisent pour décrire et gérer une grande variété de charges de travail – des « applications ».
  4. Par conséquent, des modifications sont apportées à l'application et au cluster pour les raisons suivantes :
    • changements dans les images des conteneurs ;
    • modifications apportées à la spécification déclarative ;
    • erreurs dans l'environnement - par exemple, des pannes de conteneurs.

Les grandes capacités de convergence de Kubernetes

Lorsqu'un administrateur apporte des modifications à la configuration, l'orchestrateur Kubernetes les appliquera au cluster tant que son état est ne se rapprochera pas de la nouvelle configuration. Ce modèle fonctionne pour n'importe quelle ressource Kubernetes et est extensible avec des définitions de ressources personnalisées (CRD). Par conséquent, les déploiements Kubernetes possèdent les merveilleuses propriétés suivantes :

  • Automation: les mises à jour Kubernetes fournissent un mécanisme permettant d'automatiser le processus d'application des modifications de manière gracieuse et opportune.
  • Convergence : Kubernetes continuera à tenter des mises à jour jusqu'à ce qu'elles réussissent.
  • Idempotence: Des applications répétées de convergence conduisent au même résultat.
  • Déterminisme: Lorsque les ressources sont suffisantes, l'état du cluster mis à jour dépend uniquement de l'état souhaité.

Comment fonctionne GitOps

Nous en savons suffisamment sur Kubernetes pour expliquer le fonctionnement de GitOps.

Revenons aux équipes microservices de Family Insurance. Que doivent-ils faire habituellement ? Regardez la liste ci-dessous (si des éléments semblent étranges ou inconnus, veuillez ne pas critiquer et rester avec nous). Ce ne sont que des exemples de flux de travail basés sur Jenkins. Il existe de nombreux autres processus lorsque l’on travaille avec d’autres outils.

L'essentiel est que l'on voit que chaque mise à jour se termine par des modifications des fichiers de configuration et des référentiels Git. Ces modifications apportées à Git obligent « l'opérateur GitOps » à mettre à jour le cluster :

1. Processus de travail : "Construction Jenkins – branche principale».
Liste de tâches:

  • Jenkins envoie les images taguées vers Quay ;
  • Jenkins envoie les graphiques de configuration et Helm vers le compartiment de stockage principal ;
  • La fonction cloud copie la configuration et les graphiques du compartiment de stockage principal vers le référentiel Git principal ;
  • L'opérateur GitOps met à jour le cluster.

2. build Jenkins - branche de version ou de correctif:

  • Jenkins envoie les images non balisées vers Quay ;
  • Jenkins envoie les graphiques de configuration et Helm vers le compartiment de stockage intermédiaire ;
  • La fonction cloud copie la configuration et les graphiques du compartiment de stockage intermédiaire vers le référentiel Git intermédiaire ;
  • L'opérateur GitOps met à jour le cluster.

3. Jenkins build - développement ou branche de fonctionnalités:

  • Jenkins envoie les images non balisées vers Quay ;
  • Jenkins place les graphiques de configuration et Helm dans le compartiment de stockage de développement ;
  • La fonction cloud copie la configuration et les graphiques du compartiment de stockage de développement vers le référentiel Git de développement ;
  • L'opérateur GitOps met à jour le cluster.

4. Ajouter un nouveau client:

  • Le gestionnaire ou l'administrateur (LCM/ops) appelle Gradle pour déployer et configurer initialement les équilibreurs de charge réseau (NLB) ;
  • LCM/ops valide une nouvelle configuration pour préparer le déploiement pour les mises à jour ;
  • L'opérateur GitOps met à jour le cluster.

Brève description de GitOps

  1. Décrivez l'état souhaité de l'ensemble du système à l'aide de spécifications déclaratives pour chaque environnement (dans notre histoire, l'équipe de Bob définit l'ensemble de la configuration du système dans Git).
    • Le référentiel Git est la source unique de vérité concernant l'état souhaité de l'ensemble du système.
    • Toutes les modifications apportées à l'état souhaité sont effectuées via des validations dans Git.
    • Tous les paramètres de cluster souhaités sont également observables dans le cluster lui-même. De cette façon, nous pouvons déterminer s'ils coïncident (convergent, converger) ou différer (diverger, diverger) états souhaités et observés.
  2. Si les états souhaités et observés diffèrent, alors :
    • Il existe un mécanisme de convergence qui, tôt ou tard, synchronise automatiquement les états cible et observé. À l'intérieur du cluster, Kubernetes fait cela.
    • Le processus démarre immédiatement avec une alerte « changement validé ».
    • Après une période de temps configurable, une alerte « diff » peut être envoyée si les états sont différents.
  3. De cette façon, tous les commits dans Git provoquent des mises à jour vérifiables et idempotentes du cluster.
    • La restauration est une convergence vers un état précédemment souhaité.
  4. La convergence est définitive. Son apparition est indiquée par :
    • Aucune alerte différentielle pendant un certain temps.
    • alerte « convergée » (par exemple webhook, événement de réécriture Git).

Qu’est-ce que la divergence ?

Répétons encore : toutes les propriétés du cluster souhaitées doivent être observables dans le cluster lui-même.

Quelques exemples de divergences :

  • Modification du fichier de configuration en raison de la fusion des branches dans Git.
  • Une modification dans le fichier de configuration due à un commit Git effectué par le client GUI.
  • Plusieurs modifications de l'état souhaité en raison du PR dans Git, suivies de la création de l'image du conteneur et des modifications de configuration.
  • Un changement dans l'état du cluster dû à une erreur, un conflit de ressources entraînant un « mauvais comportement » ou simplement un écart aléatoire par rapport à l'état d'origine.

Quel est le mécanisme de convergence ?

Quelques exemples:

  • Pour les conteneurs et les clusters, le mécanisme de convergence est assuré par Kubernetes.
  • Le même mécanisme peut être utilisé pour gérer des applications et des conceptions basées sur Kubernetes (telles que Istio et Kubeflow).
  • Un mécanisme de gestion de l'interaction opérationnelle entre Kubernetes, les référentiels d'images et Git fournit Opérateur GitOps Weave Flux, qui fait partie Nuage de tissage.
  • Pour les machines de base, le mécanisme de convergence doit être déclaratif et autonome. D'après notre propre expérience, nous pouvons dire que Terraform la plus proche de cette définition, mais nécessite toujours un contrôle humain. En ce sens, GitOps étend la tradition de l’Infrastructure as Code.

GitOps combine Git avec l'excellent moteur de convergence de Kubernetes pour fournir un modèle d'exploitation.

GitOps nous permet de dire : Seuls les systèmes qui peuvent être décrits et observés peuvent être automatisés et contrôlés.

GitOps est destiné à l'ensemble de la pile native du cloud (par exemple, Terraform, etc.)

GitOps n'est pas seulement Kubernetes. Nous voulons que l’ensemble du système soit piloté de manière déclarative et utilise la convergence. Par l'ensemble du système, nous entendons un ensemble d'environnements fonctionnant avec Kubernetes - par exemple, « dev cluster 1 », « production », etc. Chaque environnement comprend des machines, des clusters, des applications, ainsi que des interfaces pour les services externes qui fournissent des données, une surveillance et etc

Remarquez à quel point Terraform est important pour le problème d'amorçage dans ce cas. Kubernetes doit être déployé quelque part, et l'utilisation de Terraform signifie que nous pouvons appliquer les mêmes workflows GitOps pour créer la couche de contrôle qui sous-tend Kubernetes et les applications. Il s’agit d’une bonne pratique utile.

L'accent est mis sur l'application des concepts GitOps aux couches supérieures à Kubernetes. Il existe actuellement des solutions de type GitOps pour Istio, Helm, Ksonnet, OpenFaaS et Kubeflow, ainsi que, par exemple, pour Pulumi, qui créent une couche de développement d'applications cloud natives.

Kubernetes CI/CD : comparer GitOps avec d'autres approches

Comme indiqué, GitOps, c'est deux choses :

  1. Le modèle opérationnel pour Kubernetes et cloud natif décrit ci-dessus.
  2. La voie vers un environnement de gestion d’applications centré sur les développeurs.

Pour beaucoup, GitOps est avant tout un workflow basé sur les push Git. Nous l'aimons aussi. Mais ce n’est pas tout : intéressons-nous maintenant aux pipelines CI/CD.

GitOps permet un déploiement continu (CD) pour Kubernetes

GitOps offre un mécanisme de déploiement continu qui élimine le besoin de « systèmes de gestion de déploiement » distincts. Kubernetes fait tout le travail à votre place.

  • La mise à jour de l'application nécessite une mise à jour dans Git. Il s'agit d'une mise à jour transactionnelle vers l'état souhaité. Le « déploiement » est ensuite effectué au sein du cluster par Kubernetes lui-même sur la base de la description mise à jour.
  • En raison de la nature du fonctionnement de Kubernetes, ces mises à jour sont convergentes. Cela fournit un mécanisme de déploiement continu dans lequel toutes les mises à jour sont atomiques.
  • Note: Nuage de tissage propose un opérateur GitOps qui intègre Git et Kubernetes et permet d'effectuer le CD en conciliant l'état souhaité et actuel du cluster.

Sans Kubectl ni scripts

Vous devez éviter d'utiliser Kubectl pour mettre à jour votre cluster, et surtout éviter d'utiliser des scripts pour regrouper les commandes kubectl. Au lieu de cela, avec le pipeline GitOps, un utilisateur peut mettre à jour son cluster Kubernetes via Git.

Les avantages incluent :

  1. Droite. Un groupe de mises à jour peut être appliqué, convergé et finalement validé, nous rapprochant de l'objectif du déploiement atomique. En revanche, l’utilisation de scripts n’offre aucune garantie de convergence (nous y reviendrons plus loin).
  2. sécurité. Citation Kelsey Hightower : « Limitez l’accès à votre cluster Kubernetes aux outils d’automatisation et aux administrateurs responsables de son débogage ou de sa maintenance. » voir également ma publication sur la sécurité et le respect des spécifications techniques, ainsi que article sur le piratage de Homebrew en volant les informations d'identification d'un script Jenkins écrit avec négligence.
  3. ользовательский опыт. Kubectl expose les mécanismes du modèle objet Kubernetes, qui sont assez complexes. Idéalement, les utilisateurs devraient interagir avec le système à un niveau d’abstraction plus élevé. Ici, je ferai à nouveau référence à Kelsey et recommanderai de regarder un tel CV.

Différence entre CI et CD

GitOps améliore les modèles CI/CD existants.

Un serveur CI moderne est un outil d'orchestration. Il s’agit notamment d’un outil d’orchestration des pipelines CI. Ceux-ci incluent la création, le test, la fusion vers le tronc, etc. Les serveurs CI automatisent la gestion de pipelines complexes en plusieurs étapes. Une tentation courante consiste à créer un script pour un ensemble de mises à jour Kubernetes et à l'exécuter dans le cadre d'un pipeline pour appliquer les modifications au cluster. C’est d’ailleurs ce que font de nombreux experts. Cependant, ce n’est pas optimal, et voici pourquoi.

CI doit être utilisé pour envoyer des mises à jour vers le tronc, et le cluster Kubernetes doit se modifier en fonction de ces mises à jour pour gérer le CD en interne. Nous l'appelons modèle à tirer pour CD, contrairement au modèle push CI. Le CD fait partie orchestration d'exécution.

Pourquoi les serveurs CI ne devraient pas créer de CD via des mises à jour directes dans Kubernetes

N'utilisez pas de serveur CI pour orchestrer les mises à jour directes vers Kubernetes sous la forme d'un ensemble de tâches CI. C'est de l'anti-modèle dont nous parlons Déjà dit sur votre blog.

Revenons à Alice et Bob.

À quels problèmes ont-ils été confrontés ? Le serveur CI de Bob applique les modifications au cluster, mais s'il plante au cours du processus, Bob ne saura pas dans quel état se trouve (ou devrait être) le cluster ni comment le réparer. Il en va de même en cas de succès.

Supposons que l'équipe de Bob ait créé une nouvelle image, puis appliqué des correctifs à ses déploiements pour déployer l'image (le tout à partir du pipeline CI).

Si l'image se construit normalement, mais que le pipeline échoue, l'équipe devra comprendre :

  • La mise à jour a-t-elle été déployée ?
  • Allons-nous lancer une nouvelle version ? Cela entraînera-t-il des effets secondaires inutiles - avec la possibilité d'avoir deux versions de la même image immuable ?
  • Devons-nous attendre la prochaine mise à jour avant de lancer le build ?
  • Qu’est-ce qui n’a pas fonctionné exactement ? Quelles étapes doivent être répétées (et lesquelles peuvent être répétées en toute sécurité) ?

L'établissement d'un workflow basé sur Git ne garantit pas que l'équipe de Bob ne rencontrera pas ces problèmes. Ils peuvent toujours faire une erreur avec le commit push, la balise ou tout autre paramètre ; cependant, cette approche est encore beaucoup plus proche d’une approche explicite du tout ou rien.

Pour résumer, voici pourquoi les serveurs CI ne devraient pas gérer les CD :

  • Les scripts de mise à jour ne sont pas toujours déterministes ; Il est facile de faire des erreurs.
  • Les serveurs CI ne convergent pas vers le modèle de cluster déclaratif.
  • Il est difficile de garantir l’idempotence. Les utilisateurs doivent comprendre la sémantique profonde du système.
  • Il est plus difficile de se remettre d’un échec partiel.

Remarque sur Helm : Si vous souhaitez utiliser Helm, nous vous recommandons de le combiner avec un opérateur GitOps tel que Casque de flux. Cela contribuera à garantir la convergence. Helm lui-même n’est ni déterministe ni atomique.

GitOps comme meilleur moyen de mettre en œuvre la livraison continue pour Kubernetes

L'équipe d'Alice et Bob implémente GitOps et découvre qu'il est devenu beaucoup plus facile de travailler avec des produits logiciels, de maintenir des performances et une stabilité élevées. Terminons cet article par une illustration montrant à quoi ressemble leur nouvelle approche. Gardez à l’esprit que nous parlons principalement d’applications et de services, mais GitOps peut être utilisé pour gérer une plateforme entière.

Modèle opérationnel pour Kubernetes

Regardez le diagramme suivant. Il présente Git et le référentiel d'images de conteneur comme des ressources partagées pour deux cycles de vie orchestrés :

  • Un pipeline d'intégration continue qui lit et écrit des fichiers sur Git et peut mettre à jour un référentiel d'images de conteneurs.
  • Un pipeline Runtime GitOps qui combine déploiement, gestion et observabilité. Il lit et écrit des fichiers sur Git et peut télécharger des images de conteneurs.

Quelles sont les principales conclusions ?

  1. Séparation des préoccupations: Veuillez noter que les deux pipelines ne peuvent communiquer qu'en mettant à jour Git ou le référentiel d'images. En d'autres termes, il existe un pare-feu entre CI et l'environnement d'exécution. Nous l'appelons le « pare-feu d'immuabilité » (pare-feu d'immuabilité), puisque toutes les mises à jour du référentiel créent de nouvelles versions. Pour plus d'informations sur ce sujet, reportez-vous aux diapositives 72 à 87. cette présentation.
  2. Vous pouvez utiliser n'importe quel serveur CI et Git: GitOps fonctionne avec n'importe quel composant. Vous pouvez continuer à utiliser vos serveurs CI et Git, référentiels d'images et suites de tests préférés. Presque tous les autres outils de livraison continue du marché nécessitent leur propre serveur CI/Git ou référentiel d'images. Cela peut devenir un facteur limitant dans le développement du cloud natif. Avec GitOps, vous pouvez utiliser des outils familiers.
  3. Les événements comme outil d'intégration: Dès que les données dans Git sont mises à jour, Weave Flux (ou l'opérateur Weave Cloud) informe le runtime. Chaque fois que Kubernetes accepte un ensemble de modifications, Git est mis à jour. Cela fournit un modèle d'intégration simple pour organiser les flux de travail pour GitOps, comme indiqué ci-dessous.

Conclusion

GitOps offre les solides garanties de mise à jour requises par tout outil CI/CD moderne :

  • automatisation;
  • convergence;
  • idempotence;
  • déterminisme.

Ceci est important car il offre un modèle opérationnel aux développeurs natifs du cloud.

  • Les outils traditionnels de gestion et de surveillance des systèmes sont associés aux équipes opérationnelles opérant au sein d'un runbook (un ensemble de procédures et d'opérations de routine - traduction approximative.), lié à un déploiement spécifique.
  • Dans la gestion cloud native, les outils d'observabilité constituent le meilleur moyen de mesurer les résultats des déploiements afin que l'équipe de développement puisse réagir rapidement.

Imaginez de nombreux clusters dispersés dans différents cloud et de nombreux services avec leurs propres équipes et plans de déploiement. GitOps propose un modèle invariant à l'échelle pour gérer toute cette abondance.

PS du traducteur

A lire aussi sur notre blog :

Seuls les utilisateurs enregistrés peuvent participer à l'enquête. se connecters'il te plait.

Connaissiez-vous GitOps avant que ces deux traductions n’apparaissent sur Habré ?

  • Oui, je savais tout

  • Seulement superficiellement

  • Aucun

35 utilisateurs ont voté. 10 utilisateurs se sont abstenus.

Source: habr.com

Ajouter un commentaire