Previsioni di serie temporali con Prophet e Metaflow

9 min

Nell’ambito di un progetto di anomaly detection, ho recentemente potuto utilizzare due prodotti open source molto interessanti: Prophet, rilasciato dal Core Data Science team di Facebook e Metaflow, un ottimo framework di Netflix. Ho utilizzato Prophet, in un flusso Metaflow, per realizzare dei modelli previsionali di serie storiche. Ho deciso di scrivere questo post per condividere la mia esperienza con questi due prodotti, realizzando un piccolo progetto di machine learning. [photo by Djim Loic on Unsplash]

Un piccolo progetto

Riuscire a prevedere il futuro andamento di una serie storica è utilissimo in molte applicazioni, dal mondo della finanza all’impresa. Si cerca, per esempio, di prevedere la direzione del mercato azionario o il corretto approvvigionamento di risorse. Questo post non si pone obiettivi così ambiziosi, ma vuole solo esplorare le possibilità offerte da Prophet realizzando un modello previsionale che determini il futuro andamento delle temperature giornaliere. Per effettuare il training del modello, ho utilizzato un dataset che raccoglie le temperature minime giornaliere su 10 anni (1981-1990) nella città di Melbourne, in Australia. La fonte dei dati è Australian Bureau of Meteorology.

L’intero codice sorgente del progetto è disponibile in questo repository git.

Esplorazione dei dati

Analizziamo il nostro dataset con un semplice notebook. Utilizziamo Python e Pandas per caricare il file CSV.

Il file si presenta con due semplici colonne, indicanti rispettivamente data della misurazione e temperatura. Proviamo a visualizzare i dati e la relativa distribuzione.

Ottimo: il dataset si presenta bilanciato e la periodicità dell’andamento della temperatura nell’arco degli anni è ben evidente. Possiamo provare ad utilizzare Prophet.

Facebook Prophet

Vediamo come sia semplice, con Prophet, effettuare delle previsioni sull’andamento di serie temporali.

Per prima cosa dovremo ovviamente preoccuparci di installare il package Python.

pip install fbprophet

Prophet accetta un dataframe Pandas come base dati su cui effettuare il training. Il dataframe deve avere due colonne, DS – cioè la data della misurazione e Y – il valore della misurazione. Occupiamoci quindi di sistemare le nostre feature prima di sottoporle al metodo di fit del modello.

# Rename columns to meet Prophet input dataframe standards
df.rename(columns={'Date':'ds','Temp':'y'},inplace=True)

# Convert Date column to datetime64 dtype
df['ds']= pd.to_datetime(df['ds'], infer_datetime_format=True)

Siamo ora pronti per effettuare il training del modello con il metodo fit.

m = Prophet()
m.fit(df)

Tutto qui? Esattamente. Prophet si vanta di essere una procedura automatica che consente molto rapidamente di effettuare previsioni su dati temporali, individuando stagionalità, trend ed eliminando valori anomali. L’operazione di fitting è anche molto rapida.

Proviamo ora a prevedere il futuro andamento delle nostre temperature per l’anno successivo. Questo risultato si ottiene sottoponendo a Prophet un dataframe dove la colonna DS indica le date di interesse.

E’ possibile sfruttare dei metodi già presenti nel framework per estendere, per esempio, il dataframe iniziale del numero di giorni desiderato.

# Prepare dataframe for prediction (add 365 days)
future = m.make_future_dataframe(periods=365)

# Predict future values
forecast = m.predict(future)

Fatto! Il dataframe riporta ora le previsioni (yhat) della nostra serie temporale per il periodo richiesto.

Il framework di Facebook ci permette anche di disegnare rapidamente la nostra serie temporale e il futuro andamento della stessa comprensivo del range di confidenza. Ancora più interessante è la possibilità di rappresentare su figure differenti, le componenti (trend e stagionalità) della nostra serie.

Davvero uno strumento molto potente e semplice da utilizzare. Non cadiamo però nell’errore di considerarlo sin troppo semplice: quanto abbiamo appena visto è di fatto un “quick start” per coglierne le possibilità. Per situazioni più complesse o quando si vuole ottenere risultati migliori, Prophet consente di impostare diverse opzioni, quali:

  • Modello lineare / logistico di crescita
  • Saturazione al minimo
  • Flessibilità del trend
  • Changepoints (automatici e custom)
  • Festività ed eventi speciali (automatiche e custom)
  • Stagionalità (custom, additive e moltiplicative)
  • Parametri di incertezza (del trend e della stagionalità)

Esistono quindi diversi parametri su cui possiamo agire per adattare al meglio il nostro modello ai dati che stiamo analizzando. L’ottima documentazione ci aiuta a comprenderne l’utilizzo. Noi ci limiteremo, per questo progetto, a sperimentare valori diversi per alcuni iperparametri, in modo da migliorare le nostre predizioni. Il processo non sarà svolto in un altro notebook: al contrario, validato l’approccio, preferiamo utilizzare un framework che ci guidi nel processo di realizzazione e gestione del nostro progetto di data science. E’ arrivato il momento di utilizzare Metaflow!

Metaflow

Metaflow ci aiuta a progettare il flusso di lavoro, suddividendolo in step. E’ possibile eseguire l’elaborazione su larga scala, grazie all’integrazione con il cloud AWS. Si occupa automaticamente di versionare i dati ed i nostri esperimenti (run). Come per Prophet, anche Metaflow è disponibile sia per Python che per R.

E’ sempre una buona idea utilizzare Metaflow? In generale si, se nell’ambito del nostro progetto avremo bisogno di elaborare i dati su larga scala (e non esclusivamente sul nostro laptop) o condividere il progetto con altre persone.

Procediamo con l’installazione:

pip install metaflow

Appena installato, Metaflow è configurato per l’esecuzione locale dei flow. Vedremo in seguito come configurare l’esecuzione in cloud.

Ma cos’è un flow? Un flow è una serie di step che eseguono l’elaborazione dei dati nell’ambito del nostro progetto. Nel caso più semplice, gli step sono lineari cioè eseguiti uno di seguito all’altro.

Metaflow consente inoltre di definire step la cui esecuzione avvenga in parallelo, funzionalità necessaria per garantire la scalabilità in cloud.

In ogni flow, ciascuno step “richiama” il successivo. Nel caso di task paralleli, uno step “padre” determina poi l’esecuzione di step “figli” in più branch. Ogni branch può essere composto da più step e a sua volta può suddividersi ulteriormente. I branch possono implementare elaborazioni differenti oppure possono essere identici, ma destinati ad elaborare in parallelo lotti di dati diversi. Al termine dell’esecuzione dei task in parallelo, questi vengono sempre consolidati in uno step di “join”, il cui scopo è raccogliere i risultati (artefatti) dell’esecuzione.

Prendiamo in prestito questa immagine direttamente dalla documentazione di Metaflow: lo step “start” determina la creazione di più task di tipo “a” in parallelo. Al termine dell’esecuzione, questi vengono consolidati nello step “join”, seguito poi dall’ultimo step del flow “end”.

ProphetFlow

Proviamo ora a “convertire” il nostro notebook, effettuandone il porting su Metaflow.

Ogni flow è uno script che può essere eseguito specificando dei parametri e dei data file di input. Il decorator @step definisce i passaggi del nostro flow, a partire da “start” che si occupa di caricare i dati dal file CSV in un dataframe pandas. Nello step successivo ci occupiamo di effettuare il training del modello Prophet, al quale seguirà lo step finale “end”.

IncludeFile consente di allegare un file di dati al nostro flow, Parameter è ovviamente un parametro che possiamo specificare in fase di esecuzione.

Abbiamo quindi realizzato un esempio molto semplice, per introdurre alcuni concetti di Metaflow. Proviamo ed eseguirlo:

python src/ProphetSimpleFlow.py run

Come si vede, ad ogni esecuzione Metaflow si occupa di validare gli step del nostro flusso e il relativo codice sorgente. Tutti gli step (o task) sono eseguiti in sequenza. Un aspetto interessante di Metaflow è inoltre la possibilità di riprendere l’esecuzione di un flusso da uno step specifico, ad esempio in caso di errore.

Al termine dell’esecuzione, Metaflow memorizza automaticamente tutti gli artefatti (self.*), quindi anche il modello Prophet di cui abbiamo appena terminato il training (self.m). Vediamo come utilizzarlo in un Notebook.

Utilizzare il client Metaflow in un Notebook

Ora che il modello Prophet è stato creato, possiamo utilizzare un notebook per accedere ai risultati del flow ed effettuare delle previsioni. Utilizziamo i metodi del client Metaflow.

Come si può vedere, per ciascun flow è possibile consultare tutte le esecuzioni e accedere ai relativi artefatti. Il nostro modello Prophet è quindi accessibile come run.data.m. Proviamo ad utilizzarlo.

Analogamente a quanto visto in precedenza, siamo ora in grado di prevedere l’andamento della nostra time serie. Con l’introduzione di Metaflow abbiamo versionato gli artefatti (i modelli Prophet) e siamo in grado di accedere ai metadati relativi a ciascuna esecuzione del processo di training precedentemente effettuata.

Tuning degli iperparametri

Proviamo a migliorare le prestazioni del nostro modello previsionale, agendo su alcuni degli iperparametri che Prophet ci mette a disposizione. Consideriamo i seguenti:

  • changepoint_prior_scale – un parametro che determina la flessibilità del trend, in particolare in occasione dei changepoint
  • seasonality_prior_scale – un parametro che determina la flessibilità delle componenti stagionali

Proviamo a combinare diversi valori per questi parametri e confrontiamo le performance dei modelli ottenuti. Prophet ci mette a disposizione un comodo metodo di cross_validation per valutare le performance del modello su diversi orizzonti temporali e un metodo performance_metrics per calcolare alcune statistiche molto utili, quali ad esempio MSE e RMSE.

Utilizzeremo tali statistiche per individuare i valori di iperparametri ottimali. In questa sezione della documentazione di Prophet è approfondito l’argomento in modo esaustivo.

Come modifichiamo il nostro flow? La valutazione della performance dei modelli con differenti iperparametri è ovviamente un’attività che può essere eseguita in parallelo. Andremo quindi a creare tanti step “figli” quante sono le combinazioni dei vari valori degli iperparametri che vogliamo testare. Uno step di join al termine, raccoglierà le metriche di performance e si occuperà di individuare la migliore. Aggiungiamo quindi al nostro flow due step:

  • hyper_tuning – è lo step padre, che determina tutte le combinazioni di iperparametri possibili e per ciascuna di queste esegue lo step successivo (in parallelo)
  • cross_validation – è lo step di training e valutazione delle performance di un modello con una specifica combinazione di iperparametri

Rispetto al precedente flow, differisce inoltre anche lo step train: andremo a effettuare il merge degli artefatti prodotti dagli step di cross_validation, individuando la combinazione di ipermarametri con valore di RMSE inferiore. Utilizzeremo poi tale combinazione per il training del modello “definitivo”. Ecco un estratto del flow modificato:

L’esecuzione del flow richiederà, ovviamente, molto più tempo in quanto vengono testate le varie combinazioni di iperparametri su differenti orizzonti temporali. E’ questo il momento in cui possiamo decidere di passare dall’esecuzione locale di Metaflow, alla sua configurazione cloud su AWS. Vediamo come.

Configurazione di Metaflow su AWS

Come detto in precedenza, Metaflow è inizialmente configurato per utilizzare le sole risorse locali per l’esecuzione dei flow e la memorizzazione di dati e metadati.

E’ consigliato configurare Metaflow per utilizzare i servizi cloud di AWS: è possibile infatti eseguire alcuni step o l’intero codice di un flow su AWS, utilizzando il servizio AWS Batch. Dati e metadati vengono memorizzati rispettivamente su S3 e RDS, in maniera centralizzata e condivisibile con il team.

A tale scopo la strada più semplice è creare uno stack CloudFormation con tutta l’infrastruttura necessaria. Le istruzioni (e il template di CloudFormation) sono disponibili qui. Al termine della creazione dello stack, potremo configurare Metaflow per l’utilizzo delle risorse cloud con il seguente comando.

metaflow configure aws

Il team di Metaflow mette a disposizione un ambiente sandbox gratuito per i propri esperimenti: richiederne l’utilizzo a questo link. Purtroppo, per questo progetto, non sono stato in grado di utilizzare la sandbox in quanto l’ambiente non consente il download i package aggiuntivi, non disponendo di connettività in uscita.

Vogliamo quindi eseguire il nostro flow su AWS Batch? Non così in fretta. Dobbiamo considerare che stiamo utilizzando un package – Prophet – che deve essere installato in quanto non presente nell’immagine docker utilizzata di default.

Per risolvere questo problema sono possibili due strade: utilizzare l’integrazione con Conda che Metaflow mette a disposizione oppure utilizzare un’immagine docker differente.

Personalmente ho preferito utilizzare questa seconda strada, in quanto mi sono scontrato con diversi problemi di compatibilità e lunghi tempi di esecuzione utilizzando Conda.

Vediamo come realizzare un’immagine docker per tale scopo.

Dopo aver effettuato la build dell’immagine e averla depositata in un repository accessibile, possiamo eseguire il nostro flow su AWS Batch con il comando seguente. L’immagine utilizzata in questo progetto è disponibile su dockerub.

python src/ProphetFlow.py run --with batch:image=vnardone/prophet-metaflow

Esatto: con un semplice parametro a runtime è possibile eseguire il nostro flow interamente su AWS, utilizzando risorse di calcolo superiori rispetto al nostro laptop. La modalità di accesso agli artefatti (il modello Prophet) sarà identico anche se questi saranno ovviamente memorizzati in cloud: utilizzeremo infatti il medesimo Notebook visto in precedenza per effettuare delle previsioni senza renderci conto, di fatto, di utilizzare risorse cloud anzichè locali.

In alternativa è possibile eseguire esclusivamente gli step che richiedono più risorse computazionali su AWS Batch, utilizzando il decorator @batch. Questo andrà posto prima di ciascuno step che si intende eseguire in cloud.

@batch(image='vnardone/prophet-metaflow')
@step
def cross_validation(self):
   ....

Personalmente preferisco quest’ultimo approccio, che consente di delegare al cloud gli step più onerosi in termini computazionali, eseguendo invece rapidamente in locale gli altri step. In questo modo è anche possibile specificare immagini docker differenti per ciascuno step, qualora sia necessario.

Conclusioni

Siamo giunti alla fine: in questo post ho parlato di un semplice progetto di ML che però abbraccia prodotti open-source molto potenti che meriterebbero ulteriori approfondimenti. Mentre Prophet è ovviamente una procedura utile per un caso specifico, la previsione di serie temporali, possiamo utilizzare Metaflow in tutti i nostri progetti di data science in quanto lo stesso framework ci aiuta a tenere “ordinato” il processo di sviluppo e deployment della soluzione.

Riassumendo quelli che penso siano i punti di forza e di debolezza di Metaflow:

Punti di forza

  • Forza lo sviluppatore / data scientist a eseguire operazioni ordinate, suddivise in passaggi
  • Consente di condividere automaticamente processi, dati e metadati con il team
  • I processo e i dati vengono automaticamente versionati
  • Il framework nasconde la complessità del backend
  • Ottima integrazione con le risorse cloud AWS

Da migliorare

  • L’integrazione con Conda è decisamente troppo lenta
  • Le risorse AWS richieste potrebbero dimostrarsi molto onerose economicamente

L’intero codice sorgente del progetto è disponibile in questo repository git.

Ci siamo divertiti? Alla prossima!

Chromium & Selenium

Chromium e Selenium in AWS Lambda

4 min

Vediamo insieme come sia possibile utilizzare Chromium e Selenium in una funzione AWS Lambda; prima però, qualche informazione per chi non conosce questi due progetti.

Chromium è il browser open source da cui deriva Google Chrome. I browser condividono la maggior parte del codice e funzionalità. Differiscono però per i termini di licenza e Chromium non supporta Flash, non ha un sistema automatico di aggiornamento e non raccoglie statistiche di utilizzo e crash. Vedremo che queste differenze non incidono minimamente sulle potenzialità del progetto e che, grazie a Chromium, possiamo svolgere molti task interessanti nell’ambito delle nostre Lambda function.

Selenium è un notissimo framework dedicato al test di applicazioni web. A noi interessa in particolare Selenium WebDriver, un tool che consente di comandare a proprio piacimento tutti i principali browser oggi disponibili, tra cui anche Chrome/Chromium.

Perché usare Chrome in una Lambda function?

Qual’è lo scopo di utilizzare un browser in un ambiente (AWS Lambda) che non prevede una GUI? In realtà ne esistono diversi. Comandare un browser consente di automatizzare una serie di task. E’ possibile, per esempio, testare la propria web application in modo automatico realizzando una pipeline CI. Oppure, non meno importante, effettuare web scraping.

Il web scraping è una tecnica che consente di estrarre dati da un sito web e le sue possibili applicazioni sono infinite: monitorare i prezzi dei prodotti, verificare la disponibilità di un servizio, costruire base di dati acquisendo records da più fonti.

In questo post vedremo come utilizzare Chromium e Selenium in una Lambda function Python per effettuare il rendering di una qualsiasi URL.

Lambda function di esempio

La Lambda Function che andiamo a realizzare ha uno scopo specifico: data una URL, verrà utilizzato Chromium per effettuare il rendering della relativa pagina web e verrà catturata una screenshot del contenuto in formato PNG. Andremo a salvare l’immagine in un bucket S3. Eseguendo la funzione periodicamente, saremo in grado di “storicizzare” i cambiamenti di un qualsiasi sito, ad esempio la homepage di un quotidiano di informazioni online.

Ovviamente la nostra Lambda ha moltissime possibilità di miglioramento: lo scopo è mostrare le potenzialità di questo approccio.

Iniziamo con i pacchetti Python richiesti (requirements.txt).

selenium==2.53.0
chromedriver-binary==2.37.0

Non sono packages normalmente disponibili nell’ambiente AWS Lambda Python: andremo quindi a creare uno zipfile per distribuire la nostra function comprensiva di tutte le dipendenze.

Otteniamo Chromium, nella sua versione Headless, cioè in grado di essere eseguito in ambienti server. Ci servirà inoltre il relativo driver Selenium. Qui di seguito i link che ho utilizzato.

Come usare Selenium Webdriver

Un semplice esempio di come usare Selenium in Python: il metodo get consente ti indirizzare il browser verso l’URL indicata, mentre il metodo save_screenshot ci permette di salvare su file un’immagine PNG del contenuto. L’immagine avrà le dimensioni della finestra di Chromium, che è impostata con l’argomento window-size a 1280×1024.

Ho deciso di realizzare una classe wrapper per il nostro scopo: effettuare il rendering di una pagina, in tutta la sua altezza. Andremo quindi a calcolare la dimensione della finestra necessaria a contenere tutti gli elementi utilizzando un trucco, uno script che eseguiremo a caricamento avvenuto. Anche in questo caso Webdriver espone un metodo execute_script che fa al caso nostro.

La soluzione non è molto elegante ma funzionale: l’URL richiesta deve essere caricata due volte. La prima volta per determinare la dimensione della finestra, la seconda per ottenere la screenshot. Lo script JS utilizzato è stato preso da questo interessante post.

Il wrapper è inoltre già “Lambda ready”, cioè gestisce correttamente i percorsi temporanei e la posizione dell’eseguibile headless-chromium specificando le chrome_options necessarie.

Vediamo come è la nostra funzione Lambda:

L’event handler della funzione si occupa semplicemente di istanziare il nostro oggetto WedDriverScreenshot e lo utilizza per la generazione di due screenshot: il primo con dimensione fissa della finestra (1280×1024 pixel). Per il secondo viene invece omesso il parametro Height che sarà determinato automaticamente dal nostro wrapper.

Ecco le due immagini risultanti, messe a confronto tra loro, relative al sito www.repubblica.it

Deployment della funzione

Ho raccolto in questo repository Github tutti i file necessari al deployment del progetto su AWS Cloud. Oltre ai file già analizzati in precedenza, è presente un template CloudFormation per il deployment dello stack. La sezione più importante riguarda ovviamente la definizione della ScreenshotFunction: alcune variabili di ambiente come PATH e PYTHONPATH sono fondamentali per la corretta esecuzione della function e di Chromium. E’ necessario, inoltre, tenere in considerazione i requisiti di memoria ed il timeout: il caricamento di una pagina può impiegare infatti diversi secondi. Il percorso lib include alcune library necessarie all’esecuzione di Chromium e non presenti di default nell’ambiente Lambda.

Come di consueto mi sono affidato ad un Makefile per l’esecuzione delle operazioni principali.

## download chromedriver, headless-chrome to `./bin/`
make fetch-dependencies

## prepares build.zip archive for AWS Lambda deploy 
make lambda-build		

## create CloudFormation stack with lambda function and role.
make BUCKET=your_bucket_name create-stack 

Il template CloudFormation prevede un bucket S3 da utilizzare come source per il deployment della Lambda function. Successivamente lo stesso bucket verrà utilizzato per memorizzare gli screenshot PNG.

Conclusioni

Abbiamo utilizzato Selenium e Chromium per effettuare il rendering di una pagina web. Si tratta di una possibile applicazione di questi due progetti in ambito serverless. Come anticipato, un’altra applicazione molto interessante è il web scraping. In questo caso, l’attributo page_source di Webdriver consente di accedere ai sorgenti della pagina e un package come BeautifulSoup può essere molto utile per estrarre i dati che intendiamo raccogliere. Il pacchetto Selenium per Python mette a disposizione diversi metodi per automatizzare altre operazioni: consiglio di guardarsi l’ottima documentazione.

Vedremo un esempio di web scraping in uno dei prossimi post!

Ci siamo divertiti? Alla prossima!

AWS Lambda in Docker

Sviluppo offline di funzioni AWS Lambda

3 min

I progetti a cui lavoro sono sempre più indirizzati verso il paradigma serverless e sempre più spesso implementati su piattaforma AWS Lambda. Poter sviluppare una funzione AWS Lambda offline, cioè rimanendo comodamente nel proprio IDE preferito senza dover effettuare l’upload del codice per poterlo testare, consente di velocizzare notevolmente le attività ed essere più efficienti.

AWS Lambda environment in docker

Esatto! La soluzione che ci consente di sviluppare codice AWS Lambda in modalità offline è utilizzare un’immagine docker che replica in maniera pressoché identica l’ambiente live di AWS. Le immagini docker disponibili presso DockerHub costituiscono una sandbox all’interno della quale eseguire la propria funzione, sicuri di trovare le medesime librerie, struttura file e relativi permessi, variabili d’ambiente e contesto di produzione. Fantastico!

Raramente una Lambda function è “indipendente” da altre risorse: spesso ha la necessità di accedere a degli oggetti memorizzati in un bucket S3, accodare messaggi su SQS o accedere ad una tabella DynamoDB. L’aspetto interessante di questa soluzione è la possibilità di sviluppare e testare il proprio codice offline, interagendo comunque con servizi e risorse reali di AWS, semplicemente andando a specificare una coppia di chiavi di accesso AWS nelle variabili di ambiente AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY.

Il progetto LambdaCI è aggiornato frequentemente e ben documentato: prevede diversi ambienti di runtime tra quali Python, che andremo ad utilizzare nei prossimi paragrafi. L’ambiente che ho utilizzato per lo sviluppo è disponibile in questo repository.

Funzione di esempio

Supponiamo di lavorare ad una semplice funzione Python che si occupi di processare dei messaggi SQS e che utilizzi un package normalmente non installato nell’ambiente AWS Lambda Python. ll codice di esempio è il seguente.

Per prima cosa viene istanziato l’oggetto Logger che andremo ad utilizzare per tracciare l’evento SQS. Nel body del messaggio sono previsti degli addendi che andremo a sommare ed il risultato verrà riportato nei log. Andremo inoltre a tracciare la versione del package PILLOW, normalmente non previsto di default nell’ambiente AWS Lambda, per verificare che l’installazione dei pacchetti aggiuntivi avvenga correttamente. Per finire restituiremo un messaggio testuale (All the best for you) al termine dell’esecuzione della funzione.

Vediamo ora come eseguire la Lambda function in Docker.

Dockerfile e Docker-Compose

Per prima cosa dobbiamo preoccuparci di come installare i pacchetti Python aggiuntivi, nel nostro esempio PILLOW. Andremo quindi a creare una nuova immagine Docker grazie ad un dockerfile che parta dall’immagine lambci/lambda:python3.6 e che si occupi di installare tutti i pacchetti aggiuntivi specificati nel file requirements.txt

Per finire, con un file docker-compose.yml andremo a definire un servizio lambda da utilizzare per il debugging offline. Lo scopo è mappare la directory host src per i sorgenti e impostare PYTHONPATH per l’utilizzo dei pacchetti aggiuntivi in /var/task/lib

Come primo test basterà avviare docker-compose per eseguire la nostra funzione Lambda.

docker-compose run lambda src.lambda_function.lambda_handler

Eventi

La nostra funzione si aspetta un evento SQS da processare. Come inviarlo? Per prima cosa dobbiamo procurarci un JSON di test e salvarlo in un file (ad esempio event.json). Andremo poi a specificarlo nella chiamata a docker-compose.

docker-compose run lambda src.lambda_function.lambda_handler "$(cat event.json)"

Vediamo il risultato dell’esecuzione.

Perfetto! La nostra funzione viene eseguita correttamente ed il risultato corrisponde a quanto atteso. L’avvio del container Docker corrisponde ad un “cold start” di AWS Lambda. Vediamo come sia possibile mantenere attivo il container per richiamare più volte la funzione.

Mantenere in esecuzione

In alternativa è possibile avviare e mantenere in esecuzione il container della nostra funzione Lambda in modo da poter effettuare velocemente diverse chiamate consecutive senza attendere i tempi di “cold start”. In questa modalità viene avviato un server API che risponde di default alla porta 9001.

docker-compose run -e DOCKER_LAMBDA_STAY_OPEN=1 -p 9001:9001 lambda src.lambda_function.lambda_handler

Andremo a richiamare la nostra funzione usando, ad esempio, curl.

curl --data-binary "@event.json" http://localhost:9001/2015-03-31/functions/myfunction/invocations

All’endpoint indicato risponde l’handler di default della nostra Lambda function. Il parametro data-binary consente di inviare il contenuto del file JSON relativo all’evento SQS di esempio.

Conclusioni

Ho raccolto in questo repository GitHub i file necessari a ricreare l’ambiente Docker che utilizzo per lo sviluppo ed il debugging offline di funzioni AWS Lambda in Python. Per comodità ho raccolto in un Makefile le operazioni più frequenti.

Il comando make lambda-build realizza il pacchetto di deployment della funzione, comprensivo dei pacchetti aggiuntivi.

Di seguito un esempio di deployment della nostra Lambda function con CloudFormation.

Altri comandi disponibili nel Makefile sono:

## create Docker image with requirements
make docker-build

## run "src.lambda_function.lambda_handler" with docker-compose
## mapping "./tmp" and "./src" folders. 
## "event.json" file is loaded and provided to function  
make lambda-run

## run API server on port 9001 with "src.lambda_function.lambda_handler" 	 
make lambda-stay-open

Ci siamo divertiti? Ci piacerebbe utilizzare questo ambiente per realizzate una pipeline di CI che si occupi di testare la nostra funzione? Nel prossimo post!

disposable email

Disposable: la tua mail “usa e getta” su AWS

11 min

Il 90% dei servizi online prevede un processo di registrazione durante il quale fornire il proprio indirizzo di posta elettronica. Questo andrà poi confermato ricevendo una email provvista di link sul quale cliccare. A questo punto avremo accesso al servizio al quale eravamo interessati ma contemporaneamente daremo il via alla ricezione di newsletter, sondaggi, promozioni, ecc.

Un indirizzo “usa e getta” è utile proprio in queste situazioni: ci consente di portare a termine il processo di registrazione e ci evita di ricevere in futuro email indesiderate, mantenendo “pulita” la nostra mailbox principale, che utilizziamo per lavoro o per scrivere agli amici.

NOVITA’!!

Sei alla ricerca di un servizio email “usa e getta” pronto all’uso, dotato di API fantastiche e integrato con Slack?

my24h.email

Esistono diversi servizi online che offrono la possibilità di creare indirizzi email “usa e getta” ed il più famoso è probabilmente Mailinator. Sono però servizi che poco si prestano ad un utilizzo professionale in ambito aziendale e sono spesso caratterizzati da alcune limitazioni. Il dominio di posta utilizzato, per esempio, è spesso riconosciuto per “usa e getta” e non considerato valido durante il processo di registrazione.

Andremo a realizzare Disposable-mail, un servizio di mail monouso personalizzabile con il proprio dominio internet, utilizzando i building block AWS. Il servizio è totalmente serverless: non sono infatti necessarie istanze EC2 ma solo semplici lambda functions.

Disposable-mail funziona così:

  1. Val sul sito
  2. Scegli un indirizzo email e lo digiti
  3. La mailbox che hai scelto viene immediatamente attivata e puoi iniziare a ricevere email

Se vuoi provarlo lo trovi qui: https://www.my24h.email

Non serve registrarsi o scegliere una password. La mailbox ha validità di un’ora e allo scadere dei 60 minuti tutti i messaggi ricevuti saranno eliminati e la mailbox non sarà più attiva.

Non è possibile inviare email, ma solo riceverle. Gli allegati non vengono visualizzati (ma forse lo saranno nella prossima versione).

Per evitare abusi utilizziamo Google reCAPTCHA nella form di login, in versione hidden.

Requisiti

Per realizzare il tuo servizio di email “usa e getta” sul tuo dominio personale ti serve:

  • un account AWS
  • un dominio internet (meglio un terzo livello) su Route53 o comunque per il quale ti sia possibile configurare la relativa zona DNS
  • una coppia di chiavi (site/private) di Google reCAPTCHA validi per il tuo dominio (reCAPTCHA console)

Tutto qui.

Architettura della soluzione

Come anticipato, Disposable-mail è totalmente serverless e, data la natura di questi building blocks AWS, non ci dovremo preoccupare della loro gestione, la scalabilità è garantita e il costo è “per utilizzo”.

Dal layout si comprende come esistano essenzialmente due workflow: il primo, totalmente automatizzato, di ricezione, verifica, memorizzazione e cancellazione delle email. Il secondo, dato dall’utente che accede all’applicazione React e consulta la propria mailbox. L’elemento comune dei due è rappresentato dal bucket S3 dove vengono memorizzate le email ricevute e le tabelle DynamoDB dove vengono memorizzati i relativi metadati.

Per la ricezione delle email ci affidiamo al servizio Amazon SES (Simple Email Service): ci occuperemo della sua configurazione nel paragrafo successivo. Alcune funzioni lambda si occupano di gestire il flusso delle email. Route53 è utilizzato per ospitare la zona relativa al nostro dominio internet.

La web application è ospitata direttamente in un bucket S3 pubblico e l’endpoint API è gestito dal servizio Amazon API Gateway. Alcune funzioni lambda si occupano di processare le richieste da parte dei client.

Ci affidiamo a CloudWatch per eseguire periodicamente una funzione lambda il cui scopo è disattivare ed eliminare le mailbox scadute.

Primi passi

Il primo passo per la realizzazione della soluzione è la corretta configurazione del servizio Amazon SES. Il template CloudFormation che utilizzeremo per la realizzazione di tutta l’infrastruttura di backend presuppone che il dominio email da noi scelto sia già correttamente configurato e verificato.

Iniziamo la configurazione: dalla console AWS è necessario accedere al servizio SES. Non tutte le Region AWS dispongono di questo servizio: è possibile quindi che si debba utilizzare una Region differente da quella che si utilizza normalmente. L’intera soluzione sarà ospitata, per semplicità, sulla medesima Region.

In Domains viene visualizzato l’elenco di tutti i domini internet per i quali il servizio SES è stato configurato. Dovremo aggiungere il nostro dominio ed effettuare il processo di verifica.

Cliccando su Verify a New Domain andiamo ad aggiungere il nostro dominio specificandone il nome. Come detto in precedenza, meglio utilizzare un dominio di terzo livello perché tutte le mailbox di questo saranno gestite da Disposable-mail.

Una volta aggiunto il dominio dobbiamo occuparci della configurazione delle relativa zona DNS. Si dovranno inserire due record:

  • un record TXT per la verifica da parte di AWS
  • un record MX per abilitare la ricezione delle email

Se la zona DNS è gestita da Route53, tutto quello che dobbiamo fare è cliccare su Use Route 53: la configurazione dei due record sarà automatica. In caso contrario è necessario fare riferimento al proprio provider per modificare la zona come indicato.

Una volta che il dominio sarà stato verificato, saremo in grado di ricevere email tramite Amazon SES.

Backend – Ricezione emails

Come vedremo, il template CloudFormation crea una nuova regola di ricezione email (Rule Set) di tipo “catch-all”, cioè valida per tutti gli indirizzi email del tipo <qualsiasi.cosa>@<dominio.scelto>

Il “Recipient” specificato corrisponde infatti al nostro dominio.

La prima Action si occupa di invocare una funziona lambda e di verificarne il responso. La funzione IncomingMailCheckFunction si occupa di verificare che l’indirizzo di destinazione della mail sia valido e attivo. Indica poi al servizio SES se proseguire (CONTINUE) o interrompere l’esecuzione di ulteriori azioni (STOP_RULE_SET).

La seconda Action, eseguita esclusivamente nel caso in cui la precedente lo abbia consentito, comporta il salvataggio della email nel bucket S3, il cui nome corrisponde a “incoming.disposable.<nome_dominio>” e la notifica dell’avvenuto salvataggio ad un topic SNS “IncomingMailTopic“.

Come avviene la verifica dell’indirizzo email? Una tabella DynamoDB è utilizzata per memorizzare l’elenco degli indirizzi email attivi e la relativa scadenza (TTL). La chiave della tabella è proprio l’indirizzo email (address).

## part of IncomingMailCheckFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ['addresses_table_name'])

def address_exists(address):
  exists = False
  response = table.get_item(Key={'address': address})
  if 'Item' in response:
      item = response['Item']
      if item['TTL'] > int(time.time()):
         exists = True

def lambda_handler(event, context):
  for record in event['Records']:
    to_address = record['ses']['mail']['destination'][0]
    if address_exists(to_address):
       return {'disposition': 'CONTINUE'}
    else:
       return {'disposition': 'STOP_RULE_SET'}
    break;

Per completare l’analisi del flusso di ricezione delle email, vediamo la funzione StoreEmailFunction. Questa funzione si occupa di memorizzate i dati della email ricevuta nel database DynamoDB, nella tabella “emails”. La tabella ha come Primary Key la stringa “destination” che corrisponde all’indirizzo email ed una Sort Key che corrisponde invece al “messageId” della email stessa. La funzione lambda riceve le notifiche dal topic SNS.

## part of StoreEmailFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ['emails_table_name'])

## Store email details in database
def store_email(email, receipt):
   response = table.put_item(
                  Item= {'destination': email['destination'][0],
                         'messageId': email['messageId'],
                         'timestamp': email['timestamp'],
                         'source': email['source'],
                         'commonHeaders': email['commonHeaders'],
                         'bucketName': receipt['action']['bucketName'],
                         'bucketObjectKey': receipt['action']['objectKey'],
                         'isNew': "true"
                  }
   )

def lambda_handler(event, context):
    message = json.loads(event['Records'][0]['Sns']['Message'])
    store_email(message['mail'], message['receipt'])

Nella notifica SNS è presente il nome del bucket S3 e l’objectKey utilizzato per memorizzarla. Salvando le informazioni nella tabella DynamoDB, sarà possibile successivamente recuperare il contenuto della email per la visualizzazione.

La funzione CleanUpFunction viene invocata periodicamente da CloudWatch al fine di svuotare e disattivare le mailbox scadute. Si occupa inoltre di eliminare i record di sessione anch’essi scaduti.

Viene effettuato uno scan sulle tabelle addresses e sessions verificando il valore dell’attributo TTL.

## part of CleanUpFunction, see Github repository for 
## the complete functions code.

def cleanup():
    ## get expired addresses
    try:
        response = addresses_table.scan(
            FilterExpression = Attr('TTL').lt(int(time.time())),
            ProjectionExpression = "address"
        )
    except ClientError as e:
        logger.error('## DynamoDB Client Exception')
        logger.error(e.response['Error']['Message'])
    else:
        for I in response['Items']:
            find_emails(I['address'])
            delete_address_item(I['address'])

Backend – API

API Gateway viene utilizzato come endpoint REST (regional) per la web application.

Viene utilizzata l’integrazione lambda ed esistono tre funzioni lambda dedicate alla creazione di una nuova mailbox (CreateEmailFunction), alle consultazione dell’elenco delle email presenti (GetEmailsListFunction) e al download del contenuto di una email specifica (GetEmailFileFunction).

La funzione CreateEmailFunction risponde al metodo GET della risorsa /create e prevede due parametri:

  • address – l’indirizzo email da creare
  • captcha – la chiave necessaria per la verifica di reCAPTCHA

Entrambi i parametri vengono validati e nel caso in cui l’indirizzo email non esista, lo stesso viene creato inserendo il relativo record nella tabella addresses. La funzione restituisce nel body un parametro sessionID che sarà utilizzato dal client per le ulteriori chiamate alle API. Gli ID di sessione validi e la relativa scadenza sono memorizzati nella tabella sessions.

## part of CreateEmailFunction, see Github repository for  
## the complete functions code.

import http.client, urllib.parse
import os
import re

valid_domains = os.environ['valid_domains'].split(',')
recaptcha_key = os.environ['recaptcha_key']

## A very simple function to validate email address
def validate_email(address):
    valid = False
    # Do some basic regex validation 
    match = re.match('^[_a-z0-9-]+(\.[_a-z0-9-]+)*@[a-z0-9-]+(\.[a-z0-9-]+)*(\.[a-z]{2,4})$', address)
    if (match != None):
        domain = address.split("@")[-1]
        if domain in valid_domains:
            valid = True
    return valid

## reCAPTCHA validation function
def validate_recaptcha(token):
    conn = http.client.HTTPSConnection('www.google.com')
    headers = {"Content-type": "application/x-www-form-urlencoded"}
    params = urllib.parse.urlencode({'secret': recaptcha_key, 'response': token}) 
    conn.request('POST', '/recaptcha/api/siteverify', params, headers)
    response = conn.getresponse()
    r = response.read().decode() 
    r = json.loads(r)
    return r['success']

La funzione GetEmailsListFunction risponde al metodo GET della risorsa /{destination} dove il parametro {destination} è l’indirizzo della mailbox. La funzione ritorna l’elenco delle email presenti nella mailbox, dopo aver validato il sessionID fornito nella richiesta (queryString).

L’elenco delle email è ottenuto tramite una Query sulla tabella emails.

## part of GetEmailsListFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
emails_table = dynamodb.Table(os.environ['emails_table_name'])
sessions_table = dynamodb.Table(os.environ['sessions_table_name'])

## Get emails list from DB
def get_emails(destination):
    items = None
    try:
        filtering_exp = Key('destination').eq(destination)
        response = emails_table.query(KeyConditionExpression=filtering_exp)        
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        #Remove private bucket details        
        for i in response['Items']:
            i.pop('bucketObjectKey', None)
            i.pop('bucketName', None)
        items = {'Items' : response['Items'], 'Count' : response['Count']}
    return items

## Verify session
def session_is_valid(sessionid):
    valid = False
    try:
        response = sessions_table.get_item(
            Key={
            'sessionId': sessionid
            }
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        if 'Item' in response:
            item = response['Item']
            if item['TTL'] > int(time.time()):
                valid = True
    return valid

def lambda_handler(event, context):

    headers = {
        "access-control-allow-headers": 
           "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
        "access-control-allow-methods": 
           "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
        "access-control-allow-origin": "*"
    }
     
    if session_is_valid(event["queryStringParameters"]["sessionid"]):
        destination = event["pathParameters"]["destination"]
        items = get_emails(destination)
        if items != None:
           result = {"statusCode": 200, "body": json.dumps(items), "headers": headers }

    return result    

La funzione GetEmailFileFunction risponde al metodo GET della risorsa /{destination}/{messageId} dove il parametro {destination} è l’indirizzo della mailbox e {messageId} è l’identificativo univoco della mail. Anche questa funzione verifica il sessionID fornito nella richiesta (queryString).

Restituisce il contenuto della mail leggendolo dal bucket S3 e si occupa di segnare come “letta” il relativo record della tabella emails.

## part of GetEmailFileFunction, see Github repository for  
## the complete functions code.

dynamodb = boto3.resource("dynamodb")
emails_table = dynamodb.Table(os.environ['emails_table_name'])
s3 = boto3.client('s3')

## Get emails details, including s3 bucket and s3 bucket object key.
def get_email_file(destination, messageId):
    result = None
    try:
        response = emails_table.get_item(
            Key={
            'destination': destination,
            'messageId' : messageId
            }
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])
    else:
        if 'Item' in response:
            result = response['Item']
    return result

## Set item ad readed in DynamoDB table
def set_as_readed(destination, messageId):
    try:
        response = emails_table.update_item(
            Key={
            'destination': destination,
            'messageId' : messageId
            },
            UpdateExpression="SET isNew = :updated",                   
            ExpressionAttributeValues={':updated': 'false'}
        )
    except ClientError as e:
        logger.info('## DynamoDB Client Exception')
        logger.info(e.response['Error']['Message'])

def lambda_handler(event, context):

    headers = {
        "access-control-allow-headers": 
           "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
        "access-control-allow-methods": 
           "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
        "access-control-allow-origin": "*"
    }

    email_file = get_email_file(destination, messageId) 
    if email_file != None:
        data = s3.get_object(Bucket=email_file['bucketName'], Key=email_file['bucketObjectKey'])
        contents = data['Body'].read().decode('utf-8')
        headers.update({"content-type": "message/rfc822"})
        result = {
           "statusCode": 200,
            "headers": headers,
            "body":  contents 
        }
        if email_file['isNew'] == 'true':
           set_as_readed(destination, messageId)

    return result

Al fine di abilitare CORS è stato aggiunto un metodo OPTION per ciascuna risorsa gestita da API Gateway. In aggiunta, le funzioni lambda restituiscono degli header specifici.

Il template CloudFormation si occupa inoltre di creare la distribuzione delle API e il relativo stage in modo da ottenere un endpoint valido per la configurazione della nostra web application.

Installazione backend

Una volta terminata la configurazione del servizio SES per il proprio dominio come spiegato nella sezione Primi Passi, utilizzeremo un template di CloudFormation per realizzare l’intera infrastruttura di backend. Il template prevede la creazione del bucket S3, delle tabelle DynamoDB, di tutte le funzioni lambda e delle API. Prevede inoltre la creazione di tutti i ruoli IAM, i permessi e la configurazione dei vari servizi, incluso il topic SNS.

AWSTemplateFormatVersion: 2010-09-09
Description: Disposable Email

Parameters:
    DomainName:
        Description: Domain name to be used
        Type: String
    ReCaptchaPrivateKey:
        Description: ReCaptcha private key to validate mailbox creation requests  
        Type: String
        Default: ""
    AddressesTableName:
        Description: DynamoDB table to store valid addresses
        Type: String
        Default: "disposable_addresses_table"
    EmailsTableName:
        Description: DynamoDB table to store emails 
        Type: String
        Default: "disposable_emails_table"
    SessionsTableName:
        Description: DynamoDB table to store client sessions 
        Type: String
        Default: "disposable_sessions_table"  
    RuleSetName:
        Description: SES RuleSet name to be used 
        Type: String
        Default: "default-rule-set"
    MailboxTTL:
        Description: Life (seconds) of mailbox
        Type: String
        Default: "3600"
    SessionTTL:
        Description: Session expiration in seconds
        Type: String
        Default: "600"
    SourceBucket:
        Description: Lambda functions source S3 Bucket
        Type: String
        Default: "cfvn" 

I sorgenti delle funzioni lambda non sono presenti direttamente nel template ma in file python esterni. Durante la creazione dello stack, i file ZIP delle funzioni lambda devono essere ospitati in un bucket S3. E’ possibile specificare un bucket differente da quello “ufficiale” (specificato come parametro “SourceBucket“) se si intende modificarli prima della creazione dello stack.

Per realizzare l’intera infrastruttura di backend puoi utilizzare la console di CloudFormation oppure AWS CLI:

aws cloudformation create-stack --stack-name <name> --template-url <url> --parameters <parameters> --capabilities CAPABILITY_IAM
DescrizioneEsempio
<name> il nome che vuoi dare allo stackdisposable_stack
<url>url del templatehttps://cfvn.s3-eu-west-1.amazonaws.com/disposable.yml

<parameters> è invece l’elenco dei parametri da fornire al template. I due parametri principali e obbligatori sono DomainName e ReCaptchaPrivateKey.

ParameterKey=DomainName,ParameterValue=<your_domain> 
ParameterKey=ReCaptchaPrivateKey,ParameterValue=<your_private_captcha_key>

Una volta creato la stack, ci interessa conoscere l’endpoint da utilizzare per la configurazione della web application. Questo è fornito da CloudFormation come output. E’ possibile vederlo nella console di CloudFormation oppure direttamente da AWS CLI:

aws cloudformation describe-stacks --stack-name <name> --query Stacks[0].Outputs[0].OutputValue

Frontend

L’applicazione React / Material-UI è molto semplice: si compone di tre semplici componenti: LoginForm per consentire la creazione e l’accesso ad una mailbox, EmailList per la visualizzazione dell’elenco dei messaggi presenti e EmailViewer per la visualizzazione del contenuto di una mail.

Il componente principale App prevede nel proprio stato l’indirizzo email e l’id di sessione, entrambi inizialmente vuoti. Viene costruito il componente LoginForm che gestirà il processo di login.

<LoginForm changeAddress={this.changeAddress.bind(this)} 
           apiEndpoint={APIEndpoint}
           recaptcha_key={ReCaptcha_SiteKey}
           email_domain={email_domain}/>

Il componente LoginForm incorpora il componente reCAPTCHA e si occupa di chiamare l’API /create sull’evento onSubmit della form.

handleSubmit(event) {
        const recaptchaValue = this.recaptchaRef.current.getValue();
        if (recaptchaValue === '') {
           window.location.reload();
        } else { 
            console.log("Captcha value:", recaptchaValue);
        
          fetch(this.props.apiEndpoint + 'create?address=' 
             + encodeURI(this.state.address + this.props.email_domain)
             + '&captcha=' + recaptchaValue)
          .then(r =>  r.json().then(data => ({status: r.status, body: data})))
          .then(r => {
            console.log(r);
            console.log('Response from API: ' + r.body.message);
            if (r.status === 200) {
              this.props.changeAddress(this.state.address + this.props.email_domain, r.body.sessionid);  
            }
        })
        .catch(console.log);
        }
        event.preventDefault();
    }

A login avvenuta, si ottiene un sessionID. Il componente App si occupa di costruire il componente EmailList.

<EmailList address={this.state.address} 
           sessionid={this.state.sessionid}
           changeAddress={this.changeAddress.bind(this)} 
           apiEndpoint={APIEndpoint}/>

Il componente EmailList si occupa di chiamare l’API /{destination} per ottenere l’elenco delle email.

getList(force) {
        fetch(this.props.apiEndpoint + this.state.address +
              '?sessionid=' + encodeURI(this.props.sessionid))
        .then(response => {
            const statusCode = response.status;
            const data = response.json();
            return Promise.all([statusCode, data]);
          })
        .then(res => {
            console.log(res);
            if (res[0] === 400) {
                this.logout();
            } else {      
                res[1].Items.sort(function(a,b){
                    if (a.timestamp > b.timestamp) { return -1 } else { return 1 }
                });         
                if ((this.listIsChanged(res[1].Items) || force)) {
                    this.setState({ emails: res[1].Items });
                    if ((this.state.selectedId === '') && (res[1].Items.length > 0)) {
                        this.setState({ selectedId: res[1].Items[0].messageId });   
                    }
                }
            }
        })
        .catch(console.log)
    }

Lo stesso si occupa inoltre di creare un componente EmailViewer per la visualizzazione del messaggio selezionato.

<EmailViewer address={this.state.address} 
             messageId={this.state.selectedId} 
             sessionid={this.state.sessionid} 
             apiEndpoint={this.props.apiEndpoint}/> 

La visualizzazione del messaggio email è la parte più complessa dell’applicazione client. Ottenuto il contenuto dalla API /{destination}/{messageId} è necessario effettuare il parsing dello stesso.

A tale scopo è utilizzata la libreria emailjs-mime-parser che consente di accedere ai vari oggetti che costituiscono la mail. La funzione getMailContents si occupa di scegliere il body corretto da mostrare, dando precedenza ai contenuti HTML.

Per visualizzare il contenuto HTML si utilizza un “trucco” molto pericoloso:

<div className="body" dangerouslySetInnerHTML={{__html: this.state.messageData}} />

Installazione Frontend

Per realizzare la web application React, una volta clonato il repository, è necessario configurare alcuni parametri che sono presenti all’inizio di App.js:

//  - your APIEndpoint
const APIEndpoint = <your_API_endpoint>;

//  - your ReCaptcha Site Key 
const ReCaptcha_SiteKey = <your_reCAPTCHA_site_key>;
 
//  - your email domain
const email_domain = <your_domain>;

Usa npm start per testare l’applicazione e npm run build per prepare i file da posizionare sul tuo hosting preferito oppure, come ho fatto io, direttamente in un bucket S3 pubblico. Ricordati di abilitare il dominio “localhost” tra quelli consentiti da reCAPTCHA per poter testare localmente la tua applicazione.

Conclusione

In questo repository GitHub trovi sia i sorgenti dell’applicazione React che il template CloudFormation per la realizzazione del backend AWS.

Disposable-mail nasce come esempio di realizzazione di una soluzione cloud native. Ci sono sicuramente molti aspetti da perfezionare e sarebbe necessario renderla più sicura. Si tratta comunque di una buona base di partenza per la realizzazione della propria webmail “usa e getta”.

Ci siamo divertiti? Alla prossima!

VPN

Come realizzare un semplice VPN server su AWS

7 min

Realizzare un semplice VPN server su AWS può aiutarci a risolvere qualche piccolo problemino quotidiano: mi è servito, ad esempio, quando ho preso la rivoluzionaria decisione di rinunciare alla linea ADSL casalinga a favore di una ben più performante connettività LTE 4G. Vivendo in una zona non ancora raggiunta (e chissà quando accadrà) dalla fibra, ho deciso infatti di approfittare di una delle offerte “a tanti GB inclusi”  superando di gran lunga le prestazioni della tradizionale linea di terra e risparmiando anche un pò ogni mese.

Si è però subito posto un problema: come noto, le connettività mobile sono quasi sempre di tipo NAT, cioè l’indirizzo IP pubblico che si usa per la propria presenza su internet è in realtà condiviso tra più dispositivi e utenti. Di per se questo tipo di connettività non rappresenta un limite per accedere ai servizi web ma, al contrario, rende di fatto impossibile contattare il proprio dispositivo 4G da un altro dispositivo posto sulla rete pubblica. Come posso quindi connettermi, ovunque mi trovi, ai dispositivi della mia LAN casalinga se NATtati dietro una connettività mobile?

Per questo motivo e per altri che vedremo, può far comodo realizzare una semplice rete VPN.

Il cuore di una rete VPN è un server centrale, raggiungibile da tutti i client che saranno autenticati dallo stesso. Una volta connessi, i dispositivi posti sulla VPN potranno dialogare in modo sicuro tra loro superando le limitazioni dei livelli di rete sottostanti, come se si trovassero su un’unica – virtuale e privata – LAN.

Vediamo come realizzare un semplice VPN server su AWS utilizzando OpenVPN. Il diagramma che seguiremo è il seguente.

Realizziamo il server

Avremo bisogno di un’istanza EC2: per rapidità useremo un’istanza On-Demand. Ci occuperemo di installare e configurare tutto il software necessario; successivamente andremo a realizzare una nostra AMI in modo da poter ricreare rapidamente il nostro server.

Avviamo la nostra EC2 basata su Ubuntu Server 18.04 LTS: sarà molto semplice configurare il nostro server OpenVPN senza aver la necessità di utilizzare AMI del MarketPlace.

Un’istanza t2.micro Free-Tier è assolutamente adatta alla scopo; configuriamo un Security Group in modo che sia raggiungibile tramite protocollo SSH (TCP/22).

Una volta avviata, colleghiamoci alla console utilizzando l’utente ubuntu.

ssh -i <keypairfile.pem> [email protected]<ec2_public_ip>

Aggiorniamo subito la nostra istanza e recuperiamo lo script per l’installazione di OpenVPN

sudo apt-get update
sudo apt-get upgrade -y
wget https://git.io/vpn -O openvpn-install.sh
chmod +x openvpn-install.sh

A questo punto, prima di lanciare lo script di installazione è necessario fare una premessa: per contattare il nostro server OpenVPN sarà necessario fornire ai client l’indirizzo di quest’ultimo. L’indirizzo può essere un IP statico oppure un record DNS. La soluzione più semplice consiste nell’assegnare un Elastic IP alla nostra istanza. Tuttavia ho preferito rendere la nostra configurazione indipendente anche dall’indirizzo IP assegnato al server, utilizzando Route 53 di AWS per definire un hostname pubblico a cui i nostri client faranno riferimento.

Eseguiamo lo script

sudo ./openvpn-install.sh

La prima cosa che ci verrà chiesta è proprio l’indirizzo del server OpenVPN, che lo script tenta di determinare automaticamente. Se abbiamo assegnato un Elastic IP alla nostra istanza AWS, confermiamo l’indirizzo. Se invece intendiamo utilizzare un host in una zona Route 53, specifichiamo il nome host completo. Nel mio caso: vpn.aws.gotocloud.it.

Confermiamo poi tutte le successive richieste con i valori di default proposti e avviamo l’installazione.

Al termine dell’installazione un messaggio ci ricorda che il file di configurazione ed il certificato da utilizzare su uno dei nostri client (laptop, smartphone, raspberry, etc..)  è disponibile in /home/ubuntu/client.ovpn

Assicuriamoci di avviare OpenVPN.

sudo systemctl enable openvpn
sudo systemctl start openvpn

Alcune informazioni: lo script crea la rete 10.8.0.0/24 ed assegna l’indirizzo ip 10.8.0.1 al nostro OpenVPN server. Gli indirizzi IP successivi saranno assegnato ai client. Utilizzeremo questi indirizzo per comunicare tramite la nostra VPN.

OpenVPN utilizza di default la porta UDP 1194.  Dobbiamo quindi provvedere a modificare il SecurityGroup dell’instanza EC2 su cui stiamo lavorando aggiungendo questa policy.

Se abbiamo deciso di utilizzare un indirizzo Elastic IP, la configurazione del server è terminata. Se invece abbiamo deciso di utilizzare un nome host di una zona DNS ospitata su Route 53, dovremo fare in modo di aggiornare il relativo record A con l’indirizzo pubblico che AWS ha assegnato alla nostra istanza, ripetendo l’operazione ad ogni avvio. Per farlo utilizziamo uno script Python.

Integrazione con Route53

Per prima cosa installiamo le dipendenze tra cui Boto3 che è il SDK di AWS per Python. Dato che lo script verrà eseguito come root all’avvio, anche le relative dipendenze vanno installate nell’ambiente di root.

sudo apt install python-pip -y
sudo su
pip install boto3
pip install requests
exit

Andiamo ora a creare lo script “update_route53_zone.py” con il contenuto seguente.

import requests
import json
import boto3

META_DATA_URL = 'http://169.254.169.254/latest/meta-data/public-ipv4'

# Route53 host to be updated
zone_name = "aws.gotocloud.it."
A_record_name = "vpn"

# Get Public IP Address
r = requests.get(META_DATA_URL)
public_ip = r.text

# Prepare data for Route53 changes
data = {}
data['Comment'] = "Update openvpn DNS record"
data['Changes'] = []
data['Changes'].append({
    'Action': 'UPSERT',
    'ResourceRecordSet': {
        "Name": A_record_name + '.' + zone_name,
        "Type": "A",
        "TTL": 60,
        "ResourceRecords": [
          {
            "Value": public_ip
          }
        ]
      }
})

# Get hosted zone list to find zone_id
client = boto3.client('route53')
zones = client.list_hosted_zones()
zone_id = ''
for z in zones['HostedZones']:
	if (z['Name'] == zone_name):
		zone_id = z['Id']
		break 

# Change Route53 record
if zone_id != '':
	response = client.change_resource_record_sets(HostedZoneId=zone_id, ChangeBatch=data)
	print(response)

Lo script ottiene l’indirizzo IP pubblico dell’instanza e aggiorna la zona DNS specificata inserendo o aggiornando l’host indicato come “A_record_name”. Per poter procedere all’aggiornamento della zona DNS, è necessario assegnare all’instanza un ruolo dotato delle policy corrette.

Le API di Route 53 utilizzate sono “ChangeResourceRecordSets” e “ListHostedZones”.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "route53:ChangeResourceRecordSets",
            "Resource": "arn:aws:route53:::hostedzone/*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "route53:ListHostedZones",
            "Resource": "*"
        }
    ]
}

Una volta assegnato il ruolo, eseguendo lo script come root, verrà creato o aggiornato il record DNS. Per fare in modo che questo avvenga ad ogni avvio, utilizziamo CRON.

sudo cp update_route53_zone.py /bin
sudo crontab -e

Andremo ad inserire:

@reboot python /bin/update_route53_zone.py &

I client

Per aggiungere utenti/dispositivi alla nostra VPN utilizziamo ancora lo script openvpn-install.sh. E’ consigliato creare un utente per ogni dispositivo che andremo a connettere alla nostra VPN specificandone un nome che ci permetta di riconoscerlo facilmente. Inoltre lo script può essere utilizzato per revocare un utente.

E’ il momento del client: nella sezione Community Download di OpenVPN troviamo i client per varie versioni di Windows. Su MacOS consiglio di utilizzare TunnelBlick. IOS e Android prevedono l’app ufficiale di OpenVPN nei rispettivi store. Per configurare  Raspbian (Raspberry PI) ho invece seguito le indicazioni presenti in questo thread.

Indipendentemente dal client utilizzato, ci servirà il file OVPN generato dallo script di configurazione che dovremo quindi trasferire dal server (SFTP). Normalmente la configurazione del client è abbastanza semplice: con MacOS e TunnelBlick, ad esempio, basterà un doppio click sul file OVPN perchè questo venga importato. Rimarrà solo da avviare la VPN.

Se tutto è andato per il meglio, dal nostro dispositivo in VPN sarà possibile pingare con successo l’indirizzo del nostro VPN server 10.8.0.1.

Raspberry PI

Per il setup casalingo ho invece scelto di installare il client OpenVPN su un dispositivo Raspberry PI da utilizzare poi anche come bridge verso gli altri dispositivi presenti sulla mia LAN. L’installazione su Raspbian è semplice.

sudo apt-get install openvpn

Andremo a copiare il file OVPN nella directory corretta, cambiando l’estensione in .CONF. Procederemo poi ad avviare il servizio.

sudo cp pi4.ovpn /etc/openvpn/pi4.conf
sudo systemctl enable openvpn
sudo systemctl start openvpn

Il nostro PI è ora connesso alla VPN e lo potremo verificare pingando, anche in queso caso, l’indirizzo IP 10.8.0.1 del server. Al contrario, se il PI ospita per esempio un repository per i nostri file (Owncloud) , questo sarà raggiungibile da tutti i dispositivi connessi in VPN (ad esempio il nostro smartphone) tramite l’indirizzo IP assegnato al PI sulla rete 10.8.0.0/24

Se vogliamo inoltre che il PI si comporti da VPN gateway, pubblicando servizi ospitati sul nostra LAN da altri dispositivi (ad esempio, il modulo IP della nostra centrale antifurto) potremo utilizzare un reverse proxy come NGINX. Un esempio di configurazione è il seguente: tutte le richieste pervenute al nostro PI sulla porta 10000 vengono girate sull’indirizzo LAN della nostra centrale antifurto, rendendola di fatto accessibile tramite VPN da qualunque dispositivo connesso alla stessa.

stream {
    upstream paradox {
        server 192.168.22.3:10000;
    }

    server {
             listen 10000;
             proxy_pass paradox;
    }
}

Navigazione tramite VPN

Se avete mantenuto la configurazione di default così come indicato in questo articolo, vi sarete resi conto che, una volta avviata la VPN dal nostro client (ad esempio dal nostro laptop), questa viene utilizzata anche per la navigazione WEB: in pratica il nostro server EC2 si comporta come nostro personale gateway per andare sulla rete pubblica. Di conseguenza ci presenteremo su internet con l’indirizzo IP del server OpenVPN nella Region AWS dove è stato creato. Possiamo scegliere in che parte del mondo far finta di essere, che è poi una delle prerogative dei tanti servizi VPN a pagamento!

Se invece vogliamo evitare questo comportamento e, al contrario, intendiamo utilizzare la nostra VPN solo per raggiungere i dispositivi connessi alla stessa, dovremo modificare la configurazione di OpenVPN.

Modifichiamo il file /etc/openvpn/server/server.conf e andiamo a commentare queste direttive.

#push "redirect-gateway def1 bypass-dhcp"
#push "dhcp-option DNS 1.1.1.1"
#push "dhcp-option DNS 1.0.0.1"

Ora riavviamo il server VPN e il nostro traffico web non sarà più incanalato tramite VPN.

Conclusioni

Esistono moltissime alternative alla soluzione proposta per risolvere i problemi di raggiungibilità dietro NAT, connessione sicura di più dispositivi over internet e navigazione originata da una region diversa dalla nostra. La soluzione OpenVPN/AWS ha però diversi vantaggi. Vediamoli.

  • OpenVPN è gratuito ed esistono client per tutti i principali sistemi operativi
  • Utilizzando AWS è possibile creare rapidamente il proprio OpenVPN server in diverse Region del mondo (US/EU/etc..) utilizzando CloudFormation.
  • La configurazione è semplice e personalizzabile secondo le proprie esigenze
  • Costa meno di altri servizi VPN: pagheremo la sola istanza EC2 (on-demand, reserved o addirittura Spot se possiamo tollerare interruzioni del servizio)
  • Non ha limiti di utilizzo
  • E’ personale e autogestito: nessun log o dato fornito a un provider VPN
  • E’ possibile utilizzarlo come bastion host per collegarsi alle nostre risorse AWS

Ci siamo divertiti? Alla prossima!

Usare correttamente i layers di AWS Lambda

5 min

Cosa sono i livelli di AWS Lambda? Come sappiamo le funzioni AWS Lambda ci consentono di eseguire codice nel cloud secondo il paradigma serverless. Ciascuna applicazione cloud serverless è normalmente caratterizzata da molteplici funzioni Lambda indipendenti in grado di risponde ad eventi specifici (rest API, scheduled, triggers). Ogni funzione Lambda è definita dal proprio pacchetto di deployment che contiene il codice sorgente della stessa e gli eventuali requisiti, come librerie aggiuntive, dipendenze e middleware.

In questa tipologia di architettura, i livelli di AWS Lambda di consentono di introdurre il concetto di riusabilità del codice o delle dipendenze, in modo da condividere moduli tra diverse funzioni: i layers sono infatti semplici pacchetti riutilizzabili nella definizione delle proprie Lambda che di fatto estendono il runtime base. Vediamo come si utilizzano e la mia esperienza a riguardo.

AWS Lambda layers 101

Come si prepara un livello di AWS Lambda? Per mostrarlo consideriamo questo esempio: una Lambda realizzata in Python che richiede di eseguire un applicativo binario non compreso nel runtime AWS standard. Nel nostro esempio l’applicativo è un semplice script bash.

#!/bin/bash

# This is version.sh script
echo "Hello from layer!"

Per creare il Layer dobbiamo preparare un archivio ZIP con la seguente struttura:

layer.zip
└ bin/version.sh

Dalla console di AWS Lambda ci basta procedere alla creazione del livello specificando l’archivio ZIP sorgente e gli eventuali runtime compatibili.

Supponiamo ora di creare una Lambda function che utilizzi tale layer. Il codice potrebbe essere simile a questo:

import json

def lambda_handler(event, context):
    import os

    stream = os.popen('version.sh')
    output = stream.read()
    
    return {
        'statusCode': 200,
        'body': json.dumps('Message from script: {}'.format(output))
    }

Il suo output sarà:

{
  "statusCode": 200,
  "body": "\"Message from script: Hello from layer!\\n\""
}

La nostra funzione AWS Lambda esegue correttamente lo script bash incluso nel layer. Questo accade perché il contenuto del Layer viene estratto nel folder /opt. Poiché abbiamo utilizzato la struttura prevista da AWS per la realizzazione dell’archivio ZIP di deployment, il nostro script bash è già incluso nel PATH (/opt/bin). Ottimo!

Considerando un esempio più completo, un progetto in Python di cui ho parlato in un altro post: l’utilizzo di Chromium e Selenium in una funzione AWS Lambda.

Per utilizzare Chromium in una funzione Lambda è necessario includere i binaries e le relative librerie nel pacchetto di deployment, in quanto, ovviamente, AWS non li prevede nel runtime Python standard. Il mio primo approccio è stato quello di non utilizzare alcun layer ottenendo un unico pacchetto ZIP da più di 80MB. Ogni qualvolta volevo aggiornare il codice della mia funzione Lambda, ero costretto a effettuare l’upload dell’intero pacchetto, con conseguente lunga attesa. Considerando il numero di volte che ho ripetuto l’operazione durante la fase di sviluppo del progetto e che il sorgente della funzione era una piccolissima parte dell’intero pacchetto (poche righe di codice), mi rendo conto di quanto tempo ho sprecato!

Il secondo approccio, decisamente più smart, è stato di utilizzare un layer di AWS Lambda per includere i binaries di Chromium e tutti i packages Python richiesti in maniera analoga a quanto visto in precedenza. La struttura è questa:

layer.zip
└ bin
  └ chromium
    chromedriver    
    fonts.conf      
    lib
    └ ...        
└ python
  └ selenium
    selenium-3.14.0.dist-info
    ...

Per installare i packages Python ho utilizzato il solito PIP:

pip3 install -r requirements.txt -t python

Una volta creato il layer, i tempi necessari per il deployment della funzione si sono ridotti notevolmente, il tutto a favore della produttività.

Qualche informazione in più sui Layer di AWS Lambda:

  • Sono utilizzabili da più Lambda
  • Si possono aggiornare e ogni volta viene creata una nuova versione
  • Le versioni sono numerate automaticamente da 1 a salire
  • Le versioni precedenti non vengono eliminate
  • Possono essere condivisi con altri Account AWS e resi pubblici
  • Sono specifici di una Region AWS
  • In presenza di più layer in una Lambda, questi vengono “fusi” insieme secondo l’ordine specificato, sovrascrivendo eventuali file già presenti
  • Una funzione può utilizzare fino a 5 livelli alla volta
  • Non consentono di superare il limite della dimensione del pacchetto di distribuzione di AWS

Quando utilizzare i layers di AWS Lambda

Nel mio caso specifico, l’utilizzo di un layer ha portato grandi benefici riducendo i tempi di deployment. Mi sono chiesto se quindi è sempre una buona idea utilizzare i livelli di AWS Lambda. Spoiler alert: la risposta è no!

Esistono due ragioni principali per utilizzare i layers:

  • la riduzione della dimensione dei pacchetti di deployment delle AWS Lambda
  • la riusabilità di codice, middleware e binaries

Questo ultimo punto è proprio il più critico: cosa accade alle funzioni Lambda durante il ciclo di vita dei layers da cui dipendono?

I layer possono essere eliminati: la rimozione di un layer non comporta problemi alle funzioni che già lo utilizzano. E’ possibile modificare il (solo) codice della funzione ma, se è necessario modificare i livelli dai quali dipende, andrà rimossa la dipendenza al layer non più disponibile.

I layer possono essere aggiornati: la creazione di una nuova versione di un layer non comporta problemi alle funzioni che utilizzano le versioni precedenti. Il processo di aggiornamento delle lambda non è però automatico: se necessario dovrà essere specificata la nuova versione di layer nella definizione della lambda, rimovendo prima la precedente. Sebbene l’utilizzo dei layer possa quindi consentire la distribuzione di fix e patch di sicurezza relativi ai componenti comuni alle nostre lambda, si deve tener conto che tale processo non è completamente automatizzato.

AWS Lambda layers: test più complesso?

Oltre a quanto già evidenziato nel paragrafo precedente, l’utilizzo di layer comporta la necessità di affrontare nuove sfide, in particolare nell’ambito dei test.

Il primo aspetto da tenere in considerazione è che un layer determina l’introduzione di dipendenze che sono disponibili solo a runtime, rendendo più complessa la possibilità di debuggare il proprio codice localmente. La soluzione è effettuare il download da AWS del contenuto dei layer da cui dipende la funzione da testare ed includerlo durante il processo di build. Non molto pratico, comunque.

Analogamente l’esecuzione di unit tests e integration tests subisce un aumento di complessità: come per il debugging locale, è necessario che il contenuto dei layer sia disponibile durante l’esecuzione.

Il secondo aspetto riguarda invece linguaggi statici come Java o C#, per i quali è richiesto che tutte le dipendenze siano disponibili per compilare DLL o JAR. Ovviamente anche in questo caso esistono soluzioni più o meno eleganti, come il caricamento a runtime.

Security & Performance

In generale l’introduzione di livelli di AWS Lambda non comporta svantaggi in termini di sicurezza: al contrario è possibile effettuare il deployment di nuove versioni di layer esistenti per rilasciare security patch. Come visto in precedenza è bene ricordare che il processo di aggiornamento non è automatico.

Particolare attenzione va invece posta nei confronti di layer di terze parti: esistono diversi livelli resi disponibili pubblicamente e dedicati a vari ambiti. Sebbene sia effettivamente comodo poter utilizzare un layer già configurato per uno scopo ben specifico, è ovviamente meglio realizzare direttamente i propri layer in modo da non finire vittima di codice malevolo. In alternativa è sempre consigliato verificare prima il repository del layer che si intende utilizzare.

Performance: l’utilizzo dei layer in alternativa ad un package all-in-one, non comporta alcun effetto anche in caso di cold start.

CloudFormation

La creazione di Layer di AWS Lambda in CloudFormation è molto semplice. I Layer sono risorse di tipologia AWS::Lambda::LayerVersion. Nella definizione delle funzioni Lambda, il parametro Layers consente di specificare una lista di (massimo 5) dipendenze.

Ecco un esempio:

Conclusioni

My two cents: l’utilizzo dei layer di AWS Lambda comporta sicuramente dei benefici in presenza di dipendenze di notevoli dimensioni che non è necessario aggiornare molto di frequente. Spostare queste dipendenze in un layer riduce notevolmente i tempi di deployment della propria Lambda function.

E per condividere codice sorgente? In questo caso è bene fare delle valutazioni che tengano conto della complessità che si introduce nei processi di debug e test dell’applicazione: è probabile che l’effort richiesto non sia giustificabile dai benefici ottenibili con l’introduzione dei layer.

Ci siamo divertiti? Alla prossima!

source code

Linting e test end-to-end Cypress in AWS Amplify

6 min

Di rientro dalla pausa estiva, ho ripreso l’approfondimento di AWS Amplify. In un precedente articolo avevo trattato la realizzazione di una semplice web application utilizzando questo framework di AWS. In questo post voglio invece trattare l’implementazione di linter per il codice sorgente e dei test end-to-end con Cypress, ovviamente automatizzati nella pipeline CI/CD di AWS Amplify.

Questo il link al repository GitHub e questo il link alla web application del progetto.

Linter

Il linter è uno strumento che analizza il codice sorgente per contrassegnare errori di programmazione, bug, errori stilistici e costrutti sospetti.

(wikipedia)

Utilizzare uno o più strumenti linter ci consente di aumentare la qualità del codice sorgente prodotto. Utilizzare tali strumenti in una pipeline di CI/CD ci permette inoltre di effettuare il deployment di un’applicazione solo quando il codice sorgente della stessa rispetta determinati livelli qualitativi.

La web application che ho realizzato prevede un frontend React e un backend lambda Python. Avremo quindi bisogno due differenti strumenti, specifici per le due tecnologie utilizzate.

ESLint

Uno dei migliore linter che ho avuto la possibilità di testare per JavaScript è ESLint: le sue funzionalità permettono non solo di testare ma anche di porre rimedio (fix automatico) ad un elevato numero di problemi.

Ho utilizzato quindi ESLint per verificare il codice del frontend. La sua installazione è semplice: dalla directory principale del nostro progetto, possiamo installare il linter con il seguente comando.

npm install eslint --save-dev

Terminata l’installazione, il wizard per la prima configurazione si avvia in questo modo.

npx eslint --init

Verranno richieste diverse informazioni: in generale è possibile scegliere se utilizzare ESLint per:

  • la verifica della sintassi
  • la verifica della sintassi e la ricerca di problemi
  • la verifica della sintassi, la ricerca di problemi e il rispetto stilistico nella scrittura del codice

Quest’ultima opzione è interessante e consente di “aderire” ad uno stile tra i più popolari, come quello scelto da Airbnb o da Google. Terminata la configurazione, verranno installati i packages richiesti. Nel mio caso:

"devDependencies": {
    "eslint": "^7.8.1",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-react": "^7.20.6"
  }

Ora che abbiamo configurato ESLint, procediamo alla verifica del codice:

npx eslint src/*.js

A seconda delle opzioni scelte durante la configurazione, il risultato potrebbe non essere dei più ottimisti, rilevando una lunga serie di problemi.

Come però dicevo inizialmente, ESLint ci consente di correggere automaticamente alcuni di questi, utilizzando il seguente comando:

npx eslint src/*.js --fix

Ottimo! Degli 87 problemi rilevati inizialmente, solo 6 richiedono il nostro intervento per essere corretti.

Possiamo inoltre decide di ignorare alcuni problemi specifici. Volendo, per esempio, evitare la segnalazione per l’assenza dei commenti JSDoc, si deve modificare la sezione rules del file .eslintrc.js di configurazione di ESLint.

  'rules': {
    "require-jsdoc": "off"
  },

Rimane solo un errore, relativo ad una linea troppo lunga e che andremo direttamente a correggere.

Abbiamo svolto manualmente l’operazione di linting del codice. L’obiettivo prefissato è inoltre automatizzare questo processo nella pipeline di CI/CD, in modo da interrompere il deployment nel caso alcuni errori vengano rilevati.

Configuriamo la pipeline modificando il file amplify.yml in questo modo:

frontend:
  phases:
    preBuild:
      commands:
        - npm ci
    build:
      commands:
        - npx eslint src/*.js 
        - npm run build
  artifacts:
    baseDirectory: build
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*

Tra i comandi della fase di build del frontend, inseriamo il comando per eseguire ESLint. Qualora lo stesso rilevi un problema, la pipeline si interromperà automaticamente.

Per quanto riguardo il frontend è tutto! Per eventuali approfondimenti nell’uso di ESLint, consiglio di consultare l’ottima documentazione.

Pylint

E’ arrivato il momento di verificare il codice di backend. Avendo realizzato alcune funzioni AWS Lambda in Python, ho scelto Pylint per questo scopo. L’installazione si effettua normalmente con PIP:

pip install pylint

Per analizzare un file con Pylint basta eseguirlo specificando il nome del file come argomento.

A differenza del tool visto in precedenza, Pylint attribuisce un punteggio al codice analizzato. Il punteggio si basa ovviamente sui problemi rilevati.

Alcuni problemi possono essere ignorati andando a modificare il file di configurazione. Per generare un file di configurazione da personalizzare successivamente con le proprio impostazioni, è necessario eseguire il comando:

pylint --generate-rcfile

Il file generato è ben documentato e comprensibile.

Al fine di integrare Pylint nella pipeline di AWS Amplify, le operazioni da compiere sono più complicate di quanto visto in precedenza per ESLint.

Per prima cosa è necessario prevedere l’installazione di Pylint nell’immagine Docker utilizzata per la fase di build del backend. Come spiegato nel precedente articolo che ho scritto a riguardo di AWS Amplify,  l’immagine Docker di default utilizzata per la build della soluzione non è correttamente configurata: esiste infatti un problema noto relativo alla configurazione di Python 3.8 riportato anche in questa issue (Amplify can’t find Python3.8 on build phase of CI/CD).

Per ovviare al problema, il workaround più semplice che ho individuato è stato creare un’immagine Docker con tutti i requisiti necessari. Di seguito il relativo Dockerfile.

Installato Pylint, andremo a configurare la pipeline modificando il file amplify.yml in questo modo:

backend:
  phases:
    build:
      commands:
        - '# Evaluate backend Python code quality'
        - find amplify/backend/function -name index.py -type f | xargs pylint --fail-under=5 
        - '# Execute Amplify CLI with the helper script'
        - amplifyPush --simple

Utilizziamo find per sottoporre a Pylint tutti i file index.py delle nostre lambda function di backend. Particolare attenzione deve essere posta al parametro –fail-under: questo istruisce Pylint per considerare fallita una valutazione del codice sorgente inferiore a 5. In questo caso l’exit code di Pylint sarà diverso da zero e quindi interromperà la pipeline di CI/CD. Con una valutazione superiore a 5, l’exit code sarà invece pari a zero. La soglia di default è 10, corrispondente ad un codice sorgente “perfetto” e onestamente molto difficile da ottenere in presenza di applicazioni complesse.

Abbiamo completato l’introduzione dei linter nella nostra pipeline. Vediamo ora come automatizzare il test della nostra applicazione.

End-to-end test con Cypress

Cypress è una soluzione che permette di effettuare test automatici della nostra web application, simulando le operazioni che un utente esegue tramite UI. I test sono raccolti in suite.

Il superamento dei test garantisce il corretto funzionamento dal punto di vista dell’utente.

La console Amplify offre un’integrazione con Cypress (un framework di test end-to-end) per test basati su browser. Per le app Web che utilizzano Cypress, la console Amplify rileva automaticamente i comandi di test sulla connessione repo, esegue i test e fornisce accesso a video, schermate e qualsiasi altro artefatto reso disponibile durante la fase di compilazione.

(AWS)

L’installazione è molto semplice:

npm install cypress

Con il seguente comando avviamo per la prima Cypress:

npx cypress open

Questo comporterà la creazione di una directory cypress nel nostro progetto che include tutti i file di configurazione e le test suite. Alcune di esempio sono presenti in cypress/integration. Non mi addentro nei dettagli di come realizzare una test suite perché esiste già un’immensa documentazione a riguardo.

Configurazione di Cypress per AWS Amplify e Cognito

Vediamo come configurare correttamente il nostro progetto per integrarsi con AWS Amplify.

Il primo file che andremo a modificare è cypress.json presente ora nella root del progetto.

{ 
    "baseUrl": "http://localhost:3000/", 
    "experimentalShadowDomSupport": true
}

Il primo parametro indica l’URL base della nostra web application. Il secondo parametro experimentalShadowDomSupport è invece molto importante nel caso di applicazioni React che utilizzando il backend di autenticazione Cognito di AWS Amplify e i relativi componenti UI. In estrema sintesi, non abilitando il supporto Shadow DOM, non saremo in grado di autenticarci nella nostra applicazione durante le fasi di testing.

La test suite utilizzata per verificare le funzionalità di login e logout della web application è il seguente:

Per motivi di sicurezza username e password da utilizzare per i test non sono specificate nello script ma andranno definite in variabili di ambiente direttamente sulla console di AWS Amplify. Le variabili sono CYPRESS_username e CYPRESS_password.

Configurazione della Pipeline

Non resta che configurare la pipeline per l’esecuzione dei test, così come indicato da AWS. Ed ecco il file amplify.yml al completo.

Anche in questo caso, come per i linter, la nostra pipeline di deployment si interromperà se i test non avranno esito positivo.

E’ possibile vedere un filmato relativo all’esecuzione dei vari test direttamente dalla console di AWS Amplify, effettuando il download degli artefatti. Di seguito un esempio.

Conclusioni

L’utilizzo combinato di linter e tool di test end-to-end ci consente di rilasciare codice di qualità e verificato dal punto di vista dell’utilizzatore finale. Con AWS Amplify abbiamo la possibilità di integrare questi tool semplificando le operazioni di DevOps e mantenendo un controllo puntuale delle fasi di CI/CD.

In conclusione confermo le impressioni del primo post: la prossima volta che dovrò realizzare una soluzione, che sia un semplice PoC o una web application più complessa, prenderò sicuramente in considerazione l’utilizzo di AWS Amplify! Ovviamente affiancato da Cypress e da uno o più linter.

Ci siamo divertiti? Alla prossima!

AWS Amplify recipe

AWS Amplify 101

9 min

Durante lo scorso AWS Summit ho assistito ad un’interessante sessione riguardante AWS Amplify. La bravissima speaker Marcia Villalba (AWS Developer Advocate) mi ha rapidamente convinto a provare questo framework. Pochi giorni dopo mi si è presentata l’occasione giusta: avendo la necessità di tenere mio figlio allenato con le tabelline durante queste vacanze estive, ho deciso di sviluppare una semplice web application con AWS Amplify in modo da scoprirne pregi e difetti.

Questo il link al repository GitHub e questo il link alla web application del progetto che ne è venuto fuori. Si, puoi esercitarti anche tu con le tabelline.

Cos’è Amplify?

AWS Amplify è un insieme di strumenti e servizi che permettono ad uno sviluppatore di realizzare applicazioni moderne full stack sfruttando i servizi cloud di AWS. Dovendo, per esempio, realizzare una web application React, Amplify ci consente di gestire sviluppo e deployment del frontend, dei servizi di backend e la relativa pipeline di CI/CD. Per la medesima applicazione è possibile avere più ambienti (ad esempio dev, test, stage & produzione). Amplify consente inoltre di integrare molto rapidamente alcuni servizi AWS nel proprio frontend, scrivendo pochissime righe di codice: un esempio su tutti, l’autenticazione con AWS Cognito.

Ottimo! Sembra ci sia proprio tutto per consentire ad uno sviluppatore one-man-band di realizzare rapidamente la propria applicazione! Proviamoci.

Impara le tabelline

Come prima step meglio chiarirsi le idee su cosa vogliamo realizzare: che siano quattro schizzi su un foglio di carta (il mio caso) o un buon mockup in Balsamiq, proviamo ad immaginare UI & UX della nostra applicazione.

Lo scopo principale è allenare la conoscenza delle tabelline sottoponendo l’utente ad un test: 10 moltiplicazioni relative alla medesima tabellina, scelta casualmente da 2 ad 10 (non vi devo spiegare perché la tabellina del 1 l’ho esclusa vero?).

Sarebbe interessante tenere traccia di errori e tempo speso per rispondere alle domande, in modo da avere una scoreboard con il miglior risultato per ciascuna tabellina.

Vogliamo mostrare ad ogni utente la propria scoreboard e spingerlo a migliorarsi? Dovremo quindi memorizzare la stessa e ci serve un processo di autenticazione!

Avendo la scoreboard di ciascun utente, possiamo inoltre scegliere la tabellina oggetto della prossima sfida in base ai precedenti risultati, in modo da allenare l’utente sulle tabelline per le quali ha riscontrato maggiori difficoltà.

Ed ecco apparire, come per magia, la UI della nostra applicazione.

Primi passi

Ora che abbiamo le idee più chiare su cosa realizzare, muoviamo i primi passi. Abbiamo già deciso di utilizzare React e Amplify. Vediamo i prerequisiti di quest’ultimo e creiamo il nostro progetto, come spiegato nel Tutorial ufficiale di Amplify Docs.

I prerequisiti indicati da AWS per Amplify sono questi:

E’ necessario, ovviamente, un account AWS e scopriremo poi, durante il deployment del backend, che ci servirà anche Python >= 3.8 e Pipenv.

Per installare Amplify CLI e configurarlo procediamo come indicato. La configurazione di Amplify CLI richiede la creazione di uno IAM User che sarà poi utilizzato per la realizzazione dei servizi di backend.

npm install -g @aws-amplify/cli
amplify configure

Inizializziamo ora il nostro progetto React e il relativo backend Amplify.

# Create React project
npx create-react-app amplify-101
cd amplify-101

# Run React
npm start

# Init amplify
amplify init

# Install Amplify libraries for React
npm install aws-amplify @aws-amplify/ui-react

Dovremo a questo punto editare il file src/index.js inserendo le seguenti righe:

import Amplify from "aws-amplify";
import awsExports from "./aws-exports";
Amplify.configure(awsExports);

Ottimo. Al termine di queste attività (spiegate in dettaglio nel Tutorial ufficiale di Amplify Docs) avremo un nuovo progetto React funzionante e saremo pronti a realizzare il relativo backend con Amplify.

Autenticazione con Cognito

Di cosa abbiamo bisogno? Beh, dato che l’utente è al centro della nostra applicazione, possiamo iniziare con la realizzazione del processo di autenticazione. Ed è proprio a questo punto che mi è scappato il primo “WOW” rendendomi conto di quanto sia semplice con Amplify.

Aggiungiamo “auth” al backend con il comando indicato e rispondiamo a un paio di semplici domande.

amplify add auth

? Do you want to use the default authentication and security configuration? Default configuration
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings?  No, I am done.

Dobbiamo poi chiedere alla CLI di Amplify di pubblicare sul Cloud il nostro backend. Il comando che andremo (spesso) ad utilizzare è:

amplify push

Amplify si occupa di tutto, andando a creare quanto necessario lato backend tramite stack CloudFormation.

Inseriamo ora la UI sul nostro frontend React: il metodo più veloce consiste nel modificare due righe di codice nel file src/App.js:

# New import for auth
import { withAuthenticator } from '@aws-amplify/ui-react'

# Change default export
export default withAuthenticator(App)

Fatto! La nostra app prevede ora un flusso completo di registrazione degli utenti, login e logout. Ovviamente gli elementi della UI sono personalizzabili e possiamo modificare il comportamento della nostra applicazione in risposta all’evento di login. Vediamo come.

<AmplifySignUp> consente di personalizzare il processo di registrazione di un nuovo utente. Ho scelto di richiedere solo l’indirizzo email (che viene verificato tramite l’invio di un codice OTP) e una password. L’indirizzo email sarà utilizzato come username per le successive login.

<AmplifySignIn> consente di personalizzare il processo di login: anche in questo caso ho specificato di considerare l’indirizzo email come username per l’accesso.

<AmplifyAuthenticator> ci consente di modificare lo stato della nostra app in risposta a login e logout, tramite handleAuthStateChange. Avremo quindi la possibilità di capire se un utente si è autenticato verificando lo stato {this.state.authState}. Potremo visualizzare il relativo username con {this.state.user.username}.

Fantastico! Ora vediamo come iniziare ad aggiungere un pò di funzionalità alla nostra app.

Storage

Quale app non ha dati da memorizzare? Con Amplify abbiamo due possibilità: contenuti statici su S3 o database NoSQL con DynamoDB. Nel caso della nostra applicazione, abbiamo la necessità di creare una tabella scoreboard per memorizzare i migliori risultati di ciascun utente. Con Amplify CLI la creazione della tabella è molto rapida:

amplify add storage

Selezionando NoSQL Database e rispondendo a qualche semplice domanda sulla struttura della tabella che si intende creare, si ottiene il deployment su AWS.

Un chiarimento: Amplify supporta la creazione di API GraphQL (AppSync) che andremo ad utilizzare negli step successivi. Specificando la direttiva @model nello schema GraphQL, è possibile delegare ad Amplify il deployment di una tabella e tutto ciò che serve per gestire le relative operazioni CRUD dal frontend. E’ una grande comodità se attorno al dato che si deve gestire non esistono complesse logiche applicative.

Nel caso della nostra scoreboard, abbiamo la necessità di gestire l’inserimento dei dati esclusivamente lato backend. Dobbiamo inoltre valutare di volta in volta i risultati di un test e aggiornare la scoreboard di conseguenza. L’accesso da parte del frontend è esclusivamente in sola lettura. Per questi motivi ho preferito non utilizzare la direttiva @model e gestire storage ed API separatamente (ma pur sempre con Amplify).

Ci servirà una seconda tabella che ho chiamato challenges: come si evince dal nome, servirà a memorizzare i risultati di una sfida “in corso” in modo da poter poi confrontarli con le risposte del nostro utente e determinare l’esito della prova. Per gli stessi motivi ho preferito gestire, anche per questa tabella, deployment ed API separatamente.

Backend functions

Iniziamo a scrivere il codice per il backend: ho scelto di realizzare in Python le Lambda function necessarie. Una delle caratteristiche di Amplify che apprezzo è il fatto di concentrare l’intero codice della nostra applicazione in un unico repository: codice frontend, backend e infrastrutturale (IaaC) possono essere agevolmente e rapidamente modificati per adeguarsi a nuovi requisiti e nuove funzionalità.

Occupiamoci quindi di realizzare la funzione principale della nostra app: la generazione di un nuovo test e la verifica dei suoi risultati. Useremo il comando:

amplify add function

Ci viene chiesto come vogliamo chiamare la funzione, quale runtime vogliamo utilizzare (Python, Node, ecc..) e se la funzione deve aver accesso a qualche risorsa precedentemente definita. Nel nostro caso la funzione newChallenge avrà accesso ad entrambe le tabelle create in precedenza.

Ho usato lo stesso comando per creare la funzione scoreboard che permette al frontend di visualizzare il contenuto della scoreboard dell’utente.

Il codice sorgente delle funzioni di backend si trova nel percorso amplify/backend/function del nostro progetto. Ogni qualvolta andremo a modificarlo, ci basterà effettuare un push della soluzione per aggiornare il backend su cloud.

Non entro qui nel merito del codice delle Lambda function realizzate, che è disponibile in questo repository GitHub. Ci basti sapere che entrambe le funzioni rispondono alle richieste GraphQL con un JSON popolato con i dati richiesti, oltre ovviamente ad implementare la logica applicativa (generazione del test, memorizzazione dei risultati e valutazione degli stessi).

Il riferimento alle tabelle DynamoDB create in precedenza viene fornito tramite variabile d’ambiente: il nome della tabella scoreboard, per esempio, è fornito in STORAGE_SCOREBOARD_NAME.

dynamodb = boto3.resource('dynamodb')
scoreboard_table = dynamodb.Table(os.environ['STORAGE_SCOREBOARD_NAME'])

L’event con il quale viene invocata la funzione fornisce invece le informazioni relative alla richiesta GraphQL a cui rispondere: il parametro typeName indica per esempio se si tratta di una Query o di una Mutation. Vediamo nel prossimo paragrafo l’ultimo step necessario a completare la nostra app, l’implementazione delle API GraphQL.

API GraphQL

Definiamo l’ultima risorsa backend necessaria: le API con le quali la nostra web application React andrà ad interagire.

amplify add api

E’ possibile specificare API Rest esistenti o scegliere di creare un endpoint GraphQL, che è la nostra preferenza. Amplify si occupa di gestirne l’implementazione e l’integrazione con il frontend.

Noi ci dobbiamo solo preoccupare di definire lo schema dei dati della nostra applicazione. Vediamolo.

Definiamo due tipi base: challenge che rappresenta un test e score che rappresenta i risultati.

La definizione delle Query è più interessante: andiamo a definire due chiamate alle funzioni Lambda precedentemente implementate. La prima ottiene un array di score per visualizzare la scoreboard ed è chiamata getScores. La seconda ottiene una nuova challenge (getChallenge). Da notare che il nome delle funzioni riporta l’ambiente ${env} di riferimento.

Il type Mutation ci consente invece di inviare i risultati di un test alle API per la relativa valutazione. La funzione Lambda richiamata è sempre newChallenge alla quale vengono passati alcuni parametri, cioè l’identificativo univoco del test e i risultati indicati dall’utente, ottenendo l’esito.

Come utilizzare queste API in React? E’ molto semplice: basta specificare gli import necessari (il cui codice è generato automaticamente da Amplify) e richiamarle nel proprio frontend.

Questo è un estratto del codice utilizzato per ottenere la propria scoreboard.

import { getScores }  from './graphql/queries';
import { API, graphqlOperation } from "aws-amplify";

......

  componentDidMount() {
    this.fetchData();  
  }

  async fetchData() {
    const data = await API.graphql(graphqlOperation(getScores));
    const scores = data.data.getScores
    console.log(scores);
    this.setState({'scores':scores});
  }

Da notare: l’utente non viene specificato nella chiamata a getScores. Grazie infatti all’integrazione con AWS Cognito, l’identità dell’utente è infatti specificata direttamente nell’evento di invocazione della Lambda function, nel parametro identity.

Nel caso di mutation, il codice utilizzato sulla submit di una challenge è il seguente:

import { API, graphqlOperation } from 'aws-amplify'
import { sendChallengeResults } from './graphql/mutations';

....

  handleClick() {
    this.setState({'loadingResults': true})

    // mutation
    const challenge_result = { id: this.props.challenge.id, results: this.state.results }

    API.graphql(graphqlOperation(sendChallengeResults, challenge_result))
      .then(data => {
        console.log({ data });
        this.setState({'score': data.data.sendChallengeResults});
      })
      .catch(err => console.log('error: ', err));
  }

Deployment

Abbiamo terminato! Tutte le componenti della nostra app sono state realizzate.

amplify status

La CLI di Amplify ci consente di effettuare il deployment della nostra web application con due semplici comandi:

amplify add hosting
amplify publish

Non ho però scelto questa strada, volendo testare le potenzialità di CI/CD che Amplify mette a disposizione. Per farlo è necessario usare la Console.

Per prima posizioniamo la soluzione su un repository GIT. Successivamente, dalla console di Amplify, occupiamoci di connettere il branch del nostro repository per implementare la pipeline.

Amplify Console

Ecco fatto! La nostra pipeline è operativa e la nostra applicazione è ora disponibile online. Al primo tentativo? Non proprio!

Purtroppo per qualche motivo a me sconosciuto, l’immagine Docker di default utilizzata per la build della soluzione non è correttamente configurata: esiste infatti un problema noto relativo alla configurazione di Python 3.8 riportato anche in questa issue (Amplify can’t find Python3.8 on build phase of CI/CD).

Per ovviare al problema, il workaround più semplice che ho individuato è stato creare un’immagine Docker con tutti i requisiti necessari. Di seguito il relativo Dockerfile.

Ho reso poi disponibile l’immagine su DockerHub e ho configurato la pipeline di CI per utilizzare tale immagine.

Build settings

Conclusioni

Siamo giunti alla fine di questo post: la realizzazione di questa semplice web application mi ha permesso di muovere i primi passi con Amplify. A quali conclusioni sono arrivato?

Giudizio sicuramente molto positivo! Amplify consente di realizzare molto rapidamente applicazioni complesse e moderne, integrando facilmente i servizi (serverless) di AWS Cloud. L’autenticazione con AWS Cognito ne è un chiaro esempio, ma esiste la possibilità di integrare tante altre funzionalità, come Analytics, Elasticsearch o AI/ML.

GraphQL ci consente di gestire semplicemente i dati della nostra applicazione, in particolare per le classiche operazioni CRUD.

La centralizzazione del codice sorgente, di frontend, backend e infrastruttura (IaaC), consente di tenere sotto controllo l’intera soluzione e garantisce di poter intervenire rapidamente per adeguarsi a nuovi requisiti e nuove funzionalità.

E’ un prodotto dedicato ad uno sviluppatore full stack che opera singolarmente? Direi, non esclusivamente! Sebbene Amplify permetta ad un singolo sviluppatore di seguire ogni aspetto della propria applicazione, semplificando anche le operazioni di DevOps, credo che anche un team di lavoro possa facilmente collaborare alla realizzazione della soluzione traendo vantaggio dall’utilizzo di Amplify.

E’ adatto allo sviluppo di qualsiasi soluzione? Direi di no! Credo che il valore aggiunti di Amplify sia, come già detto, la possibilità di gestire in modo rapido e centralizzato tutti gli aspetti della propria applicazione. Se la complessità del backend è tale da prevedere elementi non direttamente gestibili da Amplify, forse è preferibile utilizzare altri strumenti o un approccio misto.

Per i motivi qui detti, credo che l’utilizzatore ideale di Amplify siano lo sviluppatore full stack, le giovani startup o le aziende di sviluppo più “agili” che hanno la necessità di mettere in campo rapidamente nuove soluzioni o nuove funzionalità.

In conclusione, la prossima volta che dovrò realizzare una soluzione, che sia un semplice PoC o una web application più complessa, prenderò sicuramente in considerazione l’utilizzo di AWS Amplify!

Ci siamo divertiti? Alla prossima!

Add to Slack – Monetizzare le proprie API

7 min

La monetizzazione delle API è un trend in forte crescita. E’ ormai un business consolidato delle aziende digitali far utilizzare, a pagamento, le API che accedono ai dati aziendali o ad alcuni servizi già erogati secondo canali differenti.

Un possibile canale di diffusione delle proprie API e dei propri servizi è l’integrazione con piattaforme esistenti e molto note, come ad esempio Slack. Se il proprio servizio o le proprie API possono essere utili e di valore nell’ambito di un meeting, di una chat di progetto o durante le attività di customer care, allora realizzare un’integrazione per Slack potrebbe rivelarsi redditizia.

Ma cosa c’è dietro l’integrazione di API esistenti con Slack? Quanto è complesso pubblicare il classico pulsante “Add to Slack” sul proprio sito web? Vediamolo in questa guida passo passo.

Creare un App

In Slack è possibile creare le proprie app o utilizzarne di esistenti. Le più complete e professionali sono elencate nel App Directory. Si trova ogni cosa, dall’integrazione con Google Calendar, a Zoom e GitHub. Normalmente le app consentono di aggiungere funzionalità ai propri canali, fornendo l’integrazione con servizi già esistenti. Un’app può dialogare con i partecipanti ad un canale inviando messaggi di suggerimento e notifiche oppure rispondendo a comandi ben specifici. L’app può “esprimersi” sotto forma di bot oppure agire come se fossimo direttamente noi a inviare messaggi. Insomma: le potenzialità sono illimitate!

Creare la propria app è semplice: basta raggiungere il portale api e seguire le indicazioni di “Create New App”.

Prima di addentrarci negli step successivi di creazione di un app, è meglio chiarire come questa può essere distribuita su Slack.

Installazione nel proprio Workspace

Di default, quando creiamo un App su Slack, la stessa può essere installata sul proprio workspace. Chiunque sia invitato a partecipare al medesimo, può quindi interagire e usufruire della stessa. Se intendiamo realizzare un applicazione dedicata esclusivamente al nostro team, non è richiesto altro effort.

Distribuzione

Se l’app che intendiamo realizzare deve poter essere installata e condivisa su altri workspace, per esempio a livello aziendale, è necessario attivare la “distribuzione” dell’applicazione. Questo consente la creazione del classico pulsante “Add to Slack” e comporta l’implementazione di un flusso OAuth 2.0 per gestire il processo di autorizzazione.

App Directory

L’App Directory è una sorta di marketplace di Slack dove vengono elencate per categoria tutte le applicazioni che hanno superato un processo di verifica. Lo stesso è condotto direttamente da un Team di Slack che si assicura che l’applicazione abbia i necessari requisiti tecnici e qualitativi. E’ ovvio che se intendiamo monetizzare le nostre API tramite un’integrazione, la pubblicazione su App Directory ci consentirà di avere un pubblico decisamente più ampio e ne gioverà anche la reputazione dell’applicazione, in quanto già verificata da Slack stessa.

Pubblicare la propria app nella Directory ufficiale è però ovviamente un processo più complesso, che prevede un lungo elenco di requisiti da soddisfare.

Interazione

Abbiamo creato la nostra app, che però non svolge ancora alcuna funzione. A seconda del servizio che vogliamo integrare in Slack, per prima cosa è meglio decidere come la nostra applicazione andrà ad interagire sui canali Slack. Esistono diverse possibilità: l’app potrà essere invocata con uno “slash command” oppure nel caso di specifici eventi.

Gli “slash command” sono i tipici comandi di Slack preceduti appunto da una slash come, ad esempio /INVITE. Nel caso invece degli eventi, è possibile richiamare l’app quando la stessa viene per esempio citata (@nomeapp) durante una conversazione oppure quando le vengono inviati dei messaggi diretti.

Personalmente ho scelto questo secondo approccio, basato su eventi, ma non esiste una sola soluzione ottimale: dipende dal servizio che stiamo integrando e dalle UX che vogliamo fornire. Nessuno ci vieta, inoltre, di prevedere entrambe le modalità.

Integrazione

Come integrare il nostro servizio e le relative API esistenti? Sarà necessario realizzare un layer dedicato, in grado di rispondere alle richieste da parte di Slack e interagire con gli utenti, da un lato, e in grado di richiamare le nostre API dall’altro lato.

Di cosa si deve occupare questo layer di integrazione? Vediamolo:

  • Ricevere gli eventi da parte di Slack e rispondere di conseguenza, erogando il servizio fornito dalle nostre API
  • Gestire il processo di autorizzazione OAuth 2.0
  • Gestire subscription e billing o integrarsi con una piattaforma di E-Commerce

Sottoscrizione agli eventi

Abbiamo deciso di ricevere delle notifiche quando si verifica un evento di interesse per la nostra app. Dobbiamo innanzitutto specificare l’endpoint a cui verranno notificati questi eventi.

Dovremo rispondere alla richiesta utilizzando il parametro challenge come indicato per validare il nostro endpoint. Dopo averlo Impostato potremo quindi specificare quali notifiche vogliamo ricevere.

Gli eventi sono ben descritti: message.im si verifica, per esempio, quando viene inviato un messaggio direct alla nostra app, tipicamente nel relativo channel privato.

Il concetto di Scope è fondamentale da comprendere: per poter ricevere alcune notifiche è necessario che la nostra applicazione abbia ottenuto l’autorizzazione ad un particolare scope; nell’esempio precedente, im:history. Vedremo che tale autorizzazione verrà richiesta durante il processo di autorizzazione dell’app.

Tutto qui? Non direi. E’ opportuno fare in modo che il nostro layer di integrazione verifichi inoltre le richieste per assicurarsi che provengano proprio dai server di Slack: non vogliamo certo pubblicare un servizio a pagamento e poi essere frodati giusto?

L’algoritmo di verifica delle richieste è indicato in questo articolo tratto dalla documentazione ufficiale. Richiede l’utilizzo del Signing Secret, un parametro che Slack attribuisce ad ogni applicazione e che è possibile trovare nella sezione App Credentials della propria applicazione.

Personalmente mi sono occupato di realizzare un layer di integrazione totalmente serverless, utilizzando API Gateway e AWS Lambda. Ecco riportata un estratto della funzione di risposta agli eventi, che prevede inoltre l’eventuale decodifica Base64.

Le notifiche sono in formato JSON e ben documentate. Un esempio di payload relativo a message:im è qui.

Cos’è l’evento app_home_opened? Si verifica quando l’utente che ha installato la nostra app ne apre la sezione corrispondente. E’ utilizzabile per inviare un messaggio di benvenuto e spiegare, per esempio, come utilizzare l’app. E’ bene ricordarsi di questo evento perché la sua corretta gestione è uno dei requirement alla pubblicazione nel App Directory.

Gli eventi disponibili sono molti e permettono di personalizzare la UX della propria integrazione.

Autorizzazione

Il processo di autorizzazione OAuth 2.0 è documentato molto chiaramente.

Il processo di autorizzazione è composto dai seguenti step:

  1. L’utente avvia il processo di installazione cliccando su “Add to Slack”. Il codice HTML del pulsante può essere facilmente ottenuto dalla pagina della propria app. L’URL richiamata dal pulsante sarà gestita dal nostro integration layer che si occuperà di rispondere con un redirect (302) verso l’URL di autorizzazione di Slack (https://slack.com/oauth/v2/authorize) specificando almeno i parametri client_id e scope che sono obbligatori. Il primo parametro identifica la nostra app e il suo valore è disponibile nella sezione App Credentials. Il secondo specifica invece le varie autorizzazioni che intendiamo richiedere per la nostra app (scope).
  2. L’utente autorizza l’installazione dell’applicazione
  3. Un primo code di autorizzazione viene fornito al nostro integration layer alla redirect URL specificata nella sezione OAuth & Permissions della nostra applicazione
  4. L’integration layer si occupa di richiedere il token di autorizzazione richiamando l’URL dedicato (https://slack.com/api/oauth.v2.access) e specificando i parametri client_id e client_secret, oltre al code appena ottenuto
  5. In caso di risposta positiva, il token viene memorizzato dall’integration layer in modo da poter essere utilizzato per interagire con il workspace relativo

Ecco un esempio di una Lambda function in grado di gestire il processo, memorizzando i token ottenuti in una tabella DynamoDB.

UX & Pagamenti

Abbiamo terminato? No, siamo solo all’inizio! Abbiamo realizzato due funzioni, la prima delle quali è in grado di ricevere le notifiche relative agli eventi che ci interessa gestire con la nostra app. La seconda si occupa invece di gestire correttamente il processo di autorizzazione.

Dobbiamo ora rimboccarci le maniche e implementare la vera integrazione tra Slack e il nostro servizio o API. Il package Python slackclient ci permette di interagire con le API Web Client in modo da poter, per esempio, postare un messaggio in risposta ad uno specifico direct. Come già detto all’inizio di questo post, le possibilità sono infinite e dipendono molto dal servizio che si vuole integrare.

I messaggi Slack possono essere arricchiti di elementi per renderli più funzionali e accattivanti. La struttura di ciascun messaggio è composto da blocchi. L’ottimo editor consente di sperimentare il layout dei messaggi prima di implementarli nella propria app.

Dovremo inoltre gestire l’integrazione con la piattaforma di E-Commerce al fine di gestire le sottoscrizioni al nostro servizio. Personalmente ho utilizzato Paddle.

Pubblicazione su App Directory

La nostra App è pronta? Abbiamo testato le sue funzionalità, il processo di installazione e l’integrazione con la piattaforma di E-Commerce? Benissimo! Siamo pronti alla pubblicazione in App Directory? Non ancora.

Il processo di pubblicazione prevede la verifica, da parte del team di Slack, di una serie di requisiti che la nostra App deve soddisfare. Ce ne sono molti, divisi in sezioni e che consiglio di leggere attentamente.

Il processo di verifica è molto scrupoloso e ci possono volere alcuni giorni per ottenere un feedback. Se anche solo uno dei requisiti non è soddisfatto, viene segnalato e si deve procedere ad una nuova submission, ovviamente dopo aver corretto la propria app.

Prepariamoci quindi a dover realizzare, ad esempio, landing page per il supporto al cliente e per la propria privacy policy. Un attenzione particolare va riposta agli scope richiesti, che andranno motivati dettagliatamente.

Se abbiamo realizzato tutto nella maniera corretta, la nostra app sarà finalmente presente nella Directory di Slack e inizieremo a monetizzare il nostro servizio.. o almeno speriamo!

Ci siamo divertiti? Alla prossima!

pipeline

AWS Lambda CI pipeline con DGoss e TravisCI

4 min

Ho recentemente parlato di come sviluppare funzioni AWS Lambda offline, utilizzando le immagini docker di LambdaCI. Partendo dall’ambiente di sviluppo Python utilizzato per lo sviluppo di una funzione Lambda di esempio, vediamo come realizzare una pipeline di Continuous Integration che si occupi di verificarne il corretto funzionamento ad ogni nuovo commit. Utilizzeremo Goss come strumento di test e TravisCI per l’implementazione della pipeline di CI.

AWS Lambda in un container Docker

Vediamo rapidamente come eseguire una funzione AWS Lambda in un container Docker.

Utilizziamo la seguente funzione Python di esempio, che si occupa di processare dei messaggi accodati su SQS e che richiede un package (PILLOW) normalmente non presente nell’ambiente Lambda.

Per creare l’immagine docker necessaria all’esecuzione della nostra Lambda function, il relativo Dockerfile è il seguente, che partendo dall’immagine lambci/lambda:python3.6, consente l’installazione di eventuali pacchetti Python aggiuntivi previsti in requirements.txt

Consiglio di vedere questo post per maggiori dettagli; ora che abbiamo la nostra immagine Docker e la nostra Lambda function viene eseguita correttamente in un container, possiamo iniziare a realizzare la test suite.

Goss e DGoss

Ho scelto di utilizzare Goss come strumento di test principalmente per due motivi: grazie al wrapper DGoss è possibile interagire direttamente con un container docker sia per la fase di esecuzione dei test che durante lo sviluppo (o editing) degli stessi. Lo scopo principale di Goss è validare la configurazione di un server, che nel nostro caso è proprio il container che ospita la Lambda function. La suite di test può essere generata direttamente dallo stato corrente del server (o container) rendendo di fatto l’operazione molto veloce e pratica.

La definizione della test suite è basata su file YAML.

Procediamo molto rapidamente con l’installazione di Goss e DGoss e avviamo il container che ci permetterà di realizzare la test suite, utilizzando la sintassi Makefile.

Il comando avvia il container docker in modalità “STAY OPEN”, cioè avviando un API server in ascolto sulla porta 9001 e alla quale risponde la nostra funzione Lambda. Il parametro edit indica a DGoss che andremo ad utilizzare il container per la realizzazione della test suite. La directory test contatterà i file necessari all’esecuzione dei test stessi. Ad esempio, volendo verificare che la funzione processi correttamente i messaggi SQS, alcuni eventi di esempio (in formato JSON) saranno salvati in questa folder per essere poi utilizzati durante i test.

A questo punto possiamo utilizzare tutti i comandi di Goss per realizzare la nostra test suite: se vogliamo, per esempio, verificare la presenza del handler di funzione, si utilizza il comando:

goss add file /var/task/src/lambda_function.py

L’opzione ADD aggiunge un nuovo test al file di configurazione goss.yaml, nel nostro caso per verificare la presenza del file specificato. Goss consente di verificare moltissimi parametri di configurazione dell’ambiente in cui viene eseguito, come la presenza di file, pacchetti, processi, risoluzione dns, ecc. Il relativo manuale è molto esaustivo.

Realizziamo ora uno script che ci consenta di verificare la risposta della nostra funzione Lambda.

#!/bin/sh
# /var/task/test/test-script.sh

curl --data-binary "@/var/task/test/test-event.json" http://localhost:9001/2015-03-31/functions/myfunction/invocations

Utilizziamo curl per interrogare l’endpoint della nostra Lambda function inviando un evento SQS di test (il file test-event.json). Aggiungiamo quindi un ulteriore test Goss, questa volta di tipo command. Il file goss.yaml diventerà come il seguente.

La sezione command prevede l’esecuzione dello script che interroga la Lambda function. Ci aspettiamo su stdout un JSON dal contenuto specifico, che conferma la corretta esecuzione dalla funzione. Ovviamene questo test è solo un esempio esplicativo di ciò che può essere fatto con Goss.

Quanto abbiamo terminato le definizione della test suite, uscendo dal container (exit), DGoss si occuperà di copiare sul nostro host locale il file goss.yaml appena editato, in modo che persista dopo l’eliminazione del container stesso. Avviando nuovamente la procedura di edit, il file sarà copiato nel nuovo container e nuovamente sincronizzato al termine.

E’ arrivato il momento di eseguire il test! Al posto di edit, andremo semplicemente a specificare il comando run di DGoss.

DGoss si occupare di creare un nuovo container ed eseguire i test specificati. Di seguito un esempio.

Ottimo! La nostra test suite è pronta, procediamo ad integrarla nella nostra pipeline CI.

AWS Lambda CI in TravisCI

E’ arrivato il momento di configurare la pipeline CI. Ho scelto TravisCI per la sua semplicità e perché consente di effettuare la build di immagini docker gratuitamente. L’ho utilizzato anche in questo post con Ansible ed è tra gli strumenti di CI che preferisco. Per automatizzare il processi di test ad ogni commit andremo a configurare TravisCI per monitorare il repository GitHub/Bitbucket che ospita i sorgenti.

Un semplice file .travis.yaml realizza la pipeline.

Analizziamolo: la direttiva services indica a TravisCI di utilizzare il servizio docker. Prime di avviare i test, viene installato Goss/DGoss e viene effettuata la build della nostra immagine AWS Lambda, come specificato in before_install. Per ultimo, con script viene indicato il comando per l’effettiva esecuzione della test suite che abbiamo preparato in precedenza.

Ecco il risultato:

Fantastico! La nostra pipeline è realizzata!

Conclusioni

Ci siamo occupati di sviluppare e automatizzare il test di una semplice funzione AWS Lambda senza che questa sia mai realmente entrata nel cloud AWS: ho utilizzato questa modalità per alcuni progetti su cui ho lavorato e credo che permetta di velocizzare notevolmente lo sviluppo ed il mantenimento dei sorgenti, consentendo di effettuare il deployment su AWS di codice già testato e di qualità.

Questo repository GitHub raccoglie quanto trattato in questo articolo e può essere utilizzato come base di partenza per realizzare la propria pipeline CI con Goss.

Ci siamo divertiti? Alla prossima!

Keys

Certificati SSL gratuiti con Certbot e AWS Lambda

5 min

Grazie al progetto Certbot e alla Electronic Frontier Foundation è possibile dotare il proprio sito web di un certificato SSL totalmente gratuito. Certbot è un tool a linea di comando che consente di richiedere un certificato SSL valido per il proprio dominio, a seguito di un processo di verifica della proprietà dello stesso. Il tool, grazie all’ausilio di diversi plug-in, si può inoltre occupare dell’installazione e dell’aggiornamento dello stesso sul proprio server web. In questo post vediamo come richiedere e rinnovare automaticamente i propri certificati SSL gratuiti con Certbot e AWS Lambda.

Perché gestire i certificati con AWS Lambda?

Diverse applicazioni web di cui mi occupo utilizzano CloudFront per la distribuzione dei contenuti, associato ad un bucket S3 di origine. Ho così deciso di realizzare una semplice Lambda function che si occupi di ottenere i certificati SSL con Certbot e di verificare periodicamente la data di scadenza degli stessi. Se necessario, questa provvede automaticamente al rinnovo ed all’importazione su AWS Certificate Manager del nuovo certificato.

Risultato? Mai più certificati SSL scaduti! L’automazione del processo è particolarmente importante tenendo conto della breve vita (90 giorni) dei certificati emessi da Let’s Encrypt CA.

Overview della soluzione

Al centro della soluzione c’è ovviamente la funzione Lambda, invocata periodicamente da un evento CloudWatch. La funzione gestisce un elenco di domini (specificato nella variabile d’ambiente DOMAINS_LIST – valori separati da virgola) e per ciascuno di essi:

  • viene verificata la presenza del certificato in ACM
  • se non presente, lo stesso viene richiesto tramite Certbot. Per completare correttamente il processo di verifica della proprietà del dominio, la funzione si occupa di copiare il token di validazione nel relativo bucket S3 che ospita le relative risorse statiche
  • per i certificati esistenti, viene verificata la data di scadenza e, qualora necessario, si procede al rinnovo

Al termine del processo, la configurazione, i certificati e le private key vengono memorizzate in un bucket S3 privato in modo che siano utilizzabili alla prossima esecuzione.

Utilizzo di Certbot in AWS Lambda

Certbot è scritto in Python ed è facilmente utilizzabile per automatizzare quindi i processi di richiesta, rinnovo e revoca dei certificati. Utilizzarlo però in ambiente AWS Lambda richiede uno step di preparazione aggiuntivo, in modo che tutti i pacchetti e le dipendenze necessarie siano correttamente installate. Questa fantastica guida spiega in maniera dettagliata come utilizzare un’istanza EC2 a tal scopo. Ho preparato il pacchetto contenente la versione 1.3.0 di Certbot che è disponibile nel repository relativo a questo post.

La configurazione di Certbot è collocata nella directory /tmp dell’istanza lambda e rimossa per ragioni di sicurezza al termine dell’esecuzione, in quanto include anche le private key dei certificati.

Per procedere alle operazioni di rinnovo è però necessario preservare la configurazione di Certbot. L’albero di directory contenente la configurazione è compresso in un file zip e copiato nel bucket S3 indicato dalla variabile d’ambiente CERTBOT_BUCKET. Al successivo avvio la configurazione viene ripristinata dal file zip nella directory /tmp dell’istanza.

Il lato oscuro dei Symlinks

Certbot verifica che il proprio albero di configurazione sia valido. Viene verificata, tra le altre cose, la presenza di link simbolici nella directory live relativa a ciascun dominio.

Il processo di backup e restore dell’albero di configurazione in formato zip rimuove tali link (sostituiti dai file effettivi). Per ripristinare la situazione iniziale, viene utilizzato questo metodo.

Superare la challenge di verifica con AWS S3

Per richiede un certificato è necessario superare una challenge, che dimostri la proprietà del relativo dominio. Poiché la Lambda function di questo progetto possa correttamente gestire tale challenge, è richiesto che:

  • esista un bucket S3 dello stesso nome del dominio
  • il bucket S3 sia già configurato come sorgente di una distribuzione CloudFront per il dominio oppure, in alternativa, che la funzionalità di static website hosting (HTTP) del bucket S3 sia – temporaneamente – attiva
  • la configurazione DNS per il dominio sia corretta
  • il ruolo IAM assegnato alla lambda function consenta le operazioni di PutObject e DeleteObject sul bucket S3

La challenge consiste nella creazione di uno specifico file contenente un token fornito da Certbot. Tale file deve essere posizionato temporaneamente sul sito web in modo che possa essere verificato dai server di Let’s Encrypt. Al termine del processo di verifica il file viene rimosso. Tali operazioni sono svolte da due script Python (auth-hook.py e cleanup-hook.py).

L’accesso al bucket S3 del dominio è richiesto solo per il superamento della challenge e l’ottenimento del primo certificato SSL. Non sarà più richiesto durante i successivi rinnovi.

Importare il certificato su ACM

Una volta ottenuto il certificato, la lambda function si occupa dell’importazione dello stesso in ACM. Qualora esista già un certificato per il medesimo dominio, questo viene sostituito specificando durante l’importazione il relativo ARN.

Nota importante: per essere utilizzato in CloudFront, è necessario importare il certificato nella region US East (N. Virginia). Per comodità ho realizzato l’intero stack in questa region AWS.

Schedulazione con CloudWatch

CloudWatch è utilizzato per eseguire la lambda function una volta al giorno. Tra i parametri di configurazione della lambda, è prevista una variabile d’ambiente (CERTS_RENEW_DAYS_BEFORE_EXPIRATION) che determina quanti giorni prima della scadenza provvedere al rinnovo del singolo certificato. La scadenza viene ottenuta da ACM. In questo modo l’esecuzione della funzione non comporta necessariamente il rinnovo e quindi può essere schedulata quotidianamente senza preoccupazioni.

Il rinnovo tramite CertBot è forzato, garantendo l’ottenimento di un nuovo certificato 30 (valore di default) giorni prima della scadenza.

Deployment tramite CloudFormation

Nel repository di questo progetto è disponibile il template CloudFormation per la creazione dello stack. Questo comprende la funzione, il relativo ruolo, l’evento CloudWatch e i relativi permessi.

Per effettuare il deployment, la prima operazione da svolgere dopo aver clonato il repository, è la build della funzione.

make lambda-build

Per creare lo stack è necessario indicare il bucket S3 da utilizzare per la memorizzazione della configurazione di Certbot.

make BUCKET=your_bucket_name create-stack 

Il bucket indicato viene utilizzato anche per memorizzare temporaneamente i sorgenti della funzione lambda per il deployment.

Una volta creato lo stack, è necessario impostare le variabili di ambiente DOMAINS_LIST con l’elenco dei domini da gestire separati da virgola e DOMAINS_EMAIL con l’indirizzo email da utilizzare durante la richiesta dei certificati. Per ciascun nuovo dominio è necessario fornire la corretta policy di accesso al relativo bucket S3 che ospita le risorse statiche.

Conclusioni

Ottenere certificati SSL gratuiti per i propri progetti è davvero utile; automatizzarne la gestione tramite questo progetto mi ha dato la possibilità di dimenticarmene e vivere felice.

Consiglio di effettuare i propri test sfruttando l’ambiente di staging messo a disposizione da Certbot. La variabile d’ambiente CERTBOT_ENV consente di definire se utilizzare l’endpoint di produzione (production) o quello di staging.

Attenzione alle quote ACM: il numero di certificati che è possibile importare in ACM è limitato nel tempo (quantità nell’ultimo anno). Durante i miei test mi sono scontrato con limite molto basso: 20! Come riportato in questo post è necessario contattare il supporto AWS per la rimozione.

Error: you have reached your limit of 20 certificates in the last year.

Ulteriori sviluppi? Molti! Il requisito di avere un bucket S3 con lo stesso nome del dominio può, per esempio, essere superato prevedendo una configurazione più avanzata, magari memorizzata in un DB. Sentiti libero di migliorare il codice sorgente originale e di farmelo sapere!

Ci siamo divertiti? Alla prossima!

Automation

Sviluppo su Ansible con Molecule, AWS e TravisCI

5 min

“Automation is awesome!” – Questo è uno degli slogan che leggo sempre più spesso su blog e articoli che si occupano di DevOps e CI/CD. In effetti credo di essere d’accordo: le possibilità che esistono oggi per automatizzare i processi di integrazione e deployment sono incredibili.

Supponiamo, per esempio, di dover automatizzare l’installazione di Apache su Ubuntu. Scriveremo un ruolo Ansible che svolga tale operazione e lo potremo poi riutilizzare come building block di un processo automatizzato più ampio, come il deployment di una soluzione.

Viene da chiedersi: come testare il ruolo durante il suo sviluppo e i successivi aggiornamenti? Avendo recentemente affrontato una simile challenge, ho avuto modo di realizzare una pipeline di CI utilizzando degli strumenti molto potenti (e gratuiti!). Vediamoli.

Ansible Role

Se non lo abbiamo ancora fatto, preoccupiamoci di installare Ansible.

Andremo a realizzare il nostro script my-httpd nel modo più semplice possibile: creiamo tutte le directory che descrivono normalmente i ruoli Ansible, nell’ambito di un progetto di esempio awesome-ci.

mkdir -p awesome-ci/roles/my-httpd
cd awesome-ci/roles/my-httpd
mkdir defaults files handlers meta templates tasks vars

Nella directory tasks creiamo il file main.yml con il codice Ansible per l’installazione di Apache. Nella directory root del progetto creiamo un playbook.yml che potrà utilizzare il nostro ruolo. Il contenuto dei due file sarà per esempio:

Ora che abbiamo scritto tutto il codice necessario per installare Apache con Ansible ci concentriamo sulla realizzazione della nostra pipeline, iniziando con i primi test.

Code linting

Cos’è il code linting? Wikipedia ci suggerisce essere “uno strumento che analizza il codice sorgente per contrassegnare errori di programmazione, bug, errori stilistici e costrutti sospetti”.

ansible-lint ci consente di effettuare questo tipo di verifica sul nostro playbook e ruolo. Per installarlo si usa PIP:

pip install ansible-lint

Per verificare quanto abbiamo scritto ci spostiamo nella directory root del progetto ed eseguiamo:

ansible-lint playbook.yml

ansible-lint verificherà il contenuto del playbook e dei ruoli utilizzati nei confronti di alcune regole, riportandoci eventuali errori e violazioni di best practices:

[201] Trailing whitespace
/home/awesome-ci/roles/my-httpd/tasks/main.yml:8
        name: httpd 

Ogni segnalazione riporta un codice identificativo (in questo caso 201). La relativa documentazione descrive cause e soluzioni. Nel mio fortunato caso l’unica segnalazione riportata riguarda uno semplice spazio di troppo alla fine della riga indicata. E’ possibile ignorare alcune regole specificandone i codici identificativi sulla command line.

ansible-lint playbook.yml -x 201

Ogni qualvolta andremo a modificare il nostro ruolo potremo utilizzare ansible-lint per verificare se abbiamo rispettato le opportune best practices. Non fermiamoci però qui! E’ arrivato il momento di verificare se il nostro ruolo fa effettivamente ciò di cui abbiamo bisogno.

Molecule

Molecule è uno strumento pensato per testare i propri ruoli Ansible, in diversi scenari e con diversi sistemi operativi, appoggiandosi a diversi provider di virtualizzazione e Cloud.

Per procedere alla sua installazione si usa PIP. Consiglio però di verificare la documentazione ufficiale per l’installazione dei requirements.

pip install molecule

Ad installazione terminata ci posizioniamo nella directory roles e ci occupiamo di ricreare il nostro ruolo con Molecule. Spostiamo poi il nostro file main.yml nel nuovo ruolo.

# Save old role
mv my-httpd my-httpd-orig

# Init new role using Molecule
molecule init role -r my-httpd -d docker

# Move task file to new role
cp my-httpd-orig/tasks/main.yml my-httpd/tasks/
rm -r my-httpd-orig 

Con il comando di inizializzazione del ruolo abbiamo indicato a Molecule di utilizzare docker come environment di test. Spostiamoci nella directory del ruolo e proviamo.

cd my-httpd
molecule test

Purtroppo vedremo subito che il nostro test fallisce durante la fase di linting. Molecule crea infatti un ruolo utilizzando ansible-galaxy, completo quindi di tutte le directory necessarie, META compresa. Quest’ultima andrebbe opportunamente corretta sostituendo le informazioni generiche riportate con quelle relative al nostro ruolo. In alternativa è possibile indicare ad ansible-lint di ignorare le relative regole. Andiamo quindi a modificare il file di configurazione di Molecule.

La directory molecule del nostro ruolo comprende una subdirectory per ciascuno scenario di test. Lo scenario che abbiamo creato è default. In questo folder troviamo il file molecule.yml che riporta la configurazione di ogni fase, compresa quella di linting. Aggiungiamo l’opzione per escludere determinate rules.

provisioner:
  name: ansible
  lint:
    name: ansible-lint
    options:
      x: [201, 701, 703]

Eseguiamo di nuovo il test: la fase di code linting viene superata con successo ma poi incappiamo in un altro errore.

fatal: [instance]: FAILED! => {"changed": false, "cmd": "apt-get update", "msg": "[Errno 2] No such file or directory", "rc": 2}

Il motivo? Non così immediatamente identificabile, ma analizzando un pò il file di configurazione di molecule che abbiamo precedentemente modificato, ci accorgiamo che l’immagine docker utilizzata per testare il ruolo è basata su CentOS. Decidiamo invece di usare Ubuntu 18.04, per il quale il comando “apt-get update” ha un senso.

platforms:
  - name: instance
    image: ubuntu:18.04

Lanciamo di nuovo il test e.. boom! Superato! Ci manca però ancora qualcosa: le operazioni indicate nel nostro ruolo vengono svolte senza errori ma non ci siamo effettivamente occupati di verificare che il servizio Apache2 sia installato ed attivo. Possiamo configurare Molecule in modo che lo verifichi per noi al termine dell’esecuzione del playbook, indipendentemente dalle azioni previste nel ruolo.

Dobbiamo modificare il file test_default.py posto nella directory tests di Molecule.

Eseguiamo di nuovo il test prestando attenzione all’azione “verify”.

--> Scenario: 'default'
--> Action: 'verify'
--> Executing Testinfra tests found in /home/awesome-ci/roles/my-httpd/molecule/default/tests/...
    ============================= test session starts ==============================
    platform darwin -- Python 3.8.1, pytest-4.6.4, py-1.8.1, pluggy-0.13.1
    rootdir: /home/awesome-ci/roles/my-httpd/molecule/default
    plugins: testinfra-3.3.0
collected 2 items                                                              
    
    tests/test_default.py ..                                                 [100%]
    
    =========================== 2 passed in 3.14 seconds ===========================
Verifier completed successfully.

Ottimo! Ricapitoliamo: abbiamo configurato Molecule per verificare mediante ansible-lint il nostro ruolo. Successivamente lo stesso viene utilizzato in un playbook per installare e avviare Apache in un container docker basato su Ubuntu. Al termine viene inoltre verificato che Apache sia effettivamente installato e avviato. Il tutto in modo automatizzato.

E’ arrivato il momento di spostarci in Cloud. Perché non provare il nostro ruolo in uno scenario totalmente differente? Ad esempio su AWS EC2?

Molecule & AWS EC2

Per testare il nostro ruolo su AWS dobbiamo creare un nuovo scenario di Molecule. Dalla directory del ruolo inizializziamo il nuovo scenario aws-ec2 con:

molecule init scenario -d ec2 -r my-httpd -s aws-ec2

Come si può notare, questa volta usiamo ec2 come environment di test. Il file molecule.yml è ovviamente diverso dal precedente scenario, in quanto prevede le impostazioni specifiche per la piattaforma AWS.

platforms:
    - name: instance
      image: ami-0047b5df4f5c2a90e     # change according to your EC2
      instance_type: t2.micro
      vpc_subnet_id: subnet-9248d8c8   # change according to your VPC

L’immagine AMI e la subnet specificate nel file di configurazione devono essere modificate in accordo con la region che si intende utilizzare per il test. Per trovare la AMI Ubuntu corretta consiglio di utilizzare questo sito.

Valgono inoltre le configurazioni previste nello scenario docker: specificare le esclusioni nelle regole di code linting e i test a termine deployment per la verifica del servizio Apache.

Dobbiamo specificare region e credenziali AWS: lo si può fare impostando alcune variabili ambiente prima di avviare il test con Molecule.

export EC2_REGION=eu-west-2
export AWS_ACCESS_KEY_ID=YOUR_ACCESSKEY
export AWS_SECRET_ACCESS_KEY=YOUR_SECRETACCESSKEY 
molecule test

Successo! Abbiamo rapidamente creato un nuovo scenario e siamo in grado di testare il nostro ruolo anche su AWS. Se collegati alla console EC2, vedremo infatti che Molecule si occuperà di avviare una nuova virtual machine su cui effettuare i test. La stessa sarò terminata concluse le operazioni. Vediamo ora come creare una pipeline di CI che, ad ogni push sul nostro repository GitHub, provveda all’esecuzione di questi test con Molecule su AWS.

Travis CI

Una volta registrati su TravisCI è possibile configurare la nostra pipeline per il repository GitHub che ospita il nostro progetto di automazione. La configurazione avviene creando il file .travis.yml in root.

Come si vede nella sezione install, il file test-requirements.txt riporta l’elenco dei pacchetti da installare. Il suo contenuto è:

ansible-lint
molecule
boto 
boto3

Nella sezione script sono invece indicate le operazioni da svolgere: ansible-lint viene utilizzato preliminarmente per le verifiche di playbook e ruolo; viene poi utilizzato Molecule per effettuare il test del ruolo nello scenario AWS-EC2.

Manca qualcosa? Si, le credenziali AWS. Per condividerle in modo sicuro utilizziamo la CLI di Travis. Per prima cosa installiamola:

gem install travis

Procediamo poi a codificare le credenziali in modo sicuro, aggiungendole al file di configurazione di Travis. Le stesse saranno poi passate sotto forma di variabili di ambiente a Molecule durante l’esecuzione dei test.

travis login --pro

travis encrypt AWS_ACCESS_KEY_ID="<your_key>" --add env.global --pro
travis encrypt AWS_SECRET_ACCESS_KEY="<your_secret>" --add env.global --pro

Proviamo una build con TravisCI e … bingo! Abbiamo terminato.

Conclusioni

Abbiamo visto come l’utilizzo di questi strumenti consenta di sviluppare ruoli e playbook Ansible di qualità sottoponendoli costantemente ad una pipeline in grado di verificarne la correttezza, su ambienti e scenari completamente differenti.

Un progetto per il quale ho utilizzato gli stessi strumenti è disponibile su questo repository GitHub: si tratta di un playbook Ansible dedicato alla realizzazione di un cluster Docker Swarm.

Ci siamo divertiti? Alla prossima!