Importer des fichiers de Cloudflare R2 en Java avec Rclone
Utilisez ProcessBuilder de Java pour exécuter rclone copy,
puis rendez le répertoire téléchargé accessible uniquement après la fin de l’exécution réussie de
rclone. L’exemple ci-dessous importe un préfixe Cloudflare R2 privé, conserve l’arborescence et les noms
de fichiers, et renvoie un code de sortie signalant un échec si le transfert échoue ou dépasse son
délai maximal.
Préparer le bucket et les outils
Ce guide concerne une application Linux utilisant OpenJDK 21 et une locale UTF-8. Il a été testé avec OpenJDK 21.0.12.1 et rclone 1.75.2. Utilisez une mise à jour maintenue de votre JDK ; la version corrective testée n’est pas en soi une version minimale requise. rclone est un exécutable distinct, disponible via son guide d’installation. Aucune dépendance Java ni aucun outil de construction ne sont nécessaires.
Vous devez disposer d’un bucket R2 existant contenant les fichiers à télécharger. Ne modifiez pas
ce préfixe pendant l’import : copier un bucket en cours de modification ne produit pas un instantané
à un instant précis. Utilisez des clés correspondant à des chemins de fichiers Linux distincts,
sans segments de chemin vides ou égaux à . ou
.., sans collisions entre fichiers et répertoires, ni noms que votre
système de fichiers ne peut pas représenter. Les espaces, les chemins imbriqués, Unicode et les
fichiers de zéro octet sont pris en charge. Des clés d’objets arbitraires ne constituent pas
nécessairement des chemins valides pour le système de fichiers.
Vérifiez chaque exécutable requis dans le shell qui lancera Java :
command -v java >/dev/null &&
command -v javac >/dev/null &&
command -v rclone >/dev/null &&
java -version && javac -version && rclone version
Les commandes ci-dessous sélectionnent C.UTF-8 ; cette locale doit être
installée. Travaillez dans un répertoire vous appartenant et accessible en écriture. Le répertoire
parent de la destination doit déjà exister, prendre en charge les renommages atomiques de
répertoires et disposer de suffisamment d’espace pour le téléchargement complet. Exécutez un seul
importateur à la fois, avec un contrôle exclusif de ce répertoire parent.
Configurer un stockage distant R2 privé
Obtenez les valeurs Access Key ID et Secret Access Key d’un jeton R2 limité à votre bucket et doté de l’autorisation Object Read only. Cette autorisation couvre la lecture et l’énumération des objets via l’API compatible S3 ; l’importateur n’a pas besoin de créer ni de supprimer des objets distants. Consultez le guide d’authentification R2 de Cloudflare.
À l’aide d’un éditeur de texte, enregistrez le contenu suivant dans votre répertoire de travail sous
le nom r2-import.conf. Remplacez les trois valeurs à renseigner par vos informations
d’accès et votre identifiant de compte. Ce point de terminaison concerne un bucket situé dans la
juridiction par défaut ; utilisez le point de terminaison propre à la juridiction de votre bucket,
le cas échéant.
[cloudflare_r2]
type = s3
provider = Cloudflare
access_key_id = YOUR_ACCESS_KEY_ID
secret_access_key = YOUR_SECRET_ACCESS_KEY
endpoint = https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
region = auto
no_check_bucket = true
Restreignez l’accès à ce fichier :
chmod 600 ./r2-import.conf
Le programme Java transmet explicitement ce fichier avec --config et ne dépend
donc pas de l’emplacement habituel de la configuration de rclone. no_check_bucket
suit les consignes de configuration de rclone de Cloudflare pour les
jetons disposant d’autorisations sur les objets. N’ajoutez pas ce fichier au dépôt et ne collez pas
les informations d’accès dans les arguments des commandes. Les variables
RCLONE_* héritées par le processus enfant sont supprimées afin que des réglages
comme une simulation globale ne puissent pas transformer silencieusement cet import en une autre
opération.
Enregistrer l’importateur Java
Enregistrez ce fichier complet sous le nom RcloneImporter.java. Les arguments sont
l’exécutable rclone, le fichier de configuration, le préfixe distant, le nouveau répertoire de
destination et le délai maximal du transfert complet en secondes. Fournissez une configuration
applicative de confiance, plutôt que d’accepter des chemins d’exécutables ou des stockages distants
arbitraires provenant des utilisateurs.
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class RcloneImporter {
private static final int DIAGNOSTIC_BYTES = 8192;
private static void runRclone(ProcessBuilder builder, Duration timeout)
throws IOException, InterruptedException {
Process process = builder.redirectErrorStream(true).start();
var diagnostics = new ByteArrayOutputStream();
var readFailure = new AtomicReference<IOException>();
Thread drain = Thread.ofVirtual().start(() -> {
try (var input = process.getInputStream()) {
byte[] buffer = new byte[4096];
int count;
while ((count = input.read(buffer)) != -1) {
int kept = Math.min(count, DIAGNOSTIC_BYTES - diagnostics.size());
if (kept > 0) diagnostics.write(buffer, 0, kept);
}
} catch (IOException error) {
readFailure.set(error);
}
});
try {
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
throw new IOException("rclone timed out after " + timeout);
}
drain.join();
if (readFailure.get() != null) throw readFailure.get();
System.err.print(diagnostics.toString(StandardCharsets.UTF_8));
if (process.exitValue() != 0) {
throw new IOException("rclone exited with code " + process.exitValue());
}
} finally {
// Reap the direct rclone child before deleting anything it might still be writing.
process.destroyForcibly();
boolean interrupted = false;
while (process.isAlive() || drain.isAlive()) {
try {
process.waitFor();
drain.join();
} catch (InterruptedException error) {
interrupted = true;
}
}
if (interrupted) Thread.currentThread().interrupt();
}
}
private static void removeStaging(Path staging) throws IOException {
Files.walkFileTree(staging, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException error)
throws IOException {
if (error != null) throw error;
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
public static void importFiles(Path executable, Path config, String source, Path destination,
Duration timeout) throws IOException, InterruptedException {
if (!Files.isExecutable(executable) || !Files.isRegularFile(config)) {
throw new IOException("Check the rclone executable and configuration file");
}
if (!source.startsWith("cloudflare_r2:") || !source.endsWith("/")) {
throw new IllegalArgumentException("Use cloudflare_r2:bucket/prefix/ as the source");
}
if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Timeout must be between 1 and 86400 seconds");
}
Path name = destination.getFileName();
if (name == null || name.toString().equals(".") || name.toString().equals("..")) {
throw new IllegalArgumentException("Choose a new destination directory name");
}
Path target = destination.toAbsolutePath();
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Destination already exists; choose a new directory");
}
Path staging = Files.createTempDirectory(target.getParent(), ".r2-import-");
boolean published = false;
try {
ProcessBuilder builder = new ProcessBuilder(executable.toAbsolutePath().toString(),
"--config", config.toAbsolutePath().toString(), "copy",
"--", source, staging.toString());
builder.environment().keySet().removeIf(key -> key.startsWith("RCLONE_"));
runRclone(builder, timeout);
if (Thread.currentThread().isInterrupted()) throw new InterruptedException();
Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE);
published = true;
} finally {
if (!published) removeStaging(staging);
}
}
public static void main(String[] args) {
if (args.length != 5) {
System.err.println("Usage: java RcloneImporter RCLONE CONFIG REMOTE DEST SECONDS");
System.exit(2);
}
try {
importFiles(Path.of(args[0]), Path.of(args[1]), args[2], Path.of(args[3]),
Duration.ofSeconds(Long.parseLong(args[4])));
System.out.println("Import complete: " + args[3]);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
System.err.println("Import interrupted; destination was not published");
System.exit(130);
} catch (IOException | IllegalArgumentException error) {
System.err.println("Import failed: " + error.getMessage());
System.exit(1);
}
}
}
Les deux flux de sortie du processus enfant sont dirigés vers un même tube. Un fil d’exécution
virtuel distinct vide continuellement ce tube et ne conserve que les 8 KiB initiaux pour les
messages de diagnostic du terminal. Le fil d’exécution appelant peut ainsi faire respecter le délai
maximal du transfert, même lorsque rclone produit plus de sortie que le tube ne peut en contenir.
La documentation de Process de Java explique
le risque de blocage de la sortie d’un sous-processus.
Le répertoire de préparation se trouve dans le même répertoire parent que la destination finale. Après un code de sortie zéro de rclone, Java demande un déplacement atomique. Si le système de fichiers ne peut pas l’effectuer, l’import échoue. Les déplacements atomiques ne constituent pas un verrou contre l’écrasement : une écriture concurrente peut survenir entre la vérification de l’existence et le déplacement, d’où l’exigence d’un accès exclusif dans cet exemple.
Compiler et importer le préfixe
Remplacez /absolute/path/to/rclone par l’exécutable trouvé précédemment et utilisez votre
bucket et votre préfixe. Conservez les guillemets autour des chemins. Exécutez les deux commandes
ensemble pour qu’un échec de compilation ne puisse pas lancer un ancien fichier de classe :
javac -proc:none -encoding UTF-8 RcloneImporter.java &&
LC_ALL=C.UTF-8 java -cp . RcloneImporter /absolute/path/to/rclone ./r2-import.conf \
'cloudflare_r2:my-data-bucket/source-files/' ./downloaded-files 600
En cas de succès, le programme affiche Import complete: ./downloaded-files et se termine avec un code de
sortie zéro. Un objet tel que source-files/reports/July résumé.txt devient
downloaded-files/reports/July résumé.txt.
copy copie le contenu du préfixe, sans
ajouter source-files comme répertoire supplémentaire. Les objets distants ne sont
pas supprimés.
Le délai de 600 secondes comprend les nouvelles tentatives de rclone ; même un fichier valide peut donc dépasser ce délai sur une connexion lente. À l’expiration du délai, le programme arrête le processus enfant rclone direct et attend sa fin, supprime le répertoire de préparation de cette invocation et laisse la destination finale absente. Choisissez un délai plus long, jusqu’à 86 400 secondes, puis réessayez avec la même destination après la fin du processus ayant échoué. Toute destination existante, y compris un lien symbolique, est refusée ; utilisez un nouveau nom pour un autre import complet.
Diagnostiquer un import infructueux
- Fin du processus enfant : Lisez les messages de diagnostic limités en taille affichés dans le terminal, puis vérifiez le fichier de configuration sélectionné, la portée du jeton sur le bucket, le point de terminaison et l’accès HTTPS sortant. Une requête qui échoue constitue une erreur, pas un téléchargement vide réussi.
- Aucun fichier : Un préfixe vide ou inexistant peut être copié avec succès sous la forme d’un répertoire vide. Vérifiez le préfixe et inspectez le résultat avant de l’utiliser ; un code de sortie zéro ne prouve pas qu’un objet particulier existait. Conservez un inventaire indépendant des fichiers attendus ou les sommes de contrôle fournies par l’éditeur si ces éléments sont requis.
- Interruption : Une application appelant
importFilespeut interrompre son fil d’exécution Java en attente ; la méthode arrête le processus enfant et nettoie le répertoire de préparation avant de propagerInterruptedException. Il appartient à l’appelant de rétablir son indicateur d’interruption. Cette opération est distincte de l’arrêt forcé de la JVM. - Arrêt brutal : Une coupure de courant,
kill -9ou l’arrêt de la JVM peuvent laisser des répertoires.r2-import-*ou un processus enfant orphelin. Arrêtez tout processus enfant restant avant de supprimer uniquement le répertoire de préparation propre à cette invocation. Ces répertoires contiennent un travail inachevé et ne constituent jamais la destination finale. Le nettoyage peut également échouer si les autorisations ou la disponibilité du système de fichiers changent.
Choisir où exécuter les imports
Cette approche convient à un service Java qui utilise déjà rclone et a besoin de fichiers locaux avant de les traiter. Si les fichiers doivent plutôt intégrer un flux de travail hébergé par Transloadit, consultez le Cloudflare Import Robot et le SDK Java.
