Migrer le Blobstore App Engine vers Cloud Storage

Ce guide explique comment effectuer une migration de Blobstore d'App Engine vers Cloud Storage.

Cloud Storage est semblable au service Blobstore d'App Engine, en ce sens que vous pouvez utiliser Cloud Storage pour diffuser des objets de données volumineux (blobs), tels que des fichiers vidéo ou image, et pour permettre à vos utilisateurs d'importer des fichiers de données volumineux. Bien que le service Blobstore d'App Engine soit accessible uniquement via les anciens services groupés App Engine, Cloud Storage est un produit Google Cloud autonome accessible via les bibliothèques clientes Cloud. Cloud Storage propose une application de stockage d'objets plus moderne et vous donne la possibilité de migrer vers Cloud Run ou une autre plate-forme d'hébergement d'applications Google Cloud par la suite.

Pour les projets Google Cloud créés après novembre 2016, Blobstore utilise des buckets Cloud Storage en arrière-plan. Cela signifie que lorsque vous migrez votre application vers Cloud Storage, tous vos objets et autorisations existants dans ces buckets Cloud Storage restent inchangés. Vous pouvez également commencer à accéder à ces buckets existants à l'aide des bibliothèques clientes Cloud pour Cloud Storage.

Principales différences et similitudes

Cloud Storage exclut les dépendances et limitations suivantes du service Blobstore :

  • L'API Blobstore pour Python 2 dépend d'une application Web.
  • L'API Blobstore pour Python 3 utilise des classes utilitaires pour faire appel à des gestionnaires Blobstore.
  • Pour Blobstore, le nombre maximal de fichiers pouvant être importés dans Blobstore est de 500. Vous pouvez créer autant d'objets que vous le souhaitez dans un bucket Cloud Storage.

Cloud Storage n'est pas compatible avec :

  • Classes du gestionnaire Blobstore
  • Objets Blobstore

Similarités de Cloud Storage et Blobstore d'App Engine :

  • Est en mesure de lire et d'écrire des objets de données volumineux dans un environnement d'exécution, ainsi que de stocker et de diffuser des objets de données volumineux statiques, tels que des films, des images ou tout autre contenu statique. La taille maximale des objets pour Cloud Storage est de 5 Tio.
  • Permet de stocker des objets dans un bucket Cloud Storage.
  • Dispose d'une version gratuite.

Avant de commencer

  • Vous devez examiner et comprendre les tarifs et les quotas de Cloud Storage :
    • Cloud Storage est un service payant qui possède sa propre tarification pour le stockage de données en fonction de la classe de stockage de vos données et de l'emplacement de vos buckets.
    • Les quotas Cloud Storage présentent certaines différences par rapport aux quotas et limites Blobstore d'App Engine, qui peuvent avoir une incidence sur vos quotas de requêtes App Engine.
  • Vérifiez que vous disposez d'une application App Engine Python 2 ou Python 3 utilisant Blobstore.
  • Les exemples de ce guide montrent une application qui migre vers Cloud Storage à l'aide du framework Flask. Notez que vous pouvez utiliser n'importe quel framework Web, y compris sur webapp2, lorsque vous migrez vers Cloud Storage.

Présentation

De manière générale, le processus de migration vers Cloud Storage à partir de Blobstore App Engine comprend les étapes suivantes :

  1. Mettre à jour les fichiers de configuration
  2. Mettre à jour votre application Python :
    • Mettre à jour votre framework Web
    • Importer et initialiser Cloud Storage
    • Mettre à jour les gestionnaires Blobstore
    • Facultatif : Mettre à jour votre modèle de données si vous utilisez Cloud NDB ou App Engine NDB
  3. Tester et déployer votre application

Mettre à jour les fichiers de configuration

Avant de modifier le code de votre application pour passer de Blobstore à Cloud Storage, mettez à jour vos fichiers de configuration afin d'utiliser la bibliothèque Cloud Storage.

  1. Mettez à jour le fichier app.yaml. Suivez les instructions correspondant à votre version de Python :

    Python 2

    Pour les applications Python 2 :

    1. Supprimez la section handlers et toutes les dépendances d'applications Web inutiles dans la section libraries.
    2. Si vous utilisez les bibliothèques clientes Cloud, ajoutez les dernières versions des bibliothèques grpcio et setuptools.
    3. Ajoutez la bibliothèque ssl, car celle-ci est requise par Cloud Storage.

    Voici un exemple de fichier app.yaml dans lequel les modifications ont été apportées :

    runtime: python27
    threadsafe: yes
    api_version: 1
    
    handlers:
    - url: /.*
      script: main.app
    
    libraries:
    - name: grpcio
      version: latest
    - name: setuptools
      version: latest
    - name: ssl
      version: latest
    

    Python 3

    Pour les applications Python 3, supprimez toutes les lignes, à l'exception de l'élément runtime. Exemple :

    runtime: python310 # or another support version
    

    L'environnement d'exécution Python 3 installe automatiquement les bibliothèques. Vous n'avez donc pas besoin de spécifier des bibliothèques intégrées à partir de l'environnement d'exécution Python 2 précédent. Si votre application Python 3 utilise d'autres anciens services groupés lors de la migration vers Cloud Storage, laissez le fichier app.yaml tel quel.

  2. Mettez à jour le fichier requirements.txt. Suivez les instructions correspondant à votre version de Python :

    Python 2

    Ajoutez les bibliothèques clientes Cloud pour Cloud Storage à votre liste de dépendances dans le fichier requirements.txt.

    google-cloud-storage
    

    Exécutez ensuite pip install -t lib -r requirements.txt pour mettre à jour la liste des bibliothèques disponibles pour votre application.

    Python 3

    Ajoutez les bibliothèques clientes Cloud pour Cloud Storage à votre liste de dépendances dans le fichier requirements.txt.

    google-cloud-storage
    

    App Engine installe automatiquement ces dépendances dans l'environnement d'exécution Python 3 lors du déploiement de l'application. Par conséquent, supprimez le dossier lib s'il existe.

  3. Pour les applications Python 2, si votre application utilise des bibliothèques intégrées ou copiées, vous devez spécifier ces chemins d'accès dans le fichier appengine_config.py :

    import pkg_resources
    from google.appengine.ext import vendor
    
    # Set PATH to your libraries folder.
    PATH = 'lib'
    # Add libraries installed in the PATH folder.
    vendor.add(PATH)
    # Add libraries to pkg_resources working set to find the distribution.
    pkg_resources.working_set.add_entry(PATH)
    

Mettre à jour votre application Python

Après avoir modifié vos fichiers de configuration, mettez à jour votre application Python.

Mettre à jour votre framework Web Python 2

Pour les applications Python 2 qui utilisent le framework webapp2, il est recommandé de migrer le framework obsolète webapp2. Pour connaître la date de fin de l'assistance Python 2, consultez le calendrier de compatibilité des environnements d'exécution.

Vous pouvez migrer vers un autre framework Web tel que Flask, Django ou WSGI. Étant donné que Cloud Storage exclut les dépendances sur webapp2 et que les gestionnaires Blobstore ne sont pas compatibles, vous pouvez supprimer ou remplacer d'autres bibliothèques liées à l'appplication Web.

Si vous choisissez de continuer à utiliser webapp2, notez que les exemples de ce guide utilisent Cloud Storage avec Flask.

Si vous prévoyez d'utiliser les services Google Cloud en plus de Cloud Storage ou pour accéder aux dernières versions d'exécution, vous devez envisager de mettre à niveau votre application vers l'environnement d'exécution Python 3. Pour en savoir plus, consultez la Présentation de la migration de Python 2 vers Python 3.

Importer et initialiser Cloud Storage

Modifiez les fichiers de votre application en mettant à jour les lignes d'importation et d'initialisation :

  1. Supprimez les instructions d'importation Blobstore, comme suit :

    import webapp2
    from google.appengine.ext import blobstore
    from google.appengine.ext.webapp import blobstore_handlers
    
  2. Ajoutez les instructions d'importation pour Cloud Storage et les bibliothèques d'authentification Google, comme suit :

    import io
    from flask import (Flask, abort, redirect, render_template,
    request, send_file, url_for)
    from google.cloud import storage
    import google.auth
    

    La bibliothèque d'authentification Google est nécessaire pour obtenir le même ID de projet que celui utilisé dans Blobstore pour Cloud Storage. Importez d'autres bibliothèques telles que Cloud NBD, le cas échéant.

  3. Créez un client pour Cloud Storage et spécifiez le bucket utilisé dans Blobstore. Exemple :

    gcs_client = storage.Client()
    _, PROJECT_ID = google.auth.default()
    BUCKET = '%s.appspot.com' % PROJECT_ID
    

    Pour les projets Google Cloud après novembre 2016, Blobstore écrit dans un bucket Cloud Storage nommé d'après l'URL de votre application et suit le format PROJECT_ID.appspot.com. Vous utilisez l'authentification Google pour obtenir l'ID du projet afin de spécifier le bucket Cloud Storage utilisé pour stocker les blobs dans Blobstore.

Mettre à jour les gestionnaires Blobstore

Comme Cloud Storage n'est pas compatible avec les gestionnaires d'importation et de téléchargement de Blobstore, vous devez utiliser une combinaison de fonctionnalités Cloud Storage, de module de bibliothèque standard io, de framework Web et d'utilitaires Python pour importer et télécharger des objets (blobs) dans Cloud Storage.

Voici comment mettre à jour les gestionnaires Blobstore en utilisant Flask comme exemple de framework Web :

  1. Remplacez les classes du gestionnaire d'importation Blobstore par une fonction d'importation dans Flask. Suivez les instructions correspondant à votre version de Python :

    Python 2

    Les gestionnaires Blobstore dans Python 2 sont des classes webapp2, comme illustré dans l'exemple Blobstore suivant :

    class UploadHandler(blobstore_handlers.BlobstoreUploadHandler):
        'Upload blob (POST) handler'
        def post(self):
            uploads = self.get_uploads()
            blob_id = uploads[0].key() if uploads else None
            store_visit(self.request.remote_addr, self.request.user_agent, blob_id)
            self.redirect('/', code=307)
    ...
    app = webapp2.WSGIApplication([
        ('/', MainHandler),
        ('/upload', UploadHandler),
        ('/view/([^/]+)?', ViewBlobHandler),
    ], debug=True)
    

    Pour utiliser Cloud Storage :

    1. Remplacez la classe d'importation de l'application Web par une fonction d'importation Flask.
    2. Remplacez le gestionnaire d'importation et le routage par une méthode Flask POST décorée avec du routage.

    Exemple de code mis à jour :

    @app.route('/upload', methods=['POST'])
    def upload():
        'Upload blob (POST) handler'
        fname = None
        upload = request.files.get('file', None)
        if upload:
            fname = secure_filename(upload.filename)
            blob = gcs_client.bucket(BUCKET).blob(fname)
            blob.upload_from_file(upload, content_type=upload.content_type)
        store_visit(request.remote_addr, request.user_agent, fname)
        return redirect(url_for('root'), code=307)
    

    Dans l'exemple de code Cloud Storage mis à jour, l'application identifie désormais les artefacts d'objet par son nom d'objet (fname) au lieu de blob_id. Le routage se produit également au bas du fichier d'application.

    Pour obtenir l'objet importé, la méthode get_uploads() de Blobstore est remplacée par la méthode request.files.get() de Flask. Dans Flask, vous pouvez utiliser la méthode secure_filename() pour obtenir un nom sans caractère de chemin d'accès, tel que / pour le fichier, et identifier l'objet à l'aide de gcs_client.bucket(BUCKET).blob(fname) pour spécifier le nom du bucket et le nom de l'objet.

    L'appel upload_from_file() de Cloud Storage effectue l'importation, comme indiqué dans l'exemple mis à jour.

    Python 3

    La classe de gestionnaire d'importation de Blobstore pour Python 3 est une classe utilitaire et nécessite d'utiliser le dictionnaire WSGI environ comme paramètre d'entrée, comme indiqué dans l'exemple suivant de Blobstore :

    class UploadHandler(blobstore.BlobstoreUploadHandler):
        'Upload blob (POST) handler'
        def post(self):
            uploads = self.get_uploads(request.environ)
            if uploads:
                blob_id = uploads[0].key()
                store_visit(request.remote_addr, request.user_agent, blob_id)
            return redirect('/', code=307)
    ...
    @app.route('/upload', methods=['POST'])
    def upload():
        """Upload handler called by blobstore when a blob is uploaded in the test."""
        return UploadHandler().post()
    

    Pour utiliser Cloud Storage, remplacez la méthode get_uploads(request.environ) de Blobstore par la méthode request.files.get() de Flask.

    Exemple de code mis à jour :

    @app.route('/upload', methods=['POST'])
    def upload():
        'Upload blob (POST) handler'
        fname = None
        upload = request.files.get('file', None)
        if upload:
            fname = secure_filename(upload.filename)
            blob = gcs_client.bucket(BUCKET).blob(fname)
            blob.upload_from_file(upload, content_type=upload.content_type)
        store_visit(request.remote_addr, request.user_agent, fname)
        return redirect(url_for('root'), code=307)
    

    Dans l'exemple de code Cloud Storage mis à jour, l'application identifie désormais les artefacts d'objet par son nom d'objet (fname) au lieu de blob_id. Le routage se produit également au bas du fichier d'application.

    Pour obtenir l'objet importé, la méthode get_uploads() de Blobstore est remplacée par la méthode request.files.get() de Flask. Dans Flask, vous pouvez utiliser la méthode secure_filename() pour obtenir un nom sans caractère de chemin d'accès, tel que / pour le fichier, et identifier l'objet à l'aide de gcs_client.bucket(BUCKET).blob(fname) pour spécifier le nom du bucket et le nom de l'objet.

    La méthode upload_from_file() de Cloud Storage effectue l'importation, comme indiqué dans l'exemple mis à jour.

  2. Remplacez les classes du gestionnaire de téléchargement de Blobstore par une fonction de téléchargement dans Flask. Suivez les instructions correspondant à votre version de Python :

    Python 2

    L'exemple suivant du gestionnaire de téléchargement illustre l'utilisation de la classe BlobstoreDownloadHandler, qui utilise webapp2 :

    class ViewBlobHandler(blobstore_handlers.BlobstoreDownloadHandler):
        'view uploaded blob (GET) handler'
        def get(self, blob_key):
            self.send_blob(blob_key) if blobstore.get(blob_key) else self.error(404)
    ...
    app = webapp2.WSGIApplication([
        ('/', MainHandler),
        ('/upload', UploadHandler),
        ('/view/([^/]+)?', ViewBlobHandler),
    ], debug=True)
    

    Pour utiliser Cloud Storage :

    1. Mettez à jour la méthode send_blob() de Blobstore pour utiliser la méthode download_as_bytes() de Cloud Storage.
    2. Remplacez le routage de webapp2 par Flask.

    Exemple de code mis à jour :

    @app.route('/view/<path:fname>')
    def view(fname):
        'view uploaded blob (GET) handler'
        blob = gcs_client.bucket(BUCKET).blob(fname)
        try:
            media = blob.download_as_bytes()
        except exceptions.NotFound:
            abort(404)
        return send_file(io.BytesIO(media), mimetype=blob.content_type)
    

    Dans l'exemple de code Cloud Storage mis à jour, Flask décore la route dans la fonction Flask et identifie l'objet à l'aide de '/view/<path:fname>'. Cloud Storage identifie l'objet blob par son nom et le nom du bucket, et utilise la méthode download_as_bytes() pour télécharger l'objet en tant qu'octets, plutôt que d'utiliser la méthode send_blob de Blobstore. Si l'artefact est introuvable, l'application renvoie une erreur HTTP 404.

    Python 3

    À l'instar du gestionnaire d'importation, la classe du gestionnaire de téléchargement de Blobstore pour Python 3 est une classe utilitaire et nécessite d'utiliser le dictionnaire WSGI environ comme paramètre d'entrée, comme illustré dans l'exemple Blobstore suivant :

    class ViewBlobHandler(blobstore.BlobstoreDownloadHandler):
        'view uploaded blob (GET) handler'
        def get(self, blob_key):
            if not blobstore.get(blob_key):
                return "Photo key not found", 404
            else:
                headers = self.send_blob(request.environ, blob_key)
    
            # Prevent Flask from setting a default content-type.
            # GAE sets it to a guessed type if the header is not set.
            headers['Content-Type'] = None
            return '', headers
    ...
    @app.route('/view/<blob_key>')
    def view_photo(blob_key):
        """View photo given a key."""
        return ViewBlobHandler().get(blob_key)
    

    Pour utiliser Cloud Storage, remplacez la méthode send_blob(request.environ, blob_key) de Blobstore par la méthode blob.download_as_bytes() de Cloud Storage.

    Exemple de code mis à jour :

    @app.route('/view/<path:fname>')
    def view(fname):
        'view uploaded blob (GET) handler'
        blob = gcs_client.bucket(BUCKET).blob(fname)
        try:
            media = blob.download_as_bytes()
        except exceptions.NotFound:
            abort(404)
        return send_file(io.BytesIO(media), mimetype=blob.content_type)
    

    Dans l'exemple de code Cloud Storage mis à jour, blob_key est remplacé par fname et Flask identifie l'objet à l'aide de l'URL '/view/<path:fname>'. La méthode gcs_client.bucket(BUCKET).blob(fname) permet de localiser le nom du fichier et le nom du bucket. La méthode download_as_bytes() de Cloud Storage télécharge l'objet sous forme d'octets, au lieu d'utiliser la méthode send_blob() à partir de Blobstore.

  3. Si votre application utilise un gestionnaire principal, remplacez la classe MainHandler par la fonction root() dans Flask. Suivez les instructions correspondant à votre version de Python :

    Python 2

    Voici un exemple d'utilisation de la classe MainHandler de Blobstore :

    class MainHandler(BaseHandler):
        'main application (GET/POST) handler'
        def get(self):
            self.render_response('index.html',
                    upload_url=blobstore.create_upload_url('/upload'))
    
        def post(self):
            visits = fetch_visits(10)
            self.render_response('index.html', visits=visits)
    
    app = webapp2.WSGIApplication([
        ('/', MainHandler),
        ('/upload', UploadHandler),
        ('/view/([^/]+)?', ViewBlobHandler),
    ], debug=True)
    

    Pour utiliser Cloud Storage :

    1. Supprimez la classe MainHandler(BaseHandler), car Flask gère le routage pour vous.
    2. Simplifiez le code Blobstore avec Flask.
    3. Supprimez le routage webapp à la fin.

    Exemple de code mis à jour :

    @app.route('/', methods=['GET', 'POST'])
    def root():
        'main application (GET/POST) handler'
        context = {}
        if request.method == 'GET':
            context['upload_url'] = url_for('upload')
        else:
            context['visits'] = fetch_visits(10)
        return render_template('index.html', **context)
    

    Python 3

    Si vous avez utilisé Flask, vous n'aurez pas de classe MainHandler, mais votre fonction racine Flask doit être mise à jour si le blobstore est utilisé. L'exemple suivant utilise la fonction blobstore.create_upload_url('/upload') :

    @app.route('/', methods=['GET', 'POST'])
    def root():
        'main application (GET/POST) handler'
        context = {}
        if request.method == 'GET':
            context['upload_url'] = blobstore.create_upload_url('/upload')
        else:
            context['visits'] = fetch_visits(10)
        return render_template('index.html', **context)
    

    Pour utiliser Cloud Storage, remplacez la fonction blobstore.create_upload_url('/upload') par la méthode url_for() de Flask afin d'obtenir l'URL de la fonction upload().

    Exemple de code mis à jour :

    @app.route('/', methods=['GET', 'POST'])
    def root():
        'main application (GET/POST) handler'
        context = {}
        if request.method == 'GET':
            context['upload_url'] = url_for('upload') # Updated to use url_for
        else:
            context['visits'] = fetch_visits(10)
        return render_template('index.html', **context)
    

Tester et déployer votre application

Le serveur de développement local vous permet de vérifier que votre application s'exécute, mais vous ne pouvez pas tester Cloud Storage tant que vous n'avez pas déployé une nouvelle version, car toutes les requêtes Cloud Storage doivent être envoyées par Internet à un bucket Cloud Storage. Consultez la page Tester et déployer votre application pour savoir comment l'exécuter localement. Déployez ensuite une nouvelle version pour vérifier que l'application apparaît comme auparavant.

Applications utilisant App Engine NDB ou Cloud NDB

Vous devez mettre à jour votre modèle de données Datastore si votre application utilise App Engine NDB ou Cloud NDB pour inclure des propriétés liées au Blobstore.

Mettre à jour votre modèle de données

Comme les propriétés BlobKey de NDB ne sont pas acceptées par Cloud Storage, vous devez modifier les lignes liées au Blobstore pour utiliser des équivalents intégrés à partir de NDB, des frameworks Web ou d'ailleurs.

Pour mettre à jour votre modèle de données, procédez comme suit :

  1. Recherchez les lignes qui utilisent BlobKey dans le modèle de données, comme suit :

    class Visit(ndb.Model):
        'Visit entity registers visitor IP address & timestamp'
        visitor   = ndb.StringProperty()
        timestamp = ndb.DateTimeProperty(auto_now_add=True)
        file_blob = ndb.BlobKeyProperty()
    
  2. Remplacez ndb.BlobKeyProperty() par ndb.StringProperty() :

    class Visit(ndb.Model):
        'Visit entity registers visitor IP address & timestamp'
        visitor   = ndb.StringProperty()
        timestamp = ndb.DateTimeProperty(auto_now_add=True)
        file_blob = ndb.StringProperty() # Modified from ndb.BlobKeyProperty()
    
  3. Si vous effectuez également une mise à niveau d'App Engine NDB vers Cloud NDB pendant la migration, consultez le guide de migration de Cloud NDB pour savoir comment refactoriser le code NDB afin d'utiliser les gestionnaires de contexte Python.

Rétrocompatibilité pour le modèle de données Datastore

Dans la section précédente, le remplacement de ndb.BlobKeyProperty par ndb.StringProperty a rendu l'application incompatible avec les versions antérieures, ce qui signifie que l'application ne pourra pas traiter les entrées plus anciennes créées par Blobstore. Si vous devez conserver d'anciennes données, créez un champ supplémentaire pour les nouvelles entrées Cloud Storage au lieu de mettre à jour le champ ndb.BlobKeyProperty, puis créez une fonction pour normaliser les données.

Dans les exemples des sections précédentes, apportez les modifications suivantes :

  1. Créez deux champs de propriété distincts lors de la définition du modèle de données. Utilisez la propriété file_blob pour identifier les objets créés par Blobstore et la propriété file_gcs pour identifier les objets créés par Cloud Storage :

    class Visit(ndb.Model):
        'Visit entity registers visitor IP address & timestamp'
        visitor   = ndb.StringProperty()
        timestamp = ndb.DateTimeProperty(auto_now_add=True)
        file_blob = ndb.BlobKeyProperty()  # backwards-compatibility
        file_gcs  = ndb.StringProperty()
    
  2. Recherchez les lignes faisant référence à de nouvelles visites, comme suit :

    def store_visit(remote_addr, user_agent, upload_key):
        'create new Visit entity in Datastore'
        with ds_client.context():
            Visit(visitor='{}: {}'.format(remote_addr, user_agent),
                    file_blob=upload_key).put()
    
  3. Modifiez votre code de sorte que file_gcs soit utilisé pour les entrées récentes. Exemple :

    def store_visit(remote_addr, user_agent, upload_key):
        'create new Visit entity in Datastore'
        with ds_client.context():
            Visit(visitor='{}: {}'.format(remote_addr, user_agent),
                    file_gcs=upload_key).put() # change file_blob to file_gcs for new requests
    
  4. Créez une fonction pour normaliser les données. L'exemple suivant illustre l'utilisation de l'ETL (extract, transform, load) pour parcourir toutes les visites, et prend les données de visiteur et d'horodatage pour vérifier si file_gcs ou file_gcs existe :

    def etl_visits(visits):
        return [{
                'visitor': v.visitor,
                'timestamp': v.timestamp,
                'file_blob': v.file_gcs if hasattr(v, 'file_gcs') \
                        and v.file_gcs else v.file_blob
                } for v in visits]
    
  5. Recherchez la ligne faisant référence à la fonction fetch_visits() :

    @app.route('/', methods=['GET', 'POST'])
    def root():
        'main application (GET/POST) handler'
        context = {}
        if request.method == 'GET':
            context['upload_url'] = url_for('upload')
        else:
            context['visits'] = fetch_visits(10)
        return render_template('index.html', **context)
    
  6. Encapsulez fetch_visits() dans la fonction etl_visits(), par exemple :

    @app.route('/', methods=['GET', 'POST'])
    def root():
        'main application (GET/POST) handler'
        context = {}
        if request.method == 'GET':
            context['upload_url'] = url_for('upload')
        else:
            context['visits'] = etl_visits(fetch_visits(10)) # etl_visits wraps around fetch_visits
        return render_template('index.html', **context)
    

Exemples

Étapes suivantes