Un bot xkcd en Go - épisode 2

Où nous dotons notre bot de sa "killer feature"

Ceci est le deuxième volet d’une trilogie sur la réalisation d’un petit projet non-trivial en Go, et de son déploiement dans un cluster Kubernetes. Dans l’épisode précédent, nous sommes partis d’une idée formulée sur le serveur Discord de ZdS et avons implémenté, testé et déployé notre bot Discord dans le cloud.

Dans ce billet, nous allons ajouter à ce bot sa killer feature, c’est-à-dire LA fonctionnalité qui va servir à le différencier de tous les autres projets similaires. Autrement dit, nous allons donner à nos utilisateurs une bonne raison d’inviter notre bot xkcd plutôt qu’un autre sur leur serveur ! :)

Ce que nous allons réaliser

Si l’on fait une recherche sur top.gg pour trouver un bot qui interagit avec xkcd, nous pouvons remarquer que la plupart des projets qui remontent implémentent pas mal de features similaires les uns par rapport aux autres.

  • Tous ces projets (ou presque) permettent de récupérer un xkcd à partir de son numéro, ou bien de récupérer le dernier paru.
  • Certains rajoutent une fonctionnalité random, permettant d’afficher un xkcd au hasard.
  • Certains permettent de faire des recherches, mais de façon plus ou moins laborieuse.

Cela dit, lorsque l’on y regarde de plus près, les bots les plus complets sont aussi les plus complexes à utiliser. Si nous voulons nous différencier, il faut que nous soignions l’expérience utilisateur et la rendions la plus intuitive possible. Sur Discord, de nombreux bots utilisent une syntaxe avec un préfixe arbitrairement complexe. C’est d’ailleurs ce que nous avons implémenté jusque là, mais est-ce vraiment la façon la plus simple d’interagir avec un bot ?

En ce qui me concerne, je suis partisan d’adopter une logique simple : pour interagir avec notre bot, il faut que ce soit aussi simple que @xkcd <ce que je veux>. Typiquement, ce que nous allons viser dans ce billet :

  • @xkcd 386 doit retourner le xkcd #386,
  • @xkcd latest doit retourner le tout dernier xkcd,
  • @xkcd random doit retourner un xkcd au hasard,
  • @xkcd help doit bien sûr retourner un message d’aide.

Pour ce qui est de rechercher un xkcd, il faut que nous soyons capables de répondre de façon intuitive au besoin le plus courant : lorsque quelqu’un se souvient de la punchline ou d’un détail de l’une de ces BD (donc dans les dialogues, le titres ou à la rigueur le texte alternatif), il doit pouvoir le retrouver en décrivant ces mots-clés.

Par exemple si je me souviens de cette fameuse BD à propos des injections SQL, où la mère répond qu’elle surnomme son fils "Bobby tables", je devrais pouvoir retrouver celle-ci en tapant @xkcd bobby tables. Contrairement à certain des bots sus-cités, il est hors de question de confronter l’utilisateur à une liste de résultats qu’il doit parcourir : notre bot doit répondre avec la BD qui correspond le mieux à la recherche, et donner un moyen à l’utilisateur de préciser sa demande si ce n’est pas ce qu’il voulait. En somme, nous avons besoin de gérer la syntaxe classique des moteurs de recherche : @xkcd video games -20 ou @xkcd video games -ferret doit permettre de retrouver ce xkcd à propos des jeux vidéos dont on se souvient, et qui n’est pas le #20 où il est question d’un furet, et où l’un des personnages dit à un moment "Let’s go play video games".

Enfin, par soucis du détail, lorsque le bot ne sait pas quoi répondre, cela ne veut plus dire la même chose qu’avant : ce n’est plus une erreur, mais simplement qu’il n’a rien trouvé de satisfaisant. Pour cette raison, plutôt que de réagir à la commande de l’utilisateur avec un 💩 (sous-entendant que c’est lui qui a fait du caca), il convient de lui indiquer que l’on ne sait pas quoi répondre : 🤷.

Indexer et rechercher des documents avec bleve

Extraire les dialogues d’une BD

Pour indexer une BD, nous allons avoir besoin d’en isoler les dialogues. Souvenez-vous des méta-données que nous retourne xkcd :

{
  "month": "2", 
  "num": 386, 
  "link": "", 
  "year": "2008", 
  "news": "", 
  "safe_title": "Duty Calls", 
  "transcript": "[[A stick man is behind computer]]\nVoice outside frame: Are you coming to bed?\nMan: I can't. This is important.\nVoice: What?\nMan: Someone is WRONG on the internet.\n{{title text: What do you want me to do?  LEAVE?  Then they'll keep being wrong!}}", 
  "alt": "What do you want me to do?  LEAVE?  Then they'll keep being wrong!", 
  "img": "https://imgs.xkcd.com/comics/duty_calls.png", 
  "title": "Duty Calls", 
  "day": "20"
}

Si l’on observe de plus près le format du transcript, on remarque que :

  • Les lignes décrivant ce qui est dessiné sont encadrées par [[ et ]] ;
  • Le texte alternatif figure entre {{ et }} ;
  • Les dialogues sont des lignes au format Personnage: texte de la réplique, c’est le texte de la réplique qui nous intéresse ;
  • Les autres lignes sont "de la voix off", elles aussi nous intéressent.

Écrivons une petite méthode pour extraire les répliques d’un Comic :

package xkcd

import (
    "bufio"
    "fmt"
    "strings"
)

// Script parses the transcript and returns all visible text from the image
func (c *Comic) Script() string {
    var w strings.Builder
    scanner := bufio.NewScanner(strings.NewReader(c.Transcript))
    for scanner.Scan() {
        line := scanner.Text()
        if strings.HasPrefix(line, "{{") || strings.HasPrefix(line, "[[") {
            continue
        }
        split := strings.SplitN(line, ":", 2)
        if len(split) == 2 {
            line = split[1]
        }
        fmt.Fprintln(&w, strings.TrimSpace(line))
    }
    return w.String()
}

Bien, maintenant passons aux choses sérieuses…

La bibliothèque d’indexation qui va bien

Vous aurez compris, nous allons avoir besoin d’incorporer un moteur d’indexation à notre bot. Ça tombe bien, car il en existe un tout beau en Go, qui supporte l’indexation et la recherche de documents arbitraires avec la syntaxe classique que nous venons de décrite plus haut : bleve.

Créons un petit package pour ajouter les fonctionnalités de bleve à notre programme :

package index

import (
    "fmt"
    "log"
    "sync"

    "github.com/blevesearch/bleve"
    "gitlab.com/neuware/xkcd-bot/pkg/xkcd"
)

var (
    path = "xkcd.index"
    idx  bleve.Index
    once sync.Once
)

// open the index, creating it if it doesn't exist
func open() {
    var err error
    if idx, err = bleve.Open(path); err == nil {
        return
    }
    log.Printf("creating index at %q", path)
    m := bleve.NewIndexMapping()
    idx, err = bleve.New(path, m)
    if err != nil {
        log.Fatalf("inex creation failed: %s", err)
    }
}

// SetPath sets the path of the current index.
func SetPath(p string) {
    path = p
    if idx != nil {
        open()
    }
}

// HasComic returns true if a comic with given ID exists in the current index.
func HasComic(id int) bool {
    once.Do(open)
    doc, _ := idx.Document(fmt.Sprint(id))
    return doc != nil
}

// AddComic adds a comic into the current index.
func AddComic(c *xkcd.Comic) {
    once.Do(open)
    id := fmt.Sprint(c.Num)
    c.Transcript = c.Script()
    idx.Index(fmt.Sprint(c.Num), c)
    log.Printf("indexed document #%s", id)
}

// A SearchResult is the association of a comic number and its score for the
// search.
type SearchResult struct {
    ComicNum string
    Score    float64
}

// Search performs a full-text search on the current index.
func Search(query string) []SearchResult {
    once.Do(open)
    results, err := idx.Search(bleve.NewSearchRequest(bleve.NewQueryStringQuery(query)))
    if err != nil {
        log.Printf("while searching for %q: %s", query, err)
    }
    res := make([]SearchResult, len(results.Hits))
    for i, r := range results.Hits {
        res[i] = SearchResult{r.ID, r.Score}
    }
    return res
}

Je vous vois venir d’ici :

Tu utilises des variables globales ?! Mais c’est mal !

Dans les langages sans namespace comme C, certes, mais en Go c’est plutôt courant, voire idiomatique à en juger par le package net/http. Dans notre programme, nous avons seulement besoin d’indexer un unique type de documents, et nous n’aurons certainement jamais besoin de plus que ça, alors autant nous simplifier la vie, non ? :)

Pour résumer ce code :

  • SetPath indique où est sauvegardé l’index que nous voulons utiliser, sur le disque,
  • HasComic retourne true si l’index contient déjà la BD dont le numéro est passé en argument,
  • AddComic indexe toutes les méta-données d’une BD après en avoir extrait les dialogues,
  • Search réalise une recherche et retourne une liste de résultats, chacun composé d’un numéro de BD et d’un score de recherche : la liste sera ordonnée avec les résultats les plus pertinents en premier (c’est le comportement par défaut de bleve).

Simple et efficace. :)

Indexer xkcd

Maintenant que nous avons incorporé un moteur d’indexation à notre bot, il est temps de lui faire manger des données. Réfléchissons à la façon la plus maligne de nous y prendre.

Sonder le site pour détecter les nouvelles parutions

Supposons que le bot possède déjà un index bien rempli. Pendant qu’il va tourner, de nouveaux contenus seront publiés. Dans ces conditions, il faut que le bot soit capable de détecter les nouvelles parutions sur xkcd. Pour ce faire, nous allons implémenter un "watcher", qui va récupérer, à intervalles régulier, le dernier xkcd paru, et regarder s’il ne le connait pas encore. Le cas échéant, lorsqu’une nouvelle BD est parue, celui-ci va appeler des callbacks en leur passant les méta-données de la BD.

De la même façon que pour l’indexation, nous allons implémenter ceci sous la forme d’un état privé qui sera global à tout le processus.

package xkcd

import (
    "sync"
    "time"
)

// Callback is the type of functions that get called when a new comic is published
type Callback func(*Comic)

type watchState struct {
    num       int
    callbacks []Callback
    period    time.Duration
    mtx       sync.Mutex
}

// setPeriod sets the polling period
func (w *watchState) setPeriod(p time.Duration) {
    w.mtx.Lock()
    defer w.mtx.Unlock()
    w.period = p
}

// addCallback registers a callback to the watchState.
func (w *watchState) addCallback(cbk Callback) {
    w.mtx.Lock()
    defer w.mtx.Unlock()
    w.callbacks = append(w.callbacks, cbk)
}

// latestNum returns the latest retrieved comic number.
func (w *watchState) latestNum() int {
    w.mtx.Lock()
    defer w.mtx.Unlock()
    return w.num
}

Notre état privé va donc être composé :

  • du numéro du dernier xkcd,
  • des callbacks à appeler en cas de nouvelle BD,
  • de la période à laquelle nous allons sonder le site,
  • et d’un mutex pour se protéger contre les accès concurrents.

Les méthodes implémentées ici n’ont rien de particulier, il s’agit de getters et de setters protégés par le mutex. Implémentons maintenant la mise à jour régulière :

// newWatcher creates and starts a new Watcher.
// The watcher polls xkcd.com every minute by default.
func newWatcher() *watchState {
    latest, _ := Get(Latest)
    w := &watchState{
        callbacks: make([]Callback, 0, 1),
        period:    time.Minute,
        num:       latest.Num,
    }
    go func() {
        for {
            w.checkUpdates()
            time.Sleep(w.period)
        }
    }()
    return w
}

// checkUpdates polls xkcd for new strips
func (w *watchState) checkUpdates() {
    w.mtx.Lock()
    defer w.mtx.Unlock()
    c, err := Get(Latest)
    if err != nil {
        log.Println("while polling xkcd for updates:", err)
    }
    if w.num != 0 && c.Num != w.num {
        log.Printf("new xkcd: #%d (%s)", c.Num, c.SafeTitle)
        w.num = c.Num
        for _, cbk := range w.callbacks {
            // Copy comic contents to avoid side effects
            tmp := *c
            cbk(&tmp)
        }
    }
}

La partie la plus tricky, ici, c’est celle que j’ai surligné. Lorsque l’on crée un watcher, on lance un appel périodique à la méthode checkUpdates dans une goroutine séparée. Vous remarquerez que je n’ai pas pris la peine de programmer l’arrêt de cette goroutine puisque nous avons uniquement besoin que celle-ci tourne tant que le bot est en marche. Si ce package xkcd avait été destiné à être distribué indépendamment du bot, il aurait fallu le faire, mais en ce qui nous concerne pour ce projet, il ne sert à rien de résoudre un problème qui n’est pas le nôtre.

Maintenant, il ne nous reste plus qu’à implémenter nos fonctions publiques :

var (
    watcher *watchState
    once    sync.Once
)

func initWatcher() {
    watcher = newWatcher()
}

// SetPeriod sets the poll period
func SetWatchPeriod(p time.Duration) {
    once.Do(initWatcher)
    watcher.setPeriod(p)
}

// OnNewComic adds a function to be called when a new xkcd comic is detected.
func OnNewComic(cbk Callback) {
    once.Do(initWatcher)
    watcher.addCallback(cbk)
}

// LatestNum returns the latest xkcd number.
func LatestNum() int {
    once.Do(initWatcher)
    return watcher.latestNum()
}

Ainsi :

  • SetWatchPeriod permet de modifier la période à laquelle on interroge xkcd,
  • OnNewComic enregistre un callback qui sera appelé avec les dernières publications,
  • LatestNum retourne le numéro du dernier xkcd paru, sans avoir besoin de faire un appel au site, ce qui va nous permettre d’économiser quelques appels superflus.

Ces trois fonctions démarrent le watcher si celui-ci n’existe pas encore (c’est à cela que servent les lignes once.Do(initWatcher)).

L’indexation initiale

Quand le bot va démarrer pour la première fois, il va falloir qu’il se mette à indexer tout le contenu existant. Quand il va (re-)démarrer (supposons après une longue coupure), il va également falloir qu’il rattrape tout ce qu’il a raté depuis la dernière fois qu’il a fonctionné. Cela revient donc à :

  • Récupérer le tout dernier xkcd au démarrage dont le numéro est N,
  • Itérer de 1 à N et indexer toutes les BD qui ne sont pas déjà présentes dans l’index.

Lorsque cette boucle sera terminée, l’index sera à jour.

Il faut faire très attention ici : cette mise à jour risque de résulter en de très nombreux appels au site d’xkcd, particulièrement la première fois que le bot sera lancé. Afin de nous comporter en gens civilisés, il est bon de ne pas faire d’appel au site pour récupérer des infos que nous possédons déjà et de mettre un délai entre deux appels au site. Espacer nos appels d’une seconde est suffisant. Cela signifie que la toute première passe d’indexation va prendre 40 minutes à la louche (le site comporte 2383 strips à l’heure où sont écrites ces lignes).

func updateIndex() {
    log.Println("updating the index")
    latest := xkcd.LatestNum()
    for num := 1; num <= latest; num++ {
        if index.HasComic(num) {
            continue
        }
        c, err := xkcd.Get(fmt.Sprint(num))
        if err != nil {
            log.Printf("couldn't get #%d: %s", num, err)
            continue
        }
        index.AddComic(c)
        time.Sleep(time.Second)
    }
    log.Printf("search index up to date")
}

L’indexation sera donc démarrée de la façon suivante :

func startIndexing(path string) {
    index.SetPath(path)
    xkcd.OnNewComic(index.AddComic)

    // start updating the index in the background
    go updateIndex()
}

Mettre tout bout à bout

Notre bot a maintenant besoin de prendre 3 paramètres de configuration :

  • Son token,
  • Le chemin de son index dans le système de fichiers,
  • La période à laquelle nous voulons détecter les nouvelles publications.

La façon idiomatique de nous y prendre, c’est de créer une structure Config qui contient ces valeurs, et que l’on passera en argument de la fonction Run. Voici comment celle-ci est désormais adaptée :

package bot

import (
    "fmt"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/bwmarrin/discordgo"
    "gitlab.com/neuware/xkcd-bot/pkg/xkcd"
)

type Config struct {
    Token       string
    IndexPath   string
    WatchPeriod time.Duration
}

// Run runs the discord bot
func Run(cfg *Config) error {
    dg, err := discordgo.New("Bot " + cfg.Token)
    if err != nil {
        return fmt.Errorf("couldn't create discordgo session: %w", err)
    }

    dg.AddHandler(onMessageCreate)

    err = dg.Open()
    if err != nil {
        return fmt.Errorf("couldn't open connection: %w", err)
    }
    defer dg.Close()

    sc := make(chan os.Signal, 1)
    signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
    log.Println("Up and running. Press Ctrl-C to exit.")

    xkcd.SetWatchPeriod(cfg.WatchPeriod)
    startIndexing(cfg.IndexPath)

    <-sc
    return nil
}

Aucune difficulté particulière ici.

Adapter le démarrage du bot

Mettons enfin à jour notre main pour prendre en compte ces nouvelles valeurs de configuration. Le bot va maintenant lire les variables d’environnement suivantes :

  • BOT_TOKEN: le token Discord,
  • WATCH_PERIOD: la période de raffraichissement (1 minute par défaut),
  • INDEX_PATH: le chemin de l’index.

Je le place dans une balise spoiler.

app/main.go
package main

import (
    "flag"
    "log"
    "os"
    "time"

    "gitlab.com/neuware/xkcd-bot/pkg/bot"
)

const (
    defaultWatchPeriod = time.Minute
    defaultIndexPath   = "./xkcd.index"
)

var (
    token       string
    indexPath   string
    watchPeriod time.Duration
)

func init() {
    flag.StringVar(&token, "t", os.Getenv("BOT_TOKEN"), "Bot token")

    p := os.Getenv("INDEX_PATH")
    if p == "" {
        p = defaultIndexPath
    }
    flag.StringVar(&indexPath, "index", p, "Index path")

    var period time.Duration
    if d, err := time.ParseDuration(os.Getenv("WATCH_PERIOD")); err == nil {
        period = d
    } else {
        period = defaultWatchPeriod
    }
    flag.DurationVar(&watchPeriod, "p", period, "Watch period")
    flag.Parse()
}

func main() {
    err := bot.Run(&bot.Config{
        Token:       token,
        IndexPath:   indexPath,
        WatchPeriod: watchPeriod,
    })
    if err != nil {
        log.Fatal(err)
    }
}

Réagir aux mentions

La dernière chose qu’il nous reste à faire avant de déployer le bot, c’est de le rendre capable de réagir lorsque l’on s’adresse directement à lui. En soi, ce n’est pas bien difficile, mais il y a des choses qui ne s’inventent pas.

Il faut savoir que lorsque l’on mentionne quelqu’un sur Discord, cela peut prendre l’une des deux formes suivantes (où 1234567 est à remplacer par l’ID de la personne):

  • <@1234567>
  • <@!1234567>

Ensuite, et nous pourrons le vérifier en testant le bot, à partir du moment où les documents sont indexés avec le numéro du xkcd comme identifiant unique, il est bon de savoir qu'une recherche sur le numéro d’un xkcd remontera forcément le document correspondant en première position. Cela veut dire que nous n’avons pas besoin de créer un cas particulier pour chercher un xkcd en fonction de son ID. Cool, non ? :D

En dehors de cela, aucune difficulté. Vous remarquerez que le code suivant rajoute également, par rapport au précédent billet, la commande random pour récupérer un xkcd au hasard (ce qui nous impose d’initialiser le générateur de nombres aléatoires) :

package bot

import (
    "fmt"
    "log"
    "math/rand"
    "strings"
    "time"

    "github.com/bwmarrin/discordgo"
    "gitlab.com/neuware/xkcd-bot/pkg/index"
    "gitlab.com/neuware/xkcd-bot/pkg/xkcd"
)

const prefix = "xkcd!"

func getCommand(dg *discordgo.Session, content string) (string, bool) {
    mention := "<@" + dg.State.User.ID + "> "
    nmention := "<@!" + dg.State.User.ID + "> "

    switch {
    case strings.HasPrefix(content, prefix):
        return content[len(prefix):], true
    case strings.HasPrefix(content, mention):
        return content[len(mention):], true
    case strings.HasPrefix(content, nmention):
        return content[len(nmention):], true
    }
    return "", false
}

func onMessageCreate(dg *discordgo.Session, m *discordgo.MessageCreate) {
    if m.Author.ID == dg.State.User.ID {
        return
    }

    cmd, ok := getCommand(dg, m.Content)
    if !ok {
        return
    }

    var err error
    switch cmd {
    case "help":
        dg.ChannelMessageSend(
            m.ChannelID,
            fmt.Sprint(
                "Commands:"+
                    "\n* `latest`: Show the latest xkcd"+
                    "\n* `random`: Pick a random xkcd"+
                    "\n\nOtherwise, I'll show you the xkcd strip that best matches your query."+
                    " You can give me a comic number, or its title, or search by keywords."+
                    "\nI also support advanced syntax. e.g: `video games -ferret`"+
                    " searches for a comic that talks about video games,"+
                    " but not the one with the ferret.",
            ),
        )
    case "latest":
        err = showXKCD(dg, m.ChannelID, xkcd.Latest)
    case "random":
        err = showRandomXKCD(dg, m.ChannelID)
    default:
        res := index.Search(cmd)
        if len(res) > 0 {
            err = showXKCD(dg, m.ChannelID, res[0].ComicNum)
        } else {
            err = fmt.Errorf("search for %q didn't yield any result", cmd)
        }
    }
    if err != nil {
        dg.MessageReactionAdd(m.ChannelID, m.ID, "🤷")
        log.Printf("Error processing command %q: %s", cmd, err)
    }
}

func showRandomXKCD(dg *discordgo.Session, channelID string) error {
    return showXKCD(dg, channelID, fmt.Sprint(rand.Int()%xkcd.LatestNum()+1))
}

func showXKCD(dg *discordgo.Session, channelID, xkcdID string) error {
    c, err := xkcd.Get(xkcdID)
    if err != nil {
        return err
    }
    date, _ := c.Date()
    _, err = dg.ChannelMessageSendEmbed(
        channelID,
        &discordgo.MessageEmbed{
            URL:         c.URL(),
            Type:        discordgo.EmbedTypeImage,
            Title:       c.SafeTitle,
            Description: c.Alt,
            Image:       &discordgo.MessageEmbedImage{URL: c.Img},
            Footer: &discordgo.MessageEmbedFooter{
                Text: fmt.Sprintf("#%d (%s)", c.Num, date.Format("2006-01-02")),
            },
        },
    )
    return err
}

func init() {
    rand.Seed(time.Now().UnixNano())
}

Persister les données avec Kubernetes

Mettre à jour l’image Docker

Nous n’avons rien à changer au niveau des Dockerfile du précédent billet. Poussons simplement la nouvelle image :

$ make image
$ make publish

Mettre à jour le déploiement

Au niveau du déploiement, notre bot est maintenant un petit peu plus compliqué à gérer proprement. :)

En effet, si nous le déployons de la même façon que la dernière fois, cela ne posera a priori aucun problème et, une fois passées les 40 minutes d’indexation, celui-ci fonctionnera tout à fait normalement. Cependant, lorsque nous le redémarrerons, toutes les données qu’il avait indexées seront perdues, et il faudra qu’il aspire à nouveau les métadonnées sur le site d’xkcd… Pour éviter cela, nous avons besoin que les données écrites par notre programme sur son système de fichiers soient persistantes. C’est là qu’intervient une nouvelle abstraction de Kubernetes : le Persistent Volume Claim.

Concrètement, lorsqu’un pod a besoin d’écrire ou de lire des données qui doivent lui survivre, il est possible de demander à Kubernetes : « Réserve-moi un espace de telle taille sur un disque ». Celui-ci va alors se débrouiller pour nous fournir un "espace disque", ou plutôt, un volume avec les propriétés que nous lui demandons, que nous pouvons monter dans les conteneurs de manière à ce que ceux-ci puissent lire ou écrire dedans.

Après un test en local, j’ai pu déterminer qu’un index sur tous les xkcd depuis la création du site nécessite 40Mio. Je vais donc ici réclamer un volume avec une petite marge (100Mio) à Kubernetes :

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: xkcd-index
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi

Cette configuration lui dit "débrouille-toi pour me donner le volume que tu veux, mais je veux qu’il fasse au moins 100Mio, et je te garantis qu’un seul pod à la fois cherchera à y accéder en lecture et en écriture".

$ kubectl apply -f deploy/volume.yml
persistentvolumeclaim/xkcd-index created

Maintenant, adaptons le fichier de déploiement, de manière à ce que notre bot stocke son index dans ce volume (et configurons-le, au passage, pour qu’il sonde le site toutes les heures en quête d’une nouvelle publication) :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: xkcd-bot
spec:
  replicas: 1
  selector:
    matchLabels:
      app: xkcd-bot
  template:
    metadata:
      labels:
        app: xkcd-bot
    spec:
      containers:
      - name: bot
        image: neuware/xkcd-bot
        env:
        - name: BOT_TOKEN
          valueFrom:
            secretKeyRef:
              name: bot-secret
              key: token
        - name: INDEX_PATH
          value: /var/lib/xkcd.index
        - name: WATCH_PERIOD
          value: 1h
        volumeMounts:
          - mountPath: /var/lib/xkcd.index
            name: xkcd-index-storage
            readOnly: false
      volumes:
      - name: xkcd-index-storage
        persistentVolumeClaim:
          claimName: xkcd-index

Appliquons cette configuration :

$ kubectl apply -f deploy/deployment.yml
deployment.apps/xkcd-bot configured
$ kubectl get pods
NAME                        READY   STATUS    RESTARTS   AGE
xkcd-bot-5fbd9cc49c-6xm59   1/1     Running   0          19s

Et voilà ! Notre déploiement est mis à jour, le pod a été automatiquement recréé avec sa nouvelle image, sa nouvelle configuration, et son volume de données. Jetons un oeil à ses logs :

$ kubectl logs -f xkcd-bot-5fbd9cc49c-6xm59
2020/11/11 13:59:47 Up and running. Press Ctrl-C to exit.
2020/11/11 13:59:47 updating the index
2020/11/11 13:59:57 indexed comic #1
2020/11/11 13:59:58 indexed comic #2
2020/11/11 13:59:59 indexed comic #3
...

Bien, allons prendre un café (et écrivons ce billet) le temps qu’il finisse de créer son index, avant de le tester. :)

Testons !

Allez, l’heure du grand test a sonné !

Pour rappel, vous pouvez vous-même tester mon instance du bot en l’invitant sur votre serveur.

Afficher un xkcd aléatoire

C’est une fonctionnalité que l’on n’avait pas implémentée dans le précédent billet, vérifions que ça fonctionne :

Bon… je ne vais pas spammer avec des captures d’écran, on voit bien ici que deux appels successifs retournent deux strips au hasard.

Et la recherche alors ?

Essayons avec l’exemple dont j’ai parlé au début. Le bot arrive-t’il à retrouver le xkcd sur les injections SQL si je lui demande bobby tables ?

Victoire ! \o/

Essayons maintenant une recherche plus compliquée. "Je veux le xkcd qui parle des jeux vidéo là"…

Non, pas celui sur le furet…

Voilà, celui-là. :)

Au fait, on peut toujours les rechercher par leur ID ?

Essayons, demandons-lui le numéro 456 pour voir.

Bon, je pense que l’on peut conclure que tout fonctionne comme on s’y attend.


Voilà qui conclut notre second épisode.

Dans le prochain billet, nous allons nous éloigner des fonctionnalités du bot, et nous mettrons à instrumenter celui-ci, de manière à pouvoir surveiller son fonctionnement comme il se doit sur tout projet un minimum sérieux.

Ce sera pour nous l’occasion de découvrir la notion de service dans Kubernetes, et d’acquérir quelques bons réflexes sur l’observabilité d’un système.

1 commentaire

Ok, alors sur le Discord de ZdS on a découvert que le bot supportait également la recherche par expressions régulières…

C’était même pas volontaire !

Je tiens à remercier les membres de ce serveur pour avoir découvert cette feature que j’ai implémentée sans le vouloir. :D

+7 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte