Piero V.

Mirror su OneDrive

Il problema

Una persona, già da diverso tempo, mi ha chiesto di aiutarla a mantenere un mirror delle cartelle importanti di un suo file server Linux sull’account OneDrive, siccome, con Office, lì ha 1TB di spazio.

Stiamo parlando di meno di 500000 file per una dimensione di circa 250GB, quindi non qualcosa di assurdamente grande, ma neanche qualcosa di proprio semplice da gestire.

Finora ho provato sia con rclone, un tool per eseguire operazioni su diversi provider cloud, in particolare di copia e sincronizzazione, sia con quello che è uno dei più diffusi client OneDrive nativi Linux, ma nessuno dei due è riuscito a gestire la cosa.

rclone deve essere uno strumento molto utile per molte situazioni, ma non per questa, perché penso che faccia costantemente richieste per verificare la struttura dei dati in OneDrive, anziché tenersi delle informazioni in locale, ma per questo motivo Microsoft lo rallenta costantemente. Invece il client OneDrive ha un database locale, ma magari a volte ci sono delle inconsistenze e si ferma, con delle eccezioni; in particolare mi sembra non gestisca bene l’essere case insensitive di OneDrive.

Questo non vuole essere un sistema di backup, ce ne sono già altri funzionanti, ma avere una copia in più fa sempre comodo, soprattutto facilmente accessibile, con l’emergenza che stiamo vivendo. Inoltre mi farei scrupoli, se ci fosse un qualche problema con i dati e i sistemi di backup fallissero, e io avessi saputo che le copie su OneDrive in realtà non stavano funzionando.

È anche vero che però ho già perso un sacco di tempo per questa cosa, e ho pensato che persino se avessi fatto io un sistema probabilmente ne avrei perso meno. Le complicazioni e i guasti ci potranno essere lo stesso (e sicuramente ci saranno), ma almeno in questo caso dovrò solamente avercela con me stesso.

Ho quindi deciso di farlo davvero un sistema di mirroring di directory su OneDrive e, proprio mentre lo sto creando, sto scrivendo questo articolo. Il codice finale non è lunghissimo, ma è comunque di qualche centinaio di righe, quindi, anziché riportarlo qui, vi lascio direttamente il link ad un mio repo su GitHub dove lo potete trovare.

Due parole su OneDrive

Fondamentalmente, come ogni sistema di oggi, OneDrive è basato su API REST.

Per il nostro scopo, di fondamentale importanza sono i Drive e i DriveItem. I primi, fondamentalmente, definiscono dei contenitori che possono contenere dei DriveItem, i quali invece sono principalmente file e cartelle, e possono avere altri DriveItem come figli.

In altre parole è una struttura ad albero, in cui l’utente è la radice, i Drive sono i nodi figli della radice, e i DriveItem sono tutti gli altri figli. La struttura però è appiattita, e per ricostruirla bisogna usare gli ID forniti da OneDrive.

Dei Drive non ci interessa molto, solo l’elenco dei figli. I DriveItem invece hanno più attributi, in particolare danno informazioni anche sulla natura del file (documento office, o un contenuto multimediale - immagini, foto, video, audio -, etc), e in particolare hanno anche delle informazioni su un hash. Questo hash è uno sha1 per OneDrive consumer, mentre per gli account business (il mio caso) è un QuickXor, un hash proprietario Microsoft, di cui è disponibile un’implementazione ufficiale in C# e diversi porting in altri linguaggi, di terze parti. Una cosa che mi è risultata utile, è che si possono richiedere solo una parte di queste informazioni, con dei parametri get.

Oltre a questi, ci sono anche tutta una serie di altri oggetti, in particolare per l’upload, che non è proprio banale, e vedremo dopo. Tutta una serie di oggetti invece non ci sarà per niente utile: essendo i dati locali l’unica fonte di verità, non ci interessano né il download, né l’elenco dei cambiamenti.

In ogni caso, è molto importante consultare la documentazione ufficiale, e fare dei test, anche solamente per capire quando l’operazione ha avuto successo, dato che molte non ritornano un classico codice 200, ma qualcosa di più specifico.

L’idea di fondo

Per l’idea mi sono ispirato un po’ al client OneDrive: mi tengo una copia della struttura di OneDrive in locale, e periodicamente controllo la consistenza tra i metadati (ultima modifica, dimensione, ed eventualmente hash) dei file locali e del database. I file locali che non sono sul database vanno aggiunti, quelli che sono sul database ma non in locale vanno cancellati da OneDrive, e infine quelli incoerenti vanno aggiornati.

Volendo, su Linux, si potrebbero usare dei watcher per avere delle notifiche di cambiamento dei file, ma non ci interessa una sincronizzazione continua, bensì un mirror, anche ritardato di qualche ora, anche perché servirebbe un watcher per ogni directory, quindi davvero molti, nel caso specifico, ben più degli 8192 permessi per impostazione predefinita dal kernel.

Probabilmente un file JSON sarebbe sufficiente, ma un database SQLite dovrebbe dare qualche garanzia in più, evitandomi anche di caricare tutto in RAM. Inoltre, per cercare di mitigare inconsistenze, l’idea è quella di ricreare l’intero database, o quasi, di tanto in tanto.

L’implementazione

Fondamentalmente quello che dobbiamo fare è abbastanza comune in tutti i linguaggi: in linea di massima, strutture ad albero, hashmap con chiavi case-insensitive, API REST e interfacciarsi con SQLite. Dunque, come criterio per la scelta finale ho preferito la semplicità di implementazione e messa in opera, optando quindi per Python.

Inizialmente avrei voluto usare l’SDK ufficiale, ma è decisamente da evitare. Tanto per cominciare, ci sono problemi di installazione, noti da Novembre 2019, se non da prima, e il fatto che in 6 mesi non li abbiano sistemati la dice lunga sullo stato del progetto. Anche chiudendo un occhio su questo e installandolo dal repo git, non sono riuscito comunque a fare le più elementari operazioni, cioè prendermi il mio drive e ottenere l’elenco dei suoi figli.

Microsoft dispone anche di un SDK per le sue Graph API, con commit molto recenti su GitHub, ma non funziona o non ha nemmeno la minima documentazione che mi consenta di capire dove mi sto sbagliando, quindi ho evitato anche questo.

Infine, si trovano diversi progetti nella documentazione ufficiale di Microsoft stessa, basati su requests-oauthlib, un’estensione per requests che permette di usare API REST che richiedono autenticazione OAuth. Finalmente un approccio che sono riuscito a usare senza troppe difficoltà.

Quindi il nostro approccio sarà quello di autenticarci usando questa libreria, poi fare tutte le varie chiamate HTTP, usando requests, ma chiamandolo tramite un oggetto restituito dall’autenticazione al posto del più classico requests.

Ma questo è solo un blocco del progetto finale. Gli altri sono la gestione del database e degli elementi di collante, in modo da non mischiare query e SQL e richieste HTTP e codice che effettua le operazioni vere e proprie.

L’autenticazione

Questa è stata decisamente la parte più difficile da capire per me, pur essendo abbastanza standard (OAuth2), ma forse anche perché fa da barriera a tutto il resto, o perché è quella documentata peggio.

La prima cosa da fare è registrare l’applicazione che stiamo scrivendo, per ottenere dei codici che la identifichino e permettano di autenticarci in sicurezza.

Rechiamoci sulla pagina dedicata allo scopo su Azure e creiamo la nuova applicazione. Sotto URI di reindirizzamento andiamo a specificare una cosa come http://localhost:8080/ (al posto di 8080 va bene anche qualsiasi altra porta, se avete veramente qualcosa in ascolto lì).

Dopo averlo fatto, creiamo anche un segreto e, com’è buona prassi, salviamo questi codici in un file a parte, che non verrà versionato, in modo da evitare di mandre in giro i nostri segreti.

Non andremo a fare invece il setup dei permessi, perché è sufficiente specificarli al momento del login vero e proprio.

Dopo questo passaggio propedeutico, è possibile proseguire con l’autenticazione, che è divisa in due fasi: la prima consiste nel far fare il login vero e proprio all’utente (solo noi stessi, probabilmente), dopodiché otterremo un token da usare per le richieste successive. Questo token in realtà è valido solamente un’ora, ma per fortuna requests-oauthlib è in grado di rinnovarlo, togliendoci questo impiccio, purché le passiamo una callback per salvare i nuovi token. Tutto ciò viene fatto nel file client.py.

La prima volta però dobbiamo fare appunto il login con e-mail e password: partendo dall’URL del servizio, la libreria ci crea un URL da visitare col browser e fare il login. Ci rendirizzerà alla pagina specificata durante la creazione della app, aggiungendo in particolare un parametro get code all’URL: questo va copiato e incollato durante l’autenticazione dell’app, che andrà a creare il token per la prima volta, dopodiché, se tutto andrà bene, non dovremo più fare un login. Questa fase è implementata nell’auth.py.

La creazione del database

Il nostro sistema è fatto in modo che possa partire sia da OneDrive vuoto, che da OneDrive pieno, che quindi in caso di corruzione del database basti semplicemente cancellarlo e ripartire, senza dover scaricare tutti i file per fare una verifica.

In questo modo, tra l’altro sarà possibile continuare a fare il mirror un po’ di volte di seguito, aggiornando il database esistente, poi cancellarlo e ricominciare, per essere sicuri di essere allineati.

Per farlo ci basiamo su dimensione dei file, data di ultima modifica (che OneDrive salva in UTC, solitamente) e il loro hash, che dobbiamo mantenere salvati. Oltre a ciò, abbiamo bisogno dei vari ID.

Siccome la struttura è ad albero, per essere sicuri che il percorso sia corretto, avremo bisogno di tenere salvate anche tutte le directory, quindi abbiamo bisogno di un flag per distinguerle dai file. Inizialmente pensavo di verificare se campi come dimensione e hash fossero NULL, ma OneDrive non restituisce alcun hash per i file di dimensione nulla, e, per evitare di confondersi tra NULL e 0 o stringa vuota, è meglio tenere direttamente un flag. Il percorso originale dovrebbe essere facilmente ricostruibile, ma OneDrive ha una strategia per gestire i conflitti, quindi teniamo salvato anche quello.

Da un punto di vista, per la creazione, invece, basta esplorare ricorsivamente il drive, partendo dalle cartelle che ci interessano. Dovremo quindi fare una serie di richieste a URL nel formato .../drive/id-risorsa/children, quindi possiamo partire dai drive e tenerci coda degli ID delle cartelle da listare. Una cosa comoda è che possiamo restringere i campi ritornati a solo quelli presenti nel database, col parametro get select.

Notate che OneDrive ha un limite di item ritornati, quindi bisogna verificare la presenza della chiave @odata.nextLink e seguirla, per avere l’elenco completo di tutti i figli di un nodo.

Inoltre, ad un certo puntoOneDrive comincia a limitare le richieste, restituendo un errore 429 e tra gli header un Retry-After. L’unica cosa da fare in questo caso è fare uno sleep e aspettare per proseguire.

Un altro problema è che, in caso di conflitti, potrebbe essere difficile tornare indietro al nome originale. Quindi, ho deciso di aggiungere un ulteriore campo, che dice se un file esiste o meno: prima di ripopolare il database, si mette tutto a 0, poi se si trova riscontro, si mettono a 1, o, se non esiste la riga, la si aggiunge. Così non si perdono mai eventuali nomi non coincidenti con quelli locali. Se proprio dovesse succedere (e.g. database corrotto), allora caricheremo nuovamente i file che confliggono. Probabilmente ci potrebbero essere strategie migliori, ma come già detto, sto cercando di tenere tutto il più semplice possibile.

Inizialmente pensavo di poter risolvere tramite l’hash, ma ci sono un paio di problemi: l’hash dovrebbe essere univoco per ogni file (o almeno, questo sarebbe l’obiettivo), ma uno stesso file può essere in directory diverse. Non ci avevo pensato a questa cosa, ma me ne sono accorto cercando di risolvere il secondo problema: partire da una lista di quasi 400000 hash da associare a 200000 righe già esistenti è molto lento, a meno di non usare un indice. Creando l’indice con vincolo di unicità mi sono imbattuto in un bell’errore, che mi ha ricordato questo problema.

Inoltre mi sono imbattuto in una curiosità di Python, che mi ero dimenticato: mentre SQLite eseguierebbe le operazioni immediatemente, Python cerca di gestire la cosa in maniera più intelligente. Il risultato è che gli insert non fanno da soli dei commit, e bisogna farli manualmente, altrimenti i dati non vengono salvati, e si deve cercare un buon compromesso tra salvataggio dei dati (per evitare di perderli per eccezioni varie) e prestazioni.

In questa fase ho deciso di installare altre due librerie di terze parti.

La prima è recordclass. Di fondo, rimango un programmatore C++/C e, in Python, per me manca una cosa fondamentale: poter dichiarare membri fuori dal costruttore, o comunque una struttura dati tipo le struct di C. Python già dalla versione 2.6 fornisce le namedtuple, ma non sono proprio la stessa cosa, perché sono immutabili. Invece le recordclass sono nate come una loro alternativa mutabile, poi col tempo sono state anche migliorate per quanto riguarda aspetti come le prestazioni.

La seconda libreria che ho aggiunto invece è dateutil, un pacchetto di estensioni a datetime, tra cui una per fare il parsing delle date in formato ISO 8601. Sinceramente ho trovato strano che un linguaggio che fa del «Batteries Included» il proprio motto non fornisca una utility completa del genere, ma solo con una delle versioni recenti (la 3.7) abbia incluso una funzione datetime.fromisoformat che non è nemmeno completa.

Confronto filesystem-OneDrive

In ogni caso, impostato e popolato il database, possiamo passare alla fase successiva: confrontare questi dati con quelli del filesystem.

Il mio obiettivo era un po’ quello di gestire la prima associazione filesystem-OneDrive stessa come un normale aggiornamento.

La strategia allora è quella di cominciare dalle directory radice, fare la lista dei loro file e sottodirectory, aggiornare i file su OneDrive e poi reiterare il processo con le sottodirectory, gestite ancora una volta con una coda.

A questo punto mi sono imbattuto in un primo grosso problema: il case insensitivity di Windows è un disastro, o meglio ho avuto la conferma che il case insensitivity è una cattiva idea in generale, perché non esiste modo di farlo correttamente.

Ho infatti scoperto, che mentre in italiano la cosa è molto semplice, c’è una corrispondenza uno-a-uno tra le lettere con gli accenti minuscole e maiuscole, in certe lingue, come il tedesco e il turco, bisogna lavorare con gruppi di lettere.

A questo punto ho preferito fare un’assunzione: usare il metodo str.lower() di Python, senza pormi troppi altri problemi. Andando a vedere l’implementazione di pathlib.PureWindowsPath, si può vedere che è stata la stessa presa la stessa decisione. Dopotutto, OneDrive può gestire i confliti per noi, e salvo la prima sincronizzazione, dovremmo già avere tutti i vari percorsi salvati nel database.

Sono poi riemersi gli stessi problemi di prima. Uno è ancora quello dei commit, per cui ho adottato una soluzione uguale alla precedente (un commit ogni 1000 query).

Ma soprattutto, si è dimostrato necessario un indice sul parent_id, perché ci sono tantissime select che lo usano nella where clause. Creandolo, sono passato da uno script che non finiva più, a metterci meno di un minuto per finire l’associazione di tutti i percorsi già esistenti (senza eventuali upload).

Del codice intermedio era veramente incasinato, e per fortuna o purtroppo, volendo aderire a PEP 8, il limite degli 80 caratteri evidenziava quanto a volte ci fossero troppi livelli di indentazione per esserci tante cose implementate insieme. In quello finale questo controllo lo faccio con due classi: una “nodo”, che aggiorna, crea o elimina un DriveItem su OneDrive, e una per creare questi nodi, associando le informazioni provenienti dal file system con quelle del database cache di OneDrive.

Upload

L’upload è quindi diventato il naturale proseguimento dell’implementazione, e OneDrive supporta due modalità. La prima è per file di piccole dimensioni (meno di 4MB), la seconda invece presuppone la creazione di una sessione di upload, divisa in chunk di dimensione massima di 60MiB.

Personalmente ho preferito usare solo questa seconda, in modo da non avere parti di codice che fanno una cosa simile. Inoltre, quest’ultima dà la possibilità di specificare delle informazioni aggiuntive già al momento della creazione del file - a noi interessa la data di ultima modifica - mentre con la prima opzione si può solo inviare il contenuto del file stesso.

Di per sé l’upload non è una cosa difficilissima, solo che requests non ha un helper per questo tipo di upload divisi in chunk e vanno fatti manualmente, richiesta per richiesta, con l’header Content-Range.

Poi ci sono alcuni dettagli minori a cui stare attenti, per esempio che la creazione della sessione di upload è POST, ma tutti i vari upload sono PUT. Inoltre, la documentazione richiede di usare multipli di 320KiB per ogni singolo chunk, pena il fallimento del completamento dell’upload in alcuni casi (ma non specifica bene quali).

Invece una cosa comoda, è che l’ultima richiesta fornisce già un item, quasi pronto per essere inserito nel database, gli manca solo il percorso locale del file.

Controllo dell’hash

Ho accennato a un hash, che in particolare è un algoritmo specifico per OneDrive.

Fortunatamente, ne esiste già una versione Python, anzi, meglio ancora, un’estensione C, che così ha alte prestazioni. Tuttavia, anche con codice nativo, la sola creazione degli hash nel mio caso ci impiega quasi 3 ore, quindi ho deciso di non controllarlo ad ogni aggiornamento.

E alla fine ho pensato di integrarlo con gli aggiornamenti stessi, visto che comunque hanno già quest’informazione dell’hash e passano comunque tutti i file per controllarli.

Mettere tutto insieme

Finora ho parlato di vari pezzi singoli, però vanno messi insieme.

Per cominciare io avevo fatto dei test, ma poi più o meno con qualche criterio ho messo assieme un po’ di regole per gestire un po’ il tutto nel file service.py. Non è proprio un demone, perché non gestisce il fork, standard output/error, etc, però dovremme essere lo stesso abbastanza semplice da trasformare in un servizio systemd o simile.

La strategia di questo file è di ricreare il database settimanalmente, di fare un VACUUM di SQLite ogni giorno, e di fare un controllo agli hash una volta ogni 3 giorni. Invece il mirroring viene fatto ogni 4 ore.

Conclusioni

Forse è ancora un po’ presto per trarre delle vere e proprie conclusioni, perché in realtà il sistema è in opera da abbastanza poco, però spero resista. I miei test sembravano funzionare abbastanza, anche se man mano che il tempo passa emergono delle imperfezioni nel codice.

Tutto sommato i requisiti dell’applicazione hanno semplificato il lavoro da fare, mentre dare la possibilità di partire da un database vuoto ma i file già caricati (ed evitarsi un’altra settimana di upload) ha aggiunto qualche complicazione.

Ma soprattutto, in caso di problemi saprò dove andare a guardare.

Il codice lo potete trovare direttamente nel mio GitHub, però non penso lo aggiornerò in futuro. Lo rilascio nel pubblico dominio e senza alcuna garanzia.