Importa archivos de Cloudflare R2 en Java con Rclone
Usa ProcessBuilder de Java para ejecutar rclone copy y expón el directorio descargado solo cuando
rclone termine correctamente. El siguiente ejemplo importa un prefijo privado de Cloudflare R2,
conserva los nombres de archivo y sus rutas anidadas, y devuelve un estado de salida de error si la
transferencia falla o supera su plazo máximo.
Prepara el bucket y las herramientas
Este tutorial está pensado para una aplicación Linux que use OpenJDK 21 y una configuración regional UTF-8. Se probó con OpenJDK 21.0.12.1 y rclone 1.75.2. Usa una actualización de tu JDK que siga recibiendo mantenimiento; el parche probado no constituye una versión mínima obligatoria. rclone es un ejecutable independiente, disponible en su guía de instalación. No se necesitan dependencias de Java ni una herramienta de compilación.
Necesitas un bucket de R2 existente que contenga los archivos que vas a descargar. Mantén ese prefijo
sin cambios durante la importación: copiar un bucket que cambia no produce una instantánea de un
momento específico. Usa claves que correspondan a rutas de archivo distintas en Linux, sin segmentos
de ruta vacíos, . o .., colisiones entre archivos y directorios ni nombres
que tu sistema de archivos no pueda representar. Se admiten espacios, rutas anidadas, Unicode y
archivos de cero bytes. Las claves de objeto arbitrarias no son necesariamente rutas válidas del
sistema de archivos.
Verifica cada ejecutable necesario en el shell que iniciará Java:
command -v java >/dev/null &&
command -v javac >/dev/null &&
command -v rclone >/dev/null &&
java -version && javac -version && rclone version
Los siguientes comandos seleccionan C.UTF-8; esa configuración regional debe estar instalada.
Trabaja en un directorio de tu propiedad en el que puedas escribir. El directorio padre del destino
debe existir, admitir cambios de nombre atómicos de directorios y tener espacio para la descarga
completa. Ejecuta un solo importador a la vez, con control exclusivo de ese directorio padre.
Configura un remoto privado de R2
Obtén el Access Key ID y la Secret Access Key de un token de R2 cuyo ámbito esté limitado a tu bucket con el permiso Object Read only. Este permiso permite leer y listar objetos mediante la API compatible con S3; el importador no necesita crear ni eliminar objetos remotos. Consulta la guía de autenticación de R2 de Cloudflare.
Guarda lo siguiente como r2-import.conf en tu directorio de trabajo usando un editor de texto.
Reemplaza los tres marcadores de posición con tus credenciales y el ID de tu cuenta. Este endpoint
corresponde a un bucket en la jurisdicción predeterminada; usa el endpoint específico de la
jurisdicción de tu bucket cuando corresponda.
[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
Restringe el acceso a ese archivo:
chmod 600 ./r2-import.conf
El programa Java pasa este archivo explícitamente con --config, por lo que no depende de la
ubicación habitual de configuración de rclone. no_check_bucket sigue las
indicaciones de configuración de rclone de Cloudflare para tokens
con permisos sobre objetos. No incluyas el archivo en un commit ni pegues credenciales en los
argumentos de los comandos. Se eliminan las variables RCLONE_* heredadas por el proceso hijo
para que ajustes como una simulación global no conviertan silenciosamente esta importación en una
operación diferente.
Guarda el importador de Java
Guarda este archivo completo como RcloneImporter.java. Los argumentos son el ejecutable de rclone, el
archivo de configuración, el prefijo remoto, el nuevo directorio de destino y el tiempo de espera
máximo para toda la transferencia, en segundos. Proporciona una configuración de aplicación de
confianza, en lugar de aceptar rutas de ejecutables o remotos arbitrarios de los usuarios.
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);
}
}
}
Ambos flujos de salida del proceso hijo se dirigen a una sola tubería. Un hilo virtual independiente
la vacía continuamente y conserva solo los primeros 8 KiB para los mensajes de diagnóstico de la
terminal. Esto permite que el hilo que realiza la llamada imponga el plazo máximo de transferencia,
incluso cuando rclone produce más salida de la que puede contener una tubería. La
documentación de Process de Java
explica el riesgo de bloqueo de la salida de los subprocesos.
El directorio provisional comparte directorio padre con el destino final. Tras una salida de rclone con código cero, Java solicita un movimiento atómico. Si el sistema de archivos no puede realizarlo, la importación falla. Los movimientos atómicos no bloquean las sobrescrituras: otro proceso de escritura puede adelantarse entre la comprobación de existencia y el movimiento, por lo que este ejemplo requiere acceso exclusivo.
Compila e importa el prefijo
Reemplaza /absolute/path/to/rclone con el ejecutable encontrado antes y usa tu bucket y tu prefijo.
Conserva las comillas alrededor de las rutas. Ejecuta los dos comandos juntos para que un fallo de
compilación no pueda iniciar un archivo de clase anterior:
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
Si la operación tiene éxito, el programa imprime Import complete: ./downloaded-files y termina con código cero. Un objeto
como source-files/reports/July résumé.txt se convierte en downloaded-files/reports/July résumé.txt.
copy copia el contenido del prefijo sin añadir
source-files como otro directorio. No elimina los objetos remotos.
El plazo máximo de 600 segundos incluye los reintentos de rclone, por lo que incluso un archivo válido puede superarlo si la conexión es lenta. Al agotarse el tiempo, se termina el proceso hijo directo de rclone, se espera a que finalice y se elimina el directorio provisional de esta invocación; el destino final queda sin crear. Elige un tiempo de espera mayor, de hasta 86.400 segundos, y vuelve a intentarlo con el mismo destino después de que el proceso fallido haya terminado. Se rechaza cualquier destino existente, incluidos los enlaces simbólicos; usa un nombre nuevo para otra importación completa.
Diagnostica una importación fallida
- Salida del proceso hijo: Lee los mensajes de diagnóstico limitados de la terminal y luego verifica el archivo de configuración seleccionado, el ámbito del token para el bucket, el endpoint y el acceso HTTPS saliente. Una solicitud fallida es un error, no una descarga vacía completada correctamente.
- Sin archivos: Un prefijo vacío o inexistente puede copiarse correctamente como un directorio vacío. Confirma el prefijo e inspecciona el resultado antes de usarlo; una salida con código cero no demuestra que existiera un objeto concreto. Mantén un inventario esperado independiente o sumas de verificación del publicador cuando sean necesarios.
- Interrupción: Una aplicación que llame a
importFilespuede interrumpir su hilo de Java en espera; el método detiene el proceso hijo y limpia el directorio provisional antes de propagarInterruptedException. Quien realiza la llamada es responsable de restaurar su indicador de interrupción. Esto es distinto de terminar la JVM. - Terminación abrupta: Una pérdida de energía,
kill -9o la terminación de la JVM pueden dejar directorios.r2-import-*o un proceso hijo huérfano. Detén cualquier proceso hijo restante antes de eliminar únicamente el directorio provisional de esa invocación. Estos directorios contienen trabajo incompleto y nunca son el destino final. La limpieza también puede fallar si cambian los permisos o la disponibilidad del sistema de archivos.
Elige dónde se ejecutan las importaciones
Este enfoque es adecuado para un servicio Java que ya usa rclone y necesita archivos locales antes de procesarlos. Si los archivos deben incorporarse a un flujo de trabajo alojado de Transloadit, consulta Cloudflare Import Robot y el SDK de Java.
