Importar arquivos do Cloudflare R2 em Java com Rclone
Use o ProcessBuilder do Java para executar rclone copy e disponibilize o diretório
baixado somente depois que o rclone terminar com sucesso. O exemplo abaixo importa um prefixo privado
no Cloudflare R2, mantém os nomes dos arquivos em diretórios aninhados e retorna um status de término
que indica falha quando a transferência falha ou excede o prazo.
Preparar o bucket e as ferramentas
Este guia se destina a uma aplicação Linux com OpenJDK 21 e uma configuração regional UTF-8. Foi testado com OpenJDK 21.0.12.1 e rclone 1.75.2. Use uma atualização do seu JDK que ainda receba suporte; a versão de correção testada não é um requisito mínimo intrínseco. O rclone é um executável separado, disponível no guia de instalação. Não são necessárias dependências Java nem uma ferramenta de compilação.
Você precisa de um bucket R2 existente que contenha os arquivos a baixar. Mantenha esse prefixo
inalterado durante a importação: copiar um bucket em alteração não produz uma cópia instantânea de
um único momento. Use chaves que correspondam a caminhos de arquivo distintos no Linux, sem segmentos
de caminho vazios, . ou .., colisões entre arquivos e diretórios
ou nomes que seu sistema de arquivos não possa representar. Há suporte a espaços, caminhos aninhados,
Unicode e arquivos de zero bytes. Chaves de objeto arbitrárias não são necessariamente caminhos
válidos no sistema de arquivos.
Verifique cada executável necessário no shell que iniciará o Java:
command -v java >/dev/null &&
command -v javac >/dev/null &&
command -v rclone >/dev/null &&
java -version && javac -version && rclone version
Os comandos abaixo selecionam C.UTF-8; essa configuração regional deve estar instalada.
Trabalhe em um diretório de sua propriedade com permissão de escrita. O diretório pai do destino deve
existir, permitir renomeações atômicas de diretórios e ter espaço para o download completo. Execute
um importador por vez, com controle exclusivo desse diretório pai.
Configurar um remoto R2 privado
Obtenha o Access Key ID e a Secret Access Key de um token R2 com escopo restrito ao seu bucket e permissão Object Read only. Essa permissão abrange a leitura e a listagem de objetos pela API compatível com S3; o importador não precisa criar nem excluir objetos remotos. Consulte o guia de autenticação do R2 da Cloudflare.
Salve o conteúdo a seguir como r2-import.conf no seu diretório de trabalho usando um editor de
texto. Substitua os três valores de exemplo pelas suas credenciais e pelo ID da sua conta. Este
endpoint se destina a um bucket na jurisdição padrão; use o endpoint específico da jurisdição do seu
bucket quando aplicável.
[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
Restrinja o acesso a esse arquivo:
chmod 600 ./r2-import.conf
O programa Java passa esse arquivo explicitamente com --config, portanto não depende do
local habitual de configuração do rclone. no_check_bucket segue as
orientações de configuração do rclone da Cloudflare para tokens com
permissões de objetos. Não inclua o arquivo em commits nem cole credenciais nos argumentos dos
comandos. As variáveis RCLONE_* herdadas pelo processo filho são removidas para que
configurações como uma simulação global não transformem silenciosamente esta importação em outra
operação.
Salvar o importador Java
Salve este arquivo completo como RcloneImporter.java. Os argumentos são o executável do rclone,
o arquivo de configuração, o prefixo remoto, o novo diretório de destino e o tempo limite da
transferência inteira em segundos. Forneça configurações confiáveis da aplicação, em vez de aceitar
caminhos de executáveis ou remotos arbitrários de usuários.
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 os fluxos de saída do processo filho são direcionados a um único pipe. Uma thread virtual
separada continua drenando esse pipe e retém apenas os primeiros 8 KiB para diagnóstico no terminal.
Isso permite que a thread chamadora imponha o prazo da transferência mesmo quando o rclone produz
mais saída do que o pipe pode comportar. A
documentação de Process do Java explica o risco de
bloqueio da saída de subprocessos.
O diretório temporário fica no mesmo diretório pai do destino final. Depois que o rclone termina com código zero, o Java solicita uma movimentação atômica. Se o sistema de arquivos não puder realizá-la, a importação falha. Movimentações atômicas não são uma trava contra sobrescrita: outro processo de escrita pode agir entre a verificação de existência e a movimentação, por isso este exemplo exige acesso exclusivo.
Compilar e importar o prefixo
Substitua /absolute/path/to/rclone pelo executável encontrado anteriormente e use seu bucket e prefixo.
Mantenha as aspas ao redor dos caminhos. Execute os dois comandos juntos para que uma falha de
compilação não possa iniciar um arquivo de classe antigo:
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
Em caso de sucesso, o programa imprime Import complete: ./downloaded-files e termina com código zero. Um objeto como
source-files/reports/July résumé.txt passa a ser downloaded-files/reports/July résumé.txt.
copy copia o conteúdo do prefixo, sem adicionar
source-files como outro diretório. Os objetos remotos não são removidos.
O prazo de 600 segundos inclui as novas tentativas do rclone, portanto até um arquivo válido pode excedê-lo em uma conexão lenta. Ao atingir o tempo limite, o programa encerra o processo filho direto do rclone, aguarda seu término, remove o diretório temporário desta execução e mantém o destino final inexistente. Escolha um tempo limite maior, de até 86.400 segundos, e tente novamente com o mesmo destino depois que o processo que falhou tiver terminado. Um destino existente, inclusive um link simbólico, é recusado; use um novo nome para outra importação concluída.
Diagnosticar uma importação malsucedida
- Término do processo filho: Leia as informações de diagnóstico do terminal, limitadas em tamanho, e verifique o arquivo de configuração selecionado, o escopo do token para o bucket, o endpoint e o acesso HTTPS de saída. Uma requisição malsucedida é um erro, não um download vazio bem-sucedido.
- Nenhum arquivo: Um prefixo vazio ou inexistente pode ser copiado com sucesso como um diretório vazio. Confirme o prefixo e inspecione o resultado antes de usá-lo; o término com código zero não comprova que um objeto específico existia. Mantenha um inventário independente do conteúdo esperado ou somas de verificação fornecidas por quem publicou os arquivos quando forem necessários.
- Interrupção: Uma aplicação que chama
importFilespode interromper sua thread Java em espera; o método encerra o processo filho e limpa o diretório temporário antes de propagarInterruptedException. Cabe ao chamador restaurar seu sinalizador de interrupção. Isso é diferente de encerrar a JVM. - Término abrupto: Uma queda de energia,
kill -9e o término da JVM podem deixar diretórios.r2-import-*ou um processo filho órfão. Encerre qualquer processo filho restante antes de remover apenas o diretório temporário pertencente àquela execução. Esses diretórios contêm trabalho incompleto, nunca o destino final. A limpeza também pode falhar se as permissões ou a disponibilidade do sistema de arquivos mudarem.
Escolher onde executar as importações
Esta abordagem é adequada para um serviço Java que já usa rclone e precisa dos arquivos locais antes de processá-los. Se, em vez disso, os arquivos precisarem entrar em um fluxo de trabalho hospedado na Transloadit, consulte o Cloudflare Import Robot e o SDK para Java.
