commit 881ec1c0ecfa7deb2f467b3b60fe7509282eb1d8 Author: Leon Hoppe Date: Sat Jan 17 17:41:39 2026 +0100 Finished backup service draft diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.OneDriveBackupService/.idea/.gitignore b/.idea/.idea.OneDriveBackupService/.idea/.gitignore new file mode 100644 index 0000000..5c5cd00 --- /dev/null +++ b/.idea/.idea.OneDriveBackupService/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/projectSettingsUpdater.xml +/contentModel.xml +/.idea.OneDriveBackupService.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/.idea.OneDriveBackupService/.idea/encodings.xml b/.idea/.idea.OneDriveBackupService/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.OneDriveBackupService/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.OneDriveBackupService/.idea/indexLayout.xml b/.idea/.idea.OneDriveBackupService/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.OneDriveBackupService/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ConfigData.cs b/ConfigData.cs new file mode 100644 index 0000000..3df3f80 --- /dev/null +++ b/ConfigData.cs @@ -0,0 +1,27 @@ +namespace OneDriveBackupService; + +public sealed class ConfigData { + public string Schedule { get; } + public string BackupUploadRoot { get; } + public string LocalRoot { get; } + public string IncludeFile { get; } + public int KeepLast { get; } + + public string TenantId { get; } + public string ClientId { get; } + public string ClientSecret { get; } + public string UserId { get; } + + public ConfigData(IConfiguration config) { + Schedule = config["Schedule"]!; + BackupUploadRoot = config["UploadRoot"]!; + LocalRoot = config["LocalRoot"]!; + IncludeFile = config["IncludeFile"]!; + KeepLast = int.Parse(config["KeepLast"]!); + + TenantId = config["TenantId"]!; + ClientId = config["ClientId"]!; + ClientSecret = config["ClientSecret"]!; + UserId = config["UserId"]!; + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6432bff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +RUN apt-get update && apt-get install -y tar && rm /var/lib/apt/lists/* +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["OneDriveBackupService.csproj", "./"] +RUN dotnet restore "OneDriveBackupService.csproj" +COPY . . +WORKDIR "/src/" +RUN dotnet build "./OneDriveBackupService.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./OneDriveBackupService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "OneDriveBackupService.dll"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b4449c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Leon Hoppe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/OneDriveBackupService.csproj b/OneDriveBackupService.csproj new file mode 100644 index 0000000..b696821 --- /dev/null +++ b/OneDriveBackupService.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + dotnet-OneDriveBackupService-890cf0cf-6a2a-4470-ba77-b6237cf7bd2c + Linux + + + + + + + + + diff --git a/OneDriveBackupService.slnx b/OneDriveBackupService.slnx new file mode 100644 index 0000000..25fbef1 --- /dev/null +++ b/OneDriveBackupService.slnx @@ -0,0 +1,3 @@ + + + diff --git a/OneDriveClient.cs b/OneDriveClient.cs new file mode 100644 index 0000000..3e2d3ca --- /dev/null +++ b/OneDriveClient.cs @@ -0,0 +1,76 @@ +using Azure.Identity; +using Microsoft.Graph; +using Microsoft.Graph.Drives.Item.Items.Item.CreateUploadSession; +using Microsoft.Graph.Models; + +namespace OneDriveBackupService; + +public class OneDriveClient { + + private readonly ConfigData _config; + private readonly GraphServiceClient _client; + + public OneDriveClient(ConfigData config) { + _config = config; + + var credential = new ClientSecretCredential(_config.TenantId, _config.ClientId, _config.ClientSecret); + _client = new GraphServiceClient(credential); + } + + public async Task> UploadFile(string filePath, CancellationToken token) { + var fileName = Path.GetFileName(filePath); + var remoteFilePath = _config.BackupUploadRoot.Trim('/') + '/' + fileName; + + var defaultDrive = await _client.Users[_config.UserId].Drive.GetAsync(cancellationToken: token); + + var driveFile = _client.Drives[defaultDrive!.Id].Items[$"root:/{remoteFilePath}:"]!; + var uploadSession = await driveFile.CreateUploadSession.PostAsync(new CreateUploadSessionPostRequestBody { + Item = new DriveItemUploadableProperties { + AdditionalData = new Dictionary { + { "@microsoft.graph.conflictBehavior", "replace" } + } + } + }, cancellationToken: token); + + await using var stream = File.OpenRead(filePath); + var uploader = new LargeFileUploadTask(uploadSession, stream); + var result = await uploader.UploadAsync(cancellationToken: token); + + return result; + } + + public async Task DeleteOldFiles(CancellationToken token) { + var defaultDrive = await _client.Users[_config.UserId].Drive.GetAsync(cancellationToken: token); + + var remoteFolder = _config.BackupUploadRoot.Trim('/'); + var backupFiles = await _client.Drives[defaultDrive!.Id] + .Items[$"root:/{remoteFolder}:"] + .Children.GetAsync(cancellationToken: token); + + if (backupFiles == null || backupFiles.Value == null || backupFiles.Value.Count == 0) + return 0; + + var sortedFiles = backupFiles.Value + .Where(i => + i.File != null && + i.Name != null && + i.Name.StartsWith("backup_") && + i.Name.EndsWith(".tar.gz")) + .OrderByDescending(i => i.CreatedDateTime) + .ToList(); + + if (sortedFiles.Count < _config.KeepLast) + return 0; + + int deletedCount = 0; + foreach (var backupFile in sortedFiles.Skip(_config.KeepLast)) { + if (backupFile.Id == null) continue; + + await _client.Drives[defaultDrive.Id].Items[backupFile.Id].DeleteAsync(cancellationToken: token); + deletedCount++; + } + + return deletedCount; + } + +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..b565dd5 --- /dev/null +++ b/Program.cs @@ -0,0 +1,13 @@ +using OneDriveBackupService; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Configuration.AddEnvironmentVariables(); + +builder.Services.AddSingleton(new ConfigData(builder.Configuration)); +builder.Services.AddTransient(); + +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..0e216b4 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "OneDriveBackupService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..55e2901 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# OneDrive Backup Service + +Ein .NET 10 Worker-Service zum automatischen Erstellen und Hochladen von Backups auf OneDrive. +Unterstützt Cron-basierte Backups sowie manuelles Triggern über `docker exec`. + +--- + +## Inhaltsverzeichnis + +- [Features](#features) +- [Manuelles Backup](#manuelles-backup) +- [Umgebungsvariablen](#umgebungsvariablen) + +--- + +## Features + +- Automatische Backups nach Cron-Schedule (`appsettings.json` / ENV) +- Upload zu OneDrive über Microsoft Graph SDK +- Behalten nur der letzten N Backups (`KeepLast`) +- Unterstützung für manuelles Backup via `docker exec` +- Lokale Zeitzone für Logs und Backup-Zeitstempel +- Flexible Konfiguration über ENV oder `appsettings.json` + +--- + +## Manuelles Backup + +Um ein Backup manuell auf einem laufenden Container auszuführen: + +```bash +docker exec -it onedrive-backup-server dotnet OneDriveBackupService.dll --run-once +``` + +* Das löst **ein sofortiges Backup** aus +* Cron-Loop des Hauptcontainers bleibt ungestört +* Backup-Dateien werden wie üblich nach OneDrive hochgeladen + +--- + +## Umgebungsvariablen + +| Variable | Beschreibung | +|----------------|-------------------------------------------------------------| +| `Schedule` | Cron-Expression für automatische Backups | +| `UploadRoot` | OneDrive Ordner für Backups | +| `LocalRoot` | Lokaler Datenpfad für Backups | +| `IncludeFile` | Textdatei mit allen unterordnern, die mit ins Backup sollen | +| `KeepLast` | Anzahl zu behaltender Backups | +| `TenantId` | Azure TenantId | +| `ClientId` | Azure ClientId | +| `ClientSecret` | Azure Client Secret | +| `UserId` | OneDrive User Id | +| `TZ` | Zeitzone für Logs / DateTime.Now (optional) | + +--- + +## Hinweise + +* Die Backup-Dateien erhalten eindeutige Namen mit Timestamp: `backup_YYYYMMDD_HHMMSS.tar.gz` +* Alte Backups werden automatisch gelöscht, basierend auf `KeepLast` +* `docker exec --run-once` löst ein manuelles Backup aus, ohne den Cron-Loop zu stoppen diff --git a/Worker.cs b/Worker.cs new file mode 100644 index 0000000..d59f83f --- /dev/null +++ b/Worker.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using Cronos; + +namespace OneDriveBackupService; + +public class Worker(ILogger logger, ConfigData config, OneDriveClient client) : BackgroundService { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + if (Environment.GetCommandLineArgs().Contains("--run-once")) { + logger.LogInformation("Manual backup triggered"); + await RunBackup(DateTime.Now, stoppingToken); + return; + } + + var expression = CronExpression.Parse(config.Schedule); + + while (!stoppingToken.IsCancellationRequested) { + var utcNow = DateTime.UtcNow; + + var cronTime = expression.GetNextOccurrence(utcNow); + var delay = cronTime.GetValueOrDefault(utcNow) - utcNow; + + if (delay < TimeSpan.Zero) + delay = TimeSpan.Zero; + + if (!cronTime.HasValue) { + logger.LogError("Cron expression falied, falling back to default delay"); + delay = TimeSpan.FromHours(12); + } + + var nextRun = DateTime.Now + delay; + logger.LogInformation("Next backup run: {time}", nextRun.ToString("f")); + await Task.Delay(delay, stoppingToken); + + await RunBackup(nextRun, stoppingToken); + } + } + + private async Task RunBackup(DateTime optimalTime, CancellationToken stoppingToken) { + logger.LogInformation("Starting backup at {now}", DateTime.Now.ToString("f")); + + var file = await CreateBackupArchive(optimalTime, stoppingToken); + if (string.IsNullOrEmpty(file)) { + logger.LogError("Backup archive creation failed"); + return; + } + + logger.LogInformation("Backup archive created, starting upload..."); + + var uploadResult = await client.UploadFile(file, stoppingToken); + if (!uploadResult.UploadSucceeded) { + logger.LogError("Upload failed"); + File.Delete(file); + return; + } + + logger.LogInformation("Upload completed"); + + File.Delete(file); + var count = await client.DeleteOldFiles(stoppingToken); + logger.LogInformation("Deleted {count} old backups", count); + + logger.LogInformation("Backup completed"); + } + + private async Task CreateBackupArchive(DateTime optimalTime, CancellationToken stoppingToken) { + var timestamp = optimalTime.ToString("yyyyMMdd_HHmmss"); + var output = $"/tmp/backup_{timestamp}.tar.gz"; + + var process = new Process { + StartInfo = new ProcessStartInfo { + FileName = "tar", + ArgumentList = { + "-czf", + output, + "-C", + config.LocalRoot, + "-T", + Path.GetRelativePath(config.LocalRoot, config.IncludeFile) + }, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + + process.Start(); + await process.WaitForExitAsync(stoppingToken); + + if (process.ExitCode != 0) { + return string.Empty; + } + + return output; + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..a8d87a7 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + + "Schedule": "0 * * * *", + "UploadRoot": "Backups", + "LocalRoot": "/backups", + "IncludeFile": "/backups/include.txt", + "KeepLast": 30, + + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "UserId": "" +}