From 9156498027a0a01b81da618d606ba80c66596304 Mon Sep 17 00:00:00 2001 From: Cavin Date: Sun, 8 Feb 2026 20:55:50 +0300 Subject: [PATCH] fix: reject empty directories and add CI Add validation to prevent backing up empty directories. Includes GitHub Actions workflow and added README. --- .github/workflows/ci.yaml | 42 ++++ README.md | 220 +++++++++++++++++- src/main/java/io/github/devcavin/App.java | 17 +- .../domain/valueobject/SourcePath.java | 16 +- .../archive/local/LocalArchiveCreator.java | 19 +- .../memory/InMemoryArchiveRepository.java | 1 + .../scheduler/ScheduledBackupService.java | 50 ++-- 7 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c4844ad --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,42 @@ +name: Simple Backup Tool CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Build + run: mvn clean package + + - name: Create test data + run: | + mkdir -p source dest + echo "test" > source/file.txt + + - name: Run backup + run: java -jar target/backup-tool-1.0-SNAPSHOT.jar source dest + + - name: Verify + run: | + if [ -f dest/*.zip ]; then + echo "Backup created" + ls -lh dest/ + else + echo "Failed" + exit 1 + fi \ No newline at end of file diff --git a/README.md b/README.md index d5d13e2..a4fb38b 100644 --- a/README.md +++ b/README.md @@ -1 +1,219 @@ -# Backup Tool \ No newline at end of file +# Backup Tool + +A Java-based automated backup tool that creates compressed ZIP archives of directories with support for both one-time and scheduled backups. + +## Features + +- Create compressed ZIP backups of directories +- One-time backup execution +- Scheduled automatic backups (cron-like) +- Command-line interface +- Easy-to-use startup scripts +- Clean Domain-Driven Design architecture + +## Prerequisites + +- Java 21 or higher +- Maven 3.6 or higher + +## Building the Project + +```bash +mvn clean package +``` + +This will create an executable JAR file at `target/backup-tool-1.0-SNAPSHOT.jar` + +## Usage + +### Option 1: Using Startup Scripts (Recommended) + +#### Linux/Mac + +```bash +# Make the script executable +chmod +x backup.sh + +# One-time backup +./backup.sh /path/to/source /path/to/destination + +# Scheduled backup (every 5 minutes for testing) +./backup.sh /path/to/source /path/to/destination 5 + +# Or edit the script to set default values and run +./backup.sh +``` + +#### Windows + +```cmd +# One-time backup +backup.bat C:\path\to\source C:\path\to\destination + +# Scheduled backup (every 5 minutes for testing) +backup.bat C:\path\to\source C:\path\to\destination 5 + +# Or edit the script to set default values and run +backup.bat +``` + +### Option 2: Direct JAR Execution + +```bash +# One-time backup +java -jar target/backup-tool-1.0-SNAPSHOT.jar /path/to/source /path/to/destination + +# Scheduled backup (every 5 minutes) +java -jar target/backup-tool-1.0-SNAPSHOT.jar /path/to/source /path/to/destination 5 +``` + +## Arguments + +1. **source** (required) - The directory you want to backup +2. **destination** (required) - The directory where backups will be stored +3. **interval_minutes** (optional) - If provided, runs backup every N minutes. If omitted, runs once and exits. + +## Examples + +### One-time Backup + +```bash +# Backup your Documents folder to a Backups directory +java -jar target/backup-tool-1.0-SNAPSHOT.jar ~/Documents ~/Backups +``` + +### Scheduled Backup (Testing - Every 5 Minutes) + +```bash +# Run backup every 5 minutes (good for testing) +java -jar target/backup-tool-1.0-SNAPSHOT.jar ~/Documents ~/Backups 5 +``` + +### Scheduled Backup (Production - Every Hour) + +```bash +# Run backup every 60 minutes +java -jar target/backup-tool-1.0-SNAPSHOT.jar ~/Documents ~/Backups 60 +``` + +### Scheduled Backup (Daily) + +```bash +# Run backup every 24 hours (1440 minutes) +java -jar target/backup-tool-1.0-SNAPSHOT.jar ~/Documents ~/Backups 1440 +``` + +## How It Works + +1. The tool creates a ZIP archive of the source directory +2. The archive is stored in the destination directory with a timestamp +3. Format: `backup-.zip` +4. For scheduled mode, the process runs in the background at the specified interval +5. Press Ctrl+C to stop the scheduled backup service + +## Project Structure + +``` +backup-tool/ +├── src/main/java/io/github/devcavin/ +│ ├── App.java # Main application entry point +│ ├── application/usecase/ # Use case layer +│ │ ├── CreateBackupJobUseCase.java + ├── ListArchiveUseCase.java +│ │ ├── RunBackupJobUseCase.java +│ │ └── ListBackupJobsUseCase.java +│ ├── domain/ # Domain layer +│ │ ├── entity/ +│ │ │ ├── BackupJob.java +│ │ │ └── Archive.java +│ │ ├── repository/ +│ │ │ ├── BackupJobRepository.java +│ │ │ └── ArchiveRepository.java +│ │ ├── service/ +│ │ │ ├── BackupService.java +│ │ │ ├── ArchiveCreator.java +│ │ │ └── ArchiveStorage.java +│ │ ├── valueobject/ +│ │ │ ├── BackupName.java +│ │ │ ├── SourcePath.java +│ │ │ └── DestinationPath.java +│ │ └── enums/ +│ │ └── BackupStatus.java +│ └── infrastructure/ # Infrastructure layer +│ ├── archive/local/ +│ │ ├── LocalArchiveCreator.java +│ │ └── LocalArchiveStorage.java +│ ├── repository/memory/ +│ │ ├── InMemoryBackupJobRepository.java +│ │ └── InMemoryArchiveRepository.java +│ └── scheduler/ +│ └── ScheduledBackupService.java +├── backup.sh # Linux/Mac startup script +├── backup.bat # Windows startup script +├── pom.xml +└── README.md +``` + +## Customizing Defaults + +Edit the startup scripts to set your preferred default values: + +**backup.sh** (Linux/Mac): +```bash +DEFAULT_SOURCE="/home/user/documents" +DEFAULT_DESTINATION="/home/user/backups" +DEFAULT_INTERVAL="" # or set to number for scheduled +``` + +**backup.bat** (Windows): +```cmd +set DEFAULT_SOURCE=C:\Users\%USERNAME%\Documents +set DEFAULT_DESTINATION=C:\Users\%USERNAME%\Backups +set DEFAULT_INTERVAL= +``` + +## Running as a Background Service + +### Linux (systemd) + +Create a service file `/etc/systemd/system/backup-tool.service`: + +```ini +[Unit] +Description=Automated Backup Service +After=network.target + +[Service] +Type=simple +User=yourusername +WorkingDirectory=/path/to/backup-tool +ExecStart=/usr/bin/java -jar /path/to/backup-tool/target/backup-tool-1.0-SNAPSHOT.jar /source /destination 60 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Then: +```bash +sudo systemctl daemon-reload +sudo systemctl enable backup-tool +sudo systemctl start backup-tool +``` + +### Windows (Task Scheduler) + +1. Open Task Scheduler +2. Create Basic Task +3. Set trigger to "When the computer starts" +4. Action: Start a program +5. Program: `javaw.exe` +6. Arguments: `-jar C:\path\to\backup-tool.jar C:\source C:\destination 60` + +## License + +MIT License + +## Contributing + +Pull requests are welcome! \ No newline at end of file diff --git a/src/main/java/io/github/devcavin/App.java b/src/main/java/io/github/devcavin/App.java index b78852b..b3c06f8 100644 --- a/src/main/java/io/github/devcavin/App.java +++ b/src/main/java/io/github/devcavin/App.java @@ -51,8 +51,20 @@ public static void main(String[] args) { System.out.println("Source: " + sourcePath); System.out.println("Destination: " + destinationPath); - UUID jobId = createJob.execute("automated-backup", sourcePath, destinationPath); - System.out.println("Backup job created with ID: " + jobId); + UUID jobId; + try { + jobId = createJob.execute("automated-backup", sourcePath, destinationPath); + System.out.println("Backup job created with ID: " + jobId); + } catch (IllegalArgumentException e) { + System.err.println("\n=== ERROR ==="); + System.err.println(e.getMessage()); + System.err.println("\nPlease check:"); + System.err.println(" - Source directory exists and is readable"); + System.err.println(" - Source directory is not empty"); + System.err.println(" - Destination directory is writable"); + System.exit(1); + return; // Never reached, but keeps compiler happy + } if (intervalMinutes > 0) { // Scheduled mode @@ -63,7 +75,6 @@ public static void main(String[] args) { ScheduledBackupService scheduler = new ScheduledBackupService(runJob); scheduler.scheduleBackup(jobId, intervalMinutes); - // Add shutdown hook to clean up gracefully Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("\nShutdown signal received..."); scheduler.shutdown(); diff --git a/src/main/java/io/github/devcavin/domain/valueobject/SourcePath.java b/src/main/java/io/github/devcavin/domain/valueobject/SourcePath.java index 96d8539..19c2dae 100644 --- a/src/main/java/io/github/devcavin/domain/valueobject/SourcePath.java +++ b/src/main/java/io/github/devcavin/domain/valueobject/SourcePath.java @@ -1,9 +1,11 @@ package io.github.devcavin.domain.valueobject; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Objects; +import java.util.stream.Stream; public class SourcePath { private final Path path; @@ -27,9 +29,21 @@ public SourcePath(String rawPath) { throw new IllegalArgumentException("Source path is not a directory: %s".formatted(resolvePath)); } + if (isDirectoryEmpty(resolvePath)) { + throw new IllegalArgumentException("Source directory is empty: %s".formatted(resolvePath)); + } + this.path = resolvePath; } + private boolean isDirectoryEmpty(Path directory) { + try (Stream entries = Files.list(directory)) { + return entries.findFirst().isEmpty(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to read source directory: %s".formatted(directory), e); + } + } + public Path getPath() { return path; } @@ -58,4 +72,4 @@ public int hashCode() { public String toString() { return path.toString(); } -} +} \ No newline at end of file diff --git a/src/main/java/io/github/devcavin/infrastructure/archive/local/LocalArchiveCreator.java b/src/main/java/io/github/devcavin/infrastructure/archive/local/LocalArchiveCreator.java index 9f3aa6d..9d704c9 100644 --- a/src/main/java/io/github/devcavin/infrastructure/archive/local/LocalArchiveCreator.java +++ b/src/main/java/io/github/devcavin/infrastructure/archive/local/LocalArchiveCreator.java @@ -7,6 +7,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -19,6 +20,7 @@ public Archive createArchive(BackupJob job) { try { Path tempFile = Files.createTempFile("backup-", ".zip"); + AtomicInteger fileCount = new AtomicInteger(0); try (Stream paths = Files.walk(source); ZipOutputStream zos = @@ -26,11 +28,26 @@ public Archive createArchive(BackupJob job) { paths .filter(Files::isRegularFile) - .forEach(path -> addToZip(source, path, zos)); + .forEach(path -> { + addToZip(source, path, zos); + fileCount.incrementAndGet(); + }); + } + + if (fileCount.get() == 0) { + Files.deleteIfExists(tempFile); + throw new IllegalStateException( + "No files found to backup in source directory: " + source + ); } long size = Files.size(tempFile); + System.out.printf("Archive created: %d file(s), size: %.2f MB%n", + fileCount.get(), + size / (1024.0 * 1024.0) + ); + return new Archive( job.getId(), tempFile, diff --git a/src/main/java/io/github/devcavin/infrastructure/repository/memory/InMemoryArchiveRepository.java b/src/main/java/io/github/devcavin/infrastructure/repository/memory/InMemoryArchiveRepository.java index 2c18beb..866dc5a 100644 --- a/src/main/java/io/github/devcavin/infrastructure/repository/memory/InMemoryArchiveRepository.java +++ b/src/main/java/io/github/devcavin/infrastructure/repository/memory/InMemoryArchiveRepository.java @@ -9,6 +9,7 @@ public class InMemoryArchiveRepository implements ArchiveRepository { private final Map store = new ConcurrentHashMap<>(); + @Override public void save(Archive archive) { store.put(archive.getId(), archive); diff --git a/src/main/java/io/github/devcavin/infrastructure/scheduler/ScheduledBackupService.java b/src/main/java/io/github/devcavin/infrastructure/scheduler/ScheduledBackupService.java index 43af3a1..1cc93f2 100644 --- a/src/main/java/io/github/devcavin/infrastructure/scheduler/ScheduledBackupService.java +++ b/src/main/java/io/github/devcavin/infrastructure/scheduler/ScheduledBackupService.java @@ -9,8 +9,7 @@ public class ScheduledBackupService { private final ScheduledExecutorService scheduler; - private final RunBackupJobUseCase runBackupJobUseCase; - + private final RunBackupJobUseCase runBackupJobUseCase; public ScheduledBackupService(RunBackupJobUseCase runBackupJobUseCase) { this.scheduler = Executors.newScheduledThreadPool(1); @@ -19,35 +18,41 @@ public ScheduledBackupService(RunBackupJobUseCase runBackupJobUseCase) { /** * Schedule a backup job to run at fixed intervals - * @param jobId The backup job id to run - * @param intervalMinutes How often to run the job (in minutes) + * @param jobId The backup job ID to run + * @param intervalMinutes How often to run the backup (in minutes) */ - public void scheduleBackup(UUID jobId, long intervalMinutes) { - System.out.printf("Scheduling backup job with id: %s to run every %d minutes\n", jobId, intervalMinutes); + System.out.println("Scheduling backup job " + jobId + " to run every " + intervalMinutes + " minutes"); - scheduler.scheduleAtFixedRate(() -> { - try { - System.out.printf("Running the scheduled backup job with id: %s\n", jobId); - runBackupJobUseCase.execute(jobId); - System.out.println("Scheduled backup completed successfully"); - } catch (Exception e) { - System.err.printf("Scheduled backup job with id %s failed", jobId); - e.printStackTrace(); // will add logging later - } - }, - 0, // delay 0 which run immediately + scheduler.scheduleAtFixedRate( + () -> { + try { + System.out.println("\n" + "=".repeat(50)); + System.out.println("Running scheduled backup: " + jobId); + System.out.println("Time: " + java.time.Instant.now()); + System.out.println("=".repeat(50)); + runBackupJobUseCase.execute(jobId); + System.out.println("Scheduled backup completed successfully"); + } catch (IllegalStateException e) { + System.err.println("Backup validation failed: " + e.getMessage()); + System.err.println("This backup will be skipped. Next run in " + intervalMinutes + " minutes."); + } catch (Exception e) { + System.err.println("Scheduled backup failed: " + e.getMessage()); + e.printStackTrace(); + } + }, + 0, // Initial delay (0 = run immediately) intervalMinutes, - TimeUnit.MINUTES); + TimeUnit.MINUTES + ); } /** - * Shutting down the scheduler gracefully + * Shutdown the scheduler gracefully */ public void shutdown() { - System.out.println("Shutting down the scheduler..."); + System.out.println("Shutting down backup scheduler..."); scheduler.shutdown(); - try { if (!scheduler.awaitTermination(60, TimeUnit.SECONDS)) { scheduler.shutdownNow(); @@ -55,7 +60,6 @@ public void shutdown() { } catch (InterruptedException e) { scheduler.shutdownNow(); Thread.currentThread().interrupt(); - // throw new RuntimeException(e); } } -} +} \ No newline at end of file