This PR updates the RPM spec file to mark the config files as such. This ensures that files will not be replaced during updates if they are modified. Also updated the description slightly to not mention now-defunct services in the description. Also applied this to Debian packages. The agent based installation has been updated slightly to better differentiate it from the the CLI/GUI package. Fixed a bug that prevented the preset file from being installed for the agent. This fixes #6099
1254 lines
59 KiB
C#
1254 lines
59 KiB
C#
// Copyright (C) 2025, The Duplicati Team
|
|
// https://duplicati.com, hello@duplicati.com
|
|
//
|
|
// 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.
|
|
using System.Globalization;
|
|
using System.IO.Compression;
|
|
using System.Text;
|
|
|
|
namespace ReleaseBuilder.Build;
|
|
|
|
public static partial class Command
|
|
{
|
|
/// <summary>
|
|
/// Implementations for the package builds
|
|
/// </summary>
|
|
private static class CreatePackage
|
|
{
|
|
/// <summary>
|
|
/// Representation of a build package
|
|
/// </summary>
|
|
/// <param name="Target">The target package</param>
|
|
/// <param name="CreatedFile">The created package file path</param>
|
|
public record BuiltPackage(PackageTarget Target, string CreatedFile);
|
|
|
|
/// <summary>
|
|
/// Builds the packages for the specified build targets.
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="buildTargets">The build targets.</param>
|
|
/// <param name="keepBuilds">A flag indicating whether to keep the build files.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
public static async Task<List<BuiltPackage>> BuildPackages(string baseDir, string buildRoot, IEnumerable<PackageTarget> buildTargets, bool keepBuilds, RuntimeConfig rtcfg)
|
|
{
|
|
var builtPackages = new List<BuiltPackage>();
|
|
|
|
var packagesToBuild = buildTargets.Distinct().ToList();
|
|
if (packagesToBuild.Count == 1)
|
|
Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}");
|
|
else
|
|
Console.WriteLine($"Building {packagesToBuild.Count} packages");
|
|
|
|
// Build the packages, but skip Docker builds as they are bundled
|
|
foreach (var target in packagesToBuild.Where(x => x.Package != PackageType.Docker))
|
|
{
|
|
Console.WriteLine($"Building {target.PackageTargetString} ...");
|
|
builtPackages.Add(new BuiltPackage(target, await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds)));
|
|
Console.WriteLine("Completed!");
|
|
}
|
|
|
|
// Build the Docker images with buildx for multi-arch support
|
|
var dockerTargets = packagesToBuild.Where(x => x.Package == PackageType.Docker).ToList();
|
|
if (dockerTargets.Count > 0)
|
|
{
|
|
var packageFolder = Path.Combine(buildRoot, "packages");
|
|
if (!Directory.Exists(packageFolder))
|
|
Directory.CreateDirectory(packageFolder);
|
|
|
|
|
|
var packageFiles = dockerTargets.Select(x => Path.Combine(packageFolder, $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{x.PackageTargetString}"))
|
|
.ToList();
|
|
|
|
if (packageFiles.All(File.Exists))
|
|
{
|
|
Console.WriteLine("All docker images already exist, skipping Docker build");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"Building {dockerTargets.Count} Docker images ...");
|
|
await BuildDockerImages(baseDir, buildRoot, dockerTargets, rtcfg);
|
|
|
|
// Create the files
|
|
foreach (var f in packageFiles)
|
|
File.WriteAllText(f, "");
|
|
}
|
|
}
|
|
|
|
return builtPackages;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the package for the given target
|
|
/// </summary>
|
|
/// <param name="baseDir">The source folder base</param>
|
|
/// <param name="releaseInfo">The release info to use</param>
|
|
/// <param name="rtcfg">The runtime configuration</param>
|
|
/// <param name="keepBuilds">A flag that allows re-using existing builds</param>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
static async Task<string> BuildPackage(string baseDir, string buildRoot, PackageTarget target, RuntimeConfig rtcfg, bool keepBuilds)
|
|
{
|
|
var packageFolder = Path.Combine(buildRoot, "packages");
|
|
if (!Directory.Exists(packageFolder))
|
|
Directory.CreateDirectory(packageFolder);
|
|
|
|
var packageFile = Path.Combine(packageFolder, $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
|
|
|
|
// Fix up non-conforming package names
|
|
// Temporary disable
|
|
// if (target.Package == PackageType.Deb)
|
|
// packageFile = Path.Combine(packageFolder, $"duplicati-{target.InterfaceString}-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.deb");
|
|
// if (target.Package == PackageType.RPM)
|
|
// packageFile = Path.Combine(packageFolder, $"duplicati-{target.InterfaceString}-{rtcfg.ReleaseInfo.Version}_{target.ArchString}.rpm");
|
|
|
|
if (File.Exists(packageFile))
|
|
{
|
|
if (keepBuilds)
|
|
{
|
|
Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}");
|
|
return packageFile;
|
|
}
|
|
|
|
File.Delete(packageFile);
|
|
}
|
|
|
|
var tempFile = Path.Combine(packageFolder, $"tmp-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
|
|
if (File.Exists(tempFile))
|
|
File.Delete(tempFile);
|
|
|
|
switch (target.Package)
|
|
{
|
|
case PackageType.Zip:
|
|
await BuildZipPackage(Path.Combine(buildRoot, target.BuildTargetString), $"duplicati-{rtcfg.ReleaseInfo.ReleaseName}-{target.BuildTargetString}", tempFile, target, rtcfg);
|
|
break;
|
|
|
|
case PackageType.MSI:
|
|
await BuildMsiPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
break;
|
|
|
|
case PackageType.DMG:
|
|
await BuildMacDmgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
break;
|
|
|
|
case PackageType.MacPkg:
|
|
if (target.Interface != InterfaceType.GUI)
|
|
await BuildMacNonAppPkgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
else
|
|
await BuildMacAppPkgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
break;
|
|
|
|
case PackageType.Deb:
|
|
await BuildDebPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
break;
|
|
|
|
case PackageType.RPM:
|
|
await BuildRpmPackage(baseDir, buildRoot, tempFile, target, rtcfg);
|
|
break;
|
|
|
|
// case PackageType.SynologySpk:
|
|
// await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
|
|
// await SignSynologyPackage(Path.Combine(outputFolder, target.PackageTargetString), rtcfg);
|
|
// break;
|
|
|
|
default:
|
|
throw new Exception($"Unsupported package type: {target.Package}");
|
|
}
|
|
|
|
if (rtcfg.UseNotarizeSigning && (target.Package == PackageType.DMG || target.Package == PackageType.MacPkg))
|
|
{
|
|
// # Notarize and staple takes a while...
|
|
Console.WriteLine($"Performing notarize and staple of {packageFile} ...");
|
|
await ProcessHelper.Execute(["xcrun", "notarytool", "submit", tempFile, "--keychain-profile", rtcfg.Configuration.ConfigFiles.NotarizeProfile, "--wait"]);
|
|
await ProcessHelper.Execute(["xcrun", "stapler", "staple", tempFile]);
|
|
}
|
|
|
|
|
|
File.Move(tempFile, packageFile);
|
|
|
|
return packageFile;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a ZIP package asynchronously.
|
|
/// </summary>
|
|
/// <param name="buildRoot">The output folder where the ZIP package will be created.</param>
|
|
/// <param name="dirName">The directory name to use as the root ZIP name.</param>
|
|
/// <param name="zipFile">The ZIP file to generate.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
static async Task BuildZipPackage(string buildRoot, string dirName, string zipFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
if (File.Exists(zipFile))
|
|
File.Delete(zipFile);
|
|
|
|
var executableExtensions = new HashSet<string>([".sh", ".bat", ".py", ".exe"], StringComparer.OrdinalIgnoreCase);
|
|
var executables = new List<string>();
|
|
|
|
using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create))
|
|
{
|
|
foreach (var f in Directory.EnumerateFiles(buildRoot, "*", SearchOption.AllDirectories))
|
|
{
|
|
var relpath = Path.GetRelativePath(buildRoot, f);
|
|
var isRenamedExecutable = ExecutableRenames.ContainsKey(relpath);
|
|
|
|
// Use more friendly names for executables on non-Windows platforms
|
|
if (target.OS != OSType.Windows && isRenamedExecutable)
|
|
relpath = ExecutableRenames[relpath];
|
|
|
|
var entry = zip.CreateEntry(Path.Combine(dirName, relpath), CompressionLevel.Optimal);
|
|
|
|
var isExecutable = isRenamedExecutable || executableExtensions.Contains(Path.GetExtension(f));
|
|
|
|
// Set execute/permission flags
|
|
entry.ExternalAttributes = isExecutable
|
|
? Convert.ToInt32("755", 8) << 16
|
|
: Convert.ToInt32("644", 8) << 16;
|
|
|
|
if (isExecutable)
|
|
executables.Add(relpath);
|
|
|
|
using (var stream = entry.Open())
|
|
using (var file = File.OpenRead(f))
|
|
await file.CopyToAsync(stream);
|
|
}
|
|
|
|
// Write the package type identifier
|
|
using (var stream = zip.CreateEntry(Path.Combine(dirName, "package_type_id.txt"), CompressionLevel.Optimal).Open())
|
|
using (var writer = new StreamWriter(stream))
|
|
writer.WriteLine(target.PackageTargetString);
|
|
|
|
if (target.OS != OSType.Windows)
|
|
{
|
|
var setEntry = zip.CreateEntry(Path.Combine(dirName, "set-permissions.sh"), CompressionLevel.Optimal);
|
|
setEntry.ExternalAttributes = Convert.ToInt32("755", 8) << 16;
|
|
|
|
using (var stream = setEntry.Open())
|
|
using (var writer = new StreamWriter(stream))
|
|
{
|
|
writer.WriteLine("#!/bin/sh");
|
|
writer.WriteLine("# This script sets the executable flags for the Duplicati binaries and support scripts");
|
|
writer.WriteLine("set -e");
|
|
foreach (var x in executables)
|
|
writer.WriteLine($"chmod +x {x}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an MSI package asynchronously.
|
|
/// </summary>
|
|
/// <param name="baseDir">The source base directory.</param>
|
|
/// <param name="buildRoot">The root directory of the build.</param>
|
|
/// <param name="msiFile">The MSI file to generate.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildMsiPackage(string baseDir, string buildRoot, string msiFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
var isWindows = OperatingSystem.IsWindows();
|
|
const string originalNamespace = "http://schemas.microsoft.com/wix/2006/wi";
|
|
const string wixv4Namespace = "http://wixtoolset.org/schemas/v4/wxs";
|
|
|
|
var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "Windows");
|
|
var resourcesSubDir = Path.Combine(resourcesDir,
|
|
target.Interface switch
|
|
{
|
|
InterfaceType.GUI => "TrayIcon",
|
|
InterfaceType.Agent => "Agent",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
});
|
|
|
|
var buildTmp = Path.Combine(buildRoot, "tmp-msi");
|
|
if (Directory.Exists(buildTmp))
|
|
Directory.Delete(buildTmp, true);
|
|
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), buildTmp, recursive: true);
|
|
await PackageSupport.InstallPackageIdentifier(buildTmp, target);
|
|
|
|
var sourceFiles = buildTmp;
|
|
if (!sourceFiles.EndsWith(Path.DirectorySeparatorChar))
|
|
sourceFiles += Path.DirectorySeparatorChar;
|
|
|
|
var binFiles = Path.Combine(resourcesSubDir, "binfiles.wxs");
|
|
if (File.Exists(binFiles))
|
|
File.Delete(binFiles);
|
|
|
|
File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist(
|
|
sourceFiles,
|
|
version: rtcfg.ReleaseInfo.Version.ToString(),
|
|
wixNs: isWindows ? wixv4Namespace : originalNamespace
|
|
));
|
|
|
|
var msiArch = target.Arch switch
|
|
{
|
|
ArchType.x86 => "x86",
|
|
ArchType.x64 => "x64",
|
|
ArchType.Arm64 => "x64", // Using x64 MSI for ARM64
|
|
_ => throw new Exception($"Architeture not supported: {target.ArchString}")
|
|
};
|
|
|
|
// Prepare the Wix arguments, different for WiX (Windows) and wixl (Linux/MacOS)
|
|
string[] wixArgs;
|
|
|
|
if (isWindows)
|
|
{
|
|
// Update namespace in the .wxs files for WiX v4
|
|
var shortcutsTempfile = Path.Combine(buildTmp, "Shortcuts.wxs");
|
|
var entryTempfile = Path.Combine(buildTmp, "Duplicati.wxs");
|
|
|
|
File.WriteAllText(shortcutsTempfile, File.ReadAllText(Path.Combine(resourcesSubDir, "Shortcuts.wxs"))
|
|
.Replace(originalNamespace, wixv4Namespace));
|
|
|
|
File.WriteAllText(entryTempfile, File.ReadAllText(Path.Combine(resourcesSubDir, "Duplicati.wxs"))
|
|
.Replace(originalNamespace, wixv4Namespace));
|
|
|
|
wixArgs = [
|
|
rtcfg.Configuration.Commands.Wix!,
|
|
"build",
|
|
"-define", $"HarvestPath={sourceFiles}",
|
|
"-arch", msiArch,
|
|
"-out", msiFile,
|
|
shortcutsTempfile,
|
|
binFiles,
|
|
entryTempfile
|
|
];
|
|
}
|
|
else
|
|
{
|
|
// wixl needs the UI extension
|
|
wixArgs = [
|
|
rtcfg.Configuration.Commands.Wix!,
|
|
"--ext", "ui",
|
|
"--extdir", Path.Combine(resourcesDir, "WixUIExtension"),
|
|
"--define", $"HarvestPath={sourceFiles}",
|
|
"--arch", msiArch,
|
|
"--output", msiFile,
|
|
Path.Combine(resourcesSubDir, "Shortcuts.wxs"),
|
|
binFiles,
|
|
Path.Combine(resourcesSubDir, "Duplicati.wxs")
|
|
];
|
|
}
|
|
|
|
await ProcessHelper.Execute(wixArgs, workingDirectory: buildRoot);
|
|
|
|
if (rtcfg.UseAuthenticodeSigning)
|
|
await rtcfg.AuthenticodeSign(msiFile);
|
|
|
|
Directory.Delete(buildTmp, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Install the package identifier into the app bundle, and performs resigning of the binaries
|
|
/// </summary>
|
|
/// <param name="appFolder">The folder where the app bundle is located</param>
|
|
/// <param name="installerDir">The installer dir where the installer files are located</param>
|
|
/// <param name="target">The package target to create the file for</param>
|
|
/// <param name="rtcfg">The runtime config</param>
|
|
/// <returns>An awaitable task</returns>
|
|
static async Task PrepareAndSignAppBundle(string appFolder, string installerDir, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
await PackageSupport.InstallPackageIdentifier(Path.Combine(appFolder, "Contents", "MacOS"), target);
|
|
|
|
// After injecting the package_type_id, resign
|
|
if (rtcfg.UseCodeSignSigning)
|
|
{
|
|
var entitlementFile = Path.Combine(installerDir, "Entitlements.plist");
|
|
// In principle, it should be enough to deep sign the bundle, but the signtool is broken
|
|
await PackageSupport.SignMacOSBinaries(rtcfg, Path.Combine(appFolder, "Contents", "MacOS"), entitlementFile);
|
|
await rtcfg.Codesign(appFolder, false, entitlementFile);
|
|
await rtcfg.VerifyCodeSign(appFolder);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a DMG package asynchronously.
|
|
/// </summary>
|
|
/// <param name="baseDir">The source base directory.</param>
|
|
/// <param name="buildRoot">The root directory of the build.</param>
|
|
/// <param name="dmgFile">The DMG file to generate.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildMacDmgPackage(string baseDir, string buildRoot, string dmgFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
var mountDir = Path.Combine(buildRoot, "mount");
|
|
if (Directory.Exists(mountDir))
|
|
{
|
|
await ProcessHelper.Execute(["hdiutil", "detach", mountDir, "-quiet", "-force",],
|
|
workingDirectory: buildRoot, codeIsError: _ => false);
|
|
|
|
Directory.Delete(mountDir, false);
|
|
}
|
|
Directory.CreateDirectory(mountDir);
|
|
|
|
var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "MacOS", "AppBundle");
|
|
var compressedDmg = Path.Combine(resourcesDir, "template.dmg.bz2");
|
|
if (!File.Exists(compressedDmg))
|
|
throw new FileNotFoundException($"Compressed dmg template file not found: {compressedDmg}");
|
|
|
|
// Remove the bz2
|
|
var templateDmg = Path.Combine(buildRoot, Path.GetFileNameWithoutExtension(compressedDmg));
|
|
if (File.Exists(templateDmg))
|
|
File.Delete(templateDmg);
|
|
|
|
// Decompress the dmg
|
|
using (var fs = File.Create(templateDmg))
|
|
await ProcessHelper.ExecuteWithOutput([
|
|
"bzip2", "--decompress", "--keep", "--quiet", "--stdout", compressedDmg
|
|
], fs, workingDirectory: buildRoot);
|
|
|
|
if (!File.Exists(templateDmg))
|
|
throw new FileNotFoundException($"Decompressed dmg template file not found: {templateDmg}");
|
|
|
|
await ProcessHelper.ExecuteAll([
|
|
["hdiutil", "resize", "-size", "300M", templateDmg],
|
|
["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir]
|
|
], workingDirectory: buildRoot);
|
|
|
|
// Change the dmg name
|
|
var dmgname = $"Duplicati {rtcfg.ReleaseInfo.ReleaseName}";
|
|
Console.WriteLine($"Setting dmg name to {dmgname}");
|
|
await ProcessHelper.Execute(["diskutil", "quiet", "rename", mountDir, dmgname], workingDirectory: mountDir);
|
|
|
|
// Make the Duplicati.app structure, root folder should exist
|
|
var appFolder = Path.Combine(mountDir, rtcfg.MacOSAppName);
|
|
if (Directory.Exists(appFolder))
|
|
Directory.Delete(appFolder, true);
|
|
|
|
// Place the prepared folder
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
|
|
await PrepareAndSignAppBundle(appFolder, resourcesDir, target, rtcfg);
|
|
|
|
// Set permissions inside DMG file
|
|
if (!OperatingSystem.IsWindows())
|
|
await EnvHelper.Chown(appFolder, "root", "admin", true);
|
|
|
|
// Unmount the dmg and compress
|
|
await ProcessHelper.ExecuteAll([
|
|
["hdiutil", "detach", mountDir, "-quiet", "-force"],
|
|
["hdiutil", "convert", templateDmg, "-quiet", "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", dmgFile]
|
|
], workingDirectory: buildRoot);
|
|
|
|
// Clean up
|
|
File.Delete(templateDmg);
|
|
Directory.Delete(mountDir, false);
|
|
|
|
await rtcfg.Codesign(dmgFile, false, Path.Combine(resourcesDir, "Entitlements.plist"));
|
|
await rtcfg.VerifyCodeSign(dmgFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the Mac package asynchronously.
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="pkgFile">The package file path.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildMacAppPkgPackage(string baseDir, string buildRoot, string pkgFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
var tmpFolder = Path.Combine(buildRoot, "tmp-pkg");
|
|
if (Directory.Exists(tmpFolder))
|
|
Directory.Delete(tmpFolder, true);
|
|
Directory.CreateDirectory(tmpFolder);
|
|
|
|
var installerDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "MacOS", "AppBundle");
|
|
|
|
var appFolder = Path.Combine(tmpFolder, rtcfg.MacOSAppName);
|
|
if (Directory.Exists(appFolder))
|
|
Directory.Delete(appFolder, true);
|
|
|
|
// Place the prepared folder
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{rtcfg.MacOSAppName}"), appFolder, recursive: true);
|
|
await PrepareAndSignAppBundle(appFolder, installerDir, target, rtcfg);
|
|
|
|
// Copy the source script files
|
|
var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" };
|
|
|
|
// Copy scripts
|
|
foreach (var s in scripts)
|
|
EnvHelper.CopyDirectory(Path.Combine(installerDir, s), Path.Combine(tmpFolder, s), recursive: true);
|
|
|
|
// Set permissions
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
await EnvHelper.Chown(appFolder, "root", "admin", true);
|
|
foreach (var f in Directory.EnumerateFiles(Path.Combine(tmpFolder, "daemon"), "*.launchagent.plist", SearchOption.AllDirectories))
|
|
await EnvHelper.Chown(f, "root", "wheel", false);
|
|
|
|
var filemode = EnvHelper.GetUnixFileMode("+x");
|
|
var allscripts = scripts.Select(x => Path.Combine(tmpFolder, x)).Where(Directory.Exists).SelectMany(x => Directory.EnumerateFiles(x, "*", SearchOption.AllDirectories));
|
|
foreach (var x in allscripts)
|
|
if (File.Exists(x))
|
|
EnvHelper.AddFilemode(x, filemode);
|
|
}
|
|
|
|
var pkgAppFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiApp.pkg");
|
|
if (File.Exists(pkgAppFile))
|
|
File.Delete(pkgAppFile);
|
|
var pkgDaemonFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiDaemon.pkg");
|
|
if (File.Exists(pkgDaemonFile))
|
|
File.Delete(pkgDaemonFile);
|
|
|
|
var distributionFile = Path.Combine(tmpFolder, "Distribution.xml");
|
|
var hostArch = target.Arch switch
|
|
{
|
|
ArchType.x86 => "i386",
|
|
ArchType.x64 => "x86_64",
|
|
ArchType.Arm64 => "arm64",
|
|
_ => throw new Exception($"Unsupported architecture: {target.ArchString}")
|
|
};
|
|
|
|
File.WriteAllText(distributionFile,
|
|
File.ReadAllText(Path.Combine(installerDir, "Distribution.xml"))
|
|
.Replace("DuplicatiApp.pkg", Path.GetFileName(pkgAppFile))
|
|
.Replace("DuplicatiDaemon.pkg", Path.GetFileName(pkgDaemonFile))
|
|
.Replace("$HOSTARCH", hostArch)
|
|
);
|
|
|
|
// Make the pkg files
|
|
await ProcessHelper.ExecuteAll([
|
|
["pkgbuild", "--analyze", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "InstallerComponent.plist"],
|
|
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "app-scripts"), "--identifier", "com.duplicati.app", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "--component-plist", "InstallerComponent.plist", pkgAppFile],
|
|
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "daemon-scripts"), "--identifier", "com.duplicati.app.daemon", "--root", Path.Combine(tmpFolder, "daemon"), "--install-location", "/Library/LaunchAgents", pkgDaemonFile],
|
|
["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile]
|
|
], workingDirectory: tmpFolder);
|
|
|
|
// Clean up
|
|
Directory.Delete(tmpFolder, true);
|
|
|
|
// Sign the pkg file
|
|
if (rtcfg.UseCodeSignSigning)
|
|
await rtcfg.Productsign(pkgFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the Mac package asynchronously.
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="pkgFile">The package file path.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildMacNonAppPkgPackage(string baseDir, string buildRoot, string pkgFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
var tmpFolder = Path.Combine(buildRoot, "tmp-pkg");
|
|
if (Directory.Exists(tmpFolder))
|
|
Directory.Delete(tmpFolder, true);
|
|
Directory.CreateDirectory(tmpFolder);
|
|
|
|
var resFolder = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "Agent",
|
|
InterfaceType.Cli => "CLI",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
var installerDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "MacOS", resFolder);
|
|
|
|
var binFolder = Path.Combine(tmpFolder, "bin");
|
|
if (Directory.Exists(binFolder))
|
|
Directory.Delete(binFolder, true);
|
|
|
|
// Place the prepared folder
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}"), binFolder, recursive: true);
|
|
|
|
// Rename the executables
|
|
await PackageSupport.RenameExecutables(binFolder);
|
|
await PackageSupport.SetPermissionFlags(binFolder, rtcfg);
|
|
|
|
// Copy the source script files
|
|
var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" };
|
|
|
|
// Copy scripts
|
|
foreach (var s in scripts)
|
|
EnvHelper.CopyDirectory(Path.Combine(installerDir, s), Path.Combine(tmpFolder, s), recursive: true);
|
|
|
|
// Inject the launch agent
|
|
EnvHelper.CopyDirectory(
|
|
Path.Combine(installerDir, "daemon"),
|
|
Path.Combine(binFolder),
|
|
recursive: true
|
|
);
|
|
|
|
// Inject the uninstall.sh script
|
|
File.Copy(
|
|
Path.Combine(installerDir, "uninstall.sh"),
|
|
Path.Combine(binFolder, "uninstall.sh"),
|
|
overwrite: true
|
|
);
|
|
|
|
// Create symlinks for the executables
|
|
if (target.Interface == InterfaceType.Cli)
|
|
{
|
|
var installedExecutables = ExecutableRenames.Values
|
|
.Select(x => Path.Combine(binFolder, x))
|
|
.Where(File.Exists)
|
|
.Select(Path.GetFileName)
|
|
.ToList();
|
|
|
|
var installScript = Path.Combine(tmpFolder, "app-scripts", "postinstall");
|
|
var installCommands = new StringBuilder();
|
|
installCommands.Append(File.ReadAllText(installScript));
|
|
installCommands.AppendLine();
|
|
installCommands.AppendLine("echo 'Creating symbolic links for the Duplicati executables'");
|
|
foreach (var x in installedExecutables)
|
|
installCommands.AppendLine($"ln -s /usr/local/duplicati/{x} /usr/local/bin/{x}");
|
|
installCommands.AppendLine("echo 'Duplicati CLI has been installed'");
|
|
installCommands.AppendLine("exit 0");
|
|
File.WriteAllText(installScript, installCommands.ToString());
|
|
|
|
var uninstallScript = Path.Combine(binFolder, "uninstall.sh");
|
|
var uninstallCommands = new StringBuilder();
|
|
uninstallCommands.Append(File.ReadAllText(uninstallScript));
|
|
uninstallCommands.AppendLine();
|
|
uninstallCommands.AppendLine("echo 'Removing links for the Duplicati executables'");
|
|
foreach (var x in installedExecutables)
|
|
uninstallCommands.AppendLine($"if [ -L /usr/local/bin/{x} ]; then rm /usr/local/bin/{x}; fi");
|
|
uninstallCommands.AppendLine("echo 'Duplicati symlinks are removed'");
|
|
uninstallCommands.AppendLine("exit 0");
|
|
File.WriteAllText(uninstallScript, uninstallCommands.ToString());
|
|
}
|
|
|
|
// Copy the package identifier
|
|
await PackageSupport.InstallPackageIdentifier(binFolder, target);
|
|
|
|
// Set permissions
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
await EnvHelper.Chown(binFolder, "root", "admin", true);
|
|
foreach (var f in Directory.EnumerateFiles(Path.Combine(tmpFolder, "daemon"), "*.launchagent.plist", SearchOption.AllDirectories))
|
|
await EnvHelper.Chown(f, "root", "wheel", false);
|
|
|
|
var filemode = EnvHelper.GetUnixFileMode("+x");
|
|
var allscripts = scripts
|
|
.Select(x => Path.Combine(tmpFolder, x))
|
|
.Where(Directory.Exists)
|
|
.SelectMany(x => Directory.EnumerateFiles(x, "*", SearchOption.AllDirectories))
|
|
.Append(Path.Combine(tmpFolder, "uninstall.sh"));
|
|
|
|
foreach (var x in allscripts)
|
|
if (File.Exists(x))
|
|
EnvHelper.AddFilemode(x, filemode);
|
|
}
|
|
|
|
// Apply code signing, if requested
|
|
await PackageSupport.SignMacOSBinaries(rtcfg, binFolder, Path.Combine(installerDir, "Entitlements.plist"));
|
|
|
|
var payloadPkgFile = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "DuplicatiAgent.pkg",
|
|
InterfaceType.Cli => "DuplicatiCLI.pkg",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
var daemonPkgFile = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "DuplicatiAgentDaemon.pkg",
|
|
InterfaceType.Cli => "DuplicatiServerDaemon.pkg",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
var pkgAppFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{payloadPkgFile}");
|
|
if (File.Exists(pkgAppFile))
|
|
File.Delete(pkgAppFile);
|
|
var pkgDaemonFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{daemonPkgFile}");
|
|
if (File.Exists(pkgDaemonFile))
|
|
File.Delete(pkgDaemonFile);
|
|
|
|
var distributionFile = Path.Combine(tmpFolder, "Distribution.xml");
|
|
var hostArch = target.Arch switch
|
|
{
|
|
ArchType.x86 => "i386",
|
|
ArchType.x64 => "x86_64",
|
|
ArchType.Arm64 => "arm64",
|
|
_ => throw new Exception($"Unsupported architecture: {target.ArchString}")
|
|
};
|
|
|
|
File.WriteAllText(distributionFile,
|
|
File.ReadAllText(Path.Combine(installerDir, "Distribution.xml"))
|
|
.Replace(payloadPkgFile, Path.GetFileName(pkgAppFile))
|
|
.Replace(daemonPkgFile, Path.GetFileName(pkgDaemonFile))
|
|
.Replace("$HOSTARCH", hostArch)
|
|
);
|
|
|
|
var installLocation = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "/usr/local/duplicati-agent",
|
|
InterfaceType.Cli => "/usr/local/duplicati",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
var appId = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "com.duplicati.agent",
|
|
InterfaceType.Cli => "com.duplicati.cli",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
var daemonId = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "com.duplicati.agent.daemon",
|
|
InterfaceType.Cli => "com.duplicati.server.daemon",
|
|
_ => throw new Exception($"Unsupported interface type: {target.Interface}")
|
|
};
|
|
|
|
// Make the pkg files
|
|
await ProcessHelper.ExecuteAll([
|
|
["pkgbuild", "--analyze", "--root", binFolder, "--install-location", installLocation, "InstallerComponent.plist"],
|
|
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "app-scripts"), "--identifier", appId, "--root", binFolder, "--install-location", installLocation, "--component-plist", "InstallerComponent.plist", pkgAppFile],
|
|
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "daemon-scripts"), "--identifier", daemonId, "--root", Path.Combine(tmpFolder, "daemon"), "--install-location", "/Library/LaunchAgents", pkgDaemonFile],
|
|
["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile]
|
|
], workingDirectory: tmpFolder);
|
|
|
|
// Clean up
|
|
Directory.Delete(tmpFolder, true);
|
|
|
|
// Sign the pkg file
|
|
if (rtcfg.UseCodeSignSigning)
|
|
await rtcfg.Productsign(pkgFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a GZip compressed file
|
|
/// </summary>
|
|
/// <param name="inputStream">The input stream path</param>
|
|
/// <param name="outputFilePath">The output file path</param>
|
|
/// <returns>A task representing the asynchronous operation</returns>
|
|
static async Task GzipCompressFileAsync(Stream inputStream, string outputFilePath)
|
|
{
|
|
using (var outputFileStream = new FileStream(outputFilePath, FileMode.Create, FileAccess.Write))
|
|
using (var gzipStream = new GZipStream(outputFileStream, CompressionMode.Compress))
|
|
await inputStream.CopyToAsync(gzipStream);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the folder for the file exists
|
|
/// </summary>
|
|
/// <param name="filename">The filename</param>
|
|
/// <returns>The filename</returns>
|
|
public static string EnsureFolderForFile(string filename)
|
|
{
|
|
var folder = Path.GetDirectoryName(filename);
|
|
if (folder != null && !Directory.Exists(folder))
|
|
Directory.CreateDirectory(folder);
|
|
return filename;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a DEB package using Docker
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="debFile">The DEB file to generate.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildDebPackage(string baseDir, string buildRoot, string debFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
// The approach here is based on:
|
|
// https://www.internalpointers.com/post/build-binary-deb-package-practical-guide
|
|
//
|
|
// It is not the recommended way to build a package,
|
|
// but since the build is from a pre-build binary,
|
|
// it is easier than trying to hack debhelper.
|
|
|
|
var debroot = Path.Combine(buildRoot, "deb");
|
|
if (Path.Exists(debroot))
|
|
Directory.Delete(debroot, true);
|
|
Directory.CreateDirectory(debroot);
|
|
|
|
// Make the package structure
|
|
var debpkgdir = $"duplicati-{rtcfg.ReleaseInfo.Version}_{target.ArchString}";
|
|
var pkgroot = Path.Combine(debroot, debpkgdir);
|
|
|
|
Directory.CreateDirectory(pkgroot);
|
|
Directory.CreateDirectory(Path.Combine(pkgroot, "DEBIAN"));
|
|
Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "lib"));
|
|
Directory.CreateDirectory(Path.Combine(pkgroot, "usr", "bin"));
|
|
|
|
// Copy main files
|
|
EnvHelper.CopyDirectory(
|
|
Path.Combine(buildRoot, target.BuildTargetString),
|
|
Path.Combine(pkgroot, "usr", "lib", "duplicati"),
|
|
recursive: true);
|
|
|
|
var pkglib = Path.Combine(pkgroot, "usr", "lib", "duplicati");
|
|
|
|
await PackageSupport.InstallPackageIdentifier(pkglib, target);
|
|
await PackageSupport.RenameExecutables(pkglib);
|
|
await PackageSupport.SetPermissionFlags(pkglib, rtcfg);
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
throw new PlatformNotSupportedException("Creating unresolved symlinks is not supported on Windows, use WSL or Docker");
|
|
|
|
foreach (var e in ExecutableRenames.Values)
|
|
{
|
|
var exefile = Path.Combine(pkgroot, "usr", "lib", "duplicati", e);
|
|
if (File.Exists(exefile))
|
|
await ProcessHelper.Execute([
|
|
"ln", "-s",
|
|
Path.Combine("..", "lib", "duplicati", e),
|
|
Path.Combine(pkgroot, "usr", "bin", e)
|
|
]);
|
|
}
|
|
|
|
// Copy debian files
|
|
var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "debian");
|
|
|
|
// Write the license file
|
|
File.Copy(
|
|
Path.Combine(baseDir, "LICENSE"),
|
|
EnsureFolderForFile(
|
|
Path.Combine(pkgroot, "usr", "share", "doc", "duplicati", "copyright")
|
|
)
|
|
);
|
|
|
|
// Write a custom changelog file
|
|
var changelogData = File.ReadAllText(Path.Combine(resourcesDir, "changelog.template.txt"))
|
|
.Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
|
|
.Replace("%DATE%", DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss +0000", CultureInfo.InvariantCulture));
|
|
|
|
// Write a compressed changelog file
|
|
using (var changelogStream = new MemoryStream(Encoding.UTF8.GetBytes(changelogData)))
|
|
await GzipCompressFileAsync(
|
|
changelogStream,
|
|
EnsureFolderForFile(Path.Combine(pkgroot, "usr", "share", "doc", "duplicati", "changelog.gz"))
|
|
);
|
|
|
|
// Custom arch, from: https://wiki.debian.org/SupportedArchitectures
|
|
var debArchString = target.Arch switch
|
|
{
|
|
ArchType.x86 => "i386",
|
|
ArchType.x64 => "amd64",
|
|
ArchType.Arm64 => "arm64",
|
|
ArchType.Arm7 => "armhf",
|
|
_ => throw new Exception($"Architeture not supported: {target.ArchString}")
|
|
};
|
|
|
|
var packageType = target.Interface switch
|
|
{
|
|
InterfaceType.Agent => "-agent",
|
|
_ => ""
|
|
};
|
|
|
|
// Write a custom control file
|
|
File.WriteAllText(
|
|
Path.Combine(pkgroot, "DEBIAN", "control"),
|
|
File.ReadAllText(Path.Combine(resourcesDir, "control.template.txt"))
|
|
.Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
|
|
.Replace("%ARCH%", debArchString)
|
|
.Replace("%PACKAGE_TYPE%", packageType)
|
|
.Replace("%DEPENDS%", string.Join(", ", target.Interface == InterfaceType.GUI
|
|
? DebianGUIDepends
|
|
: DebianCLIDepends))
|
|
);
|
|
|
|
// Install various helper files
|
|
var sharedDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "shared");
|
|
var supportFiles = new List<(string Source, string Destination)>();
|
|
if (target.Interface != InterfaceType.Agent)
|
|
{
|
|
supportFiles.AddRange([
|
|
(
|
|
Path.Combine(resourcesDir, "systemd", "duplicati.default"),
|
|
Path.Combine(pkgroot, "etc", "default", "duplicati")
|
|
),
|
|
(
|
|
Path.Combine(resourcesDir, "systemd", "duplicati.service"),
|
|
Path.Combine(pkgroot, "lib", "systemd", "system", "duplicati.service")
|
|
)
|
|
]);
|
|
}
|
|
else
|
|
{
|
|
supportFiles.AddRange([
|
|
(
|
|
Path.Combine(resourcesDir, "systemd", "duplicati-agent.default"),
|
|
Path.Combine(pkgroot, "etc", "default", "duplicati-agent")
|
|
),
|
|
(
|
|
Path.Combine(resourcesDir, "systemd", "duplicati-agent.service"),
|
|
Path.Combine(pkgroot, "lib", "systemd", "system", "duplicati-agent.service")
|
|
)
|
|
]);
|
|
|
|
}
|
|
|
|
if (target.Interface == InterfaceType.GUI)
|
|
{
|
|
supportFiles.Add((
|
|
Path.Combine(sharedDir, "desktop", "duplicati.desktop"),
|
|
Path.Combine(pkgroot, "usr", "share", "applications", "duplicati.desktop")
|
|
));
|
|
|
|
supportFiles.AddRange(
|
|
new[] { "duplicati.png", "duplicati.svg", "duplicati.xpm" }
|
|
.Select(f => (
|
|
Path.Combine(sharedDir, "pixmaps", f),
|
|
Path.Combine(pkgroot, "usr", "share", "pixmaps", f)
|
|
))
|
|
);
|
|
}
|
|
|
|
foreach (var f in supportFiles)
|
|
{
|
|
var dir = Path.GetDirectoryName(f.Destination);
|
|
if (!Directory.Exists(dir) && dir != null)
|
|
Directory.CreateDirectory(dir);
|
|
File.Copy(f.Source, f.Destination, true);
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
File.SetUnixFileMode(f.Destination, UnixFileMode.OtherRead | UnixFileMode.GroupRead | UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
|
}
|
|
|
|
// Write a conf file
|
|
var confroot = Path.Combine(pkgroot, "etc");
|
|
var conffiles = Directory.EnumerateFiles(confroot, "*", SearchOption.AllDirectories)
|
|
.Select(x => "/" + Path.GetRelativePath(pkgroot, x))
|
|
.Append(string.Empty)
|
|
.ToList();
|
|
|
|
File.WriteAllText(
|
|
Path.Combine(pkgroot, "DEBIAN", "conffiles"),
|
|
string.Join("\n", conffiles)
|
|
);
|
|
|
|
if (target.Interface == InterfaceType.Agent)
|
|
{
|
|
// Write a custom postinst script
|
|
File.Copy(
|
|
Path.Combine(resourcesDir, $"postinst-agent"),
|
|
Path.Combine(pkgroot, "DEBIAN", "postinst"),
|
|
true
|
|
);
|
|
|
|
// Write a custom prerm script
|
|
File.Copy(
|
|
Path.Combine(resourcesDir, $"prerm-agent"),
|
|
Path.Combine(pkgroot, "DEBIAN", "prerm"),
|
|
true
|
|
);
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
var filemode = UnixFileMode.UserRead
|
|
| UnixFileMode.UserWrite
|
|
| UnixFileMode.UserExecute
|
|
| UnixFileMode.GroupRead
|
|
| UnixFileMode.GroupExecute
|
|
| UnixFileMode.OtherRead
|
|
| UnixFileMode.OtherExecute;
|
|
|
|
File.SetUnixFileMode(Path.Combine(pkgroot, "DEBIAN", "prerm"), filemode);
|
|
File.SetUnixFileMode(Path.Combine(pkgroot, "DEBIAN", "postinst"), filemode);
|
|
}
|
|
}
|
|
|
|
// Copy the Docker build file
|
|
File.Copy(
|
|
Path.Combine(resourcesDir, "Dockerfile.build"),
|
|
Path.Combine(debroot, "Dockerfile"),
|
|
true
|
|
);
|
|
|
|
// Build a Docker image to build with
|
|
await ProcessHelper.Execute([
|
|
"docker", "build",
|
|
"-t", "duplicati/debian-build:latest",
|
|
debroot
|
|
], workingDirectory: debroot);
|
|
|
|
var debpkgname = $"{debpkgdir}.deb";
|
|
|
|
// Docker desktop has some sync issues
|
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
|
|
|
// Build in Docker
|
|
await ProcessHelper.Execute([
|
|
"docker", "run",
|
|
"--workdir", $"/build",
|
|
"--volume", $"{debroot}:/build:rw", "duplicati/debian-build:latest",
|
|
// Using gzip compression for compatibility with older Debian versions
|
|
"dpkg-deb", "-Zgzip", "--build", "--root-owner-group", debpkgdir
|
|
]);
|
|
|
|
File.Move(Path.Combine(debroot, debpkgname), debFile);
|
|
Directory.Delete(debroot, true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the RPM package using Docker
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="rpmFile">The RPM file to generate.</param>
|
|
/// <param name="target">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
static async Task BuildRpmPackage(string baseDir, string buildRoot, string rpmFile, PackageTarget target, RuntimeConfig rtcfg)
|
|
{
|
|
var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "fedora");
|
|
var tmpbuild = Path.Combine(buildRoot, "tmp-fedora");
|
|
if (Directory.Exists(tmpbuild))
|
|
Directory.Delete(tmpbuild, true);
|
|
Directory.CreateDirectory(tmpbuild);
|
|
|
|
var servicename = target.Interface == InterfaceType.Agent ? "-agent" : "";
|
|
|
|
var tarsrc = Path.Combine(tmpbuild, $"duplicati{servicename}-{rtcfg.ReleaseInfo.Version}");
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), tarsrc, recursive: true);
|
|
|
|
await PackageSupport.InstallPackageIdentifier(tarsrc, target);
|
|
await PackageSupport.RenameExecutables(tarsrc);
|
|
await PackageSupport.SetPermissionFlags(tarsrc, rtcfg);
|
|
|
|
var binaries = ExecutableRenames.Values.Where(x => File.Exists(Path.Combine(tarsrc, x))).ToList();
|
|
|
|
var executables =
|
|
binaries
|
|
.Concat(Directory.GetFiles(tarsrc, "*.sh", SearchOption.AllDirectories).Select(x => Path.GetRelativePath(tarsrc, x)))
|
|
.Concat(Directory.GetFiles(tarsrc, "*.py", SearchOption.AllDirectories).Select(x => Path.GetRelativePath(tarsrc, x)))
|
|
.ToList();
|
|
|
|
// Create the tarball
|
|
var tarfile = Path.Combine(tmpbuild, $"duplicati-{rtcfg.ReleaseInfo.Version}.tar.bz2");
|
|
await ProcessHelper.Execute(
|
|
["tar", "-cjf", tarfile, Path.GetFileName(tarsrc)],
|
|
workingDirectory: Path.GetDirectoryName(tarsrc)
|
|
);
|
|
Directory.Delete(tarsrc, true);
|
|
|
|
// Create rpmbuild structure
|
|
var sources = Path.Combine(tmpbuild, "SOURCES");
|
|
Directory.CreateDirectory(sources);
|
|
|
|
File.Move(tarfile, Path.Combine(sources, Path.GetFileName(tarfile)));
|
|
|
|
// Move in extra files for building
|
|
var sharedDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "shared");
|
|
File.Copy(Path.Combine(sharedDir, "pixmaps", "duplicati.xpm"), Path.Combine(sources, "duplicati.xpm"));
|
|
File.Copy(Path.Combine(sharedDir, "pixmaps", "duplicati.png"), Path.Combine(sources, "duplicati.png"));
|
|
File.Copy(Path.Combine(sharedDir, "desktop", "duplicati.desktop"), Path.Combine(sources, "duplicati.desktop"));
|
|
File.Copy(Path.Combine(resourcesDir, "duplicati-install-recursive.sh"), Path.Combine(sources, "duplicati-install-recursive.sh"));
|
|
if (target.Interface != InterfaceType.Agent)
|
|
{
|
|
File.Copy(Path.Combine(resourcesDir, "systemd", "duplicati.service"), Path.Combine(sources, "duplicati.service"));
|
|
File.Copy(Path.Combine(resourcesDir, "systemd", "duplicati.default"), Path.Combine(sources, "duplicati.default"));
|
|
}
|
|
else
|
|
{
|
|
File.Copy(Path.Combine(resourcesDir, "systemd", "duplicati-agent.service"), Path.Combine(sources, "duplicati-agent.service"));
|
|
File.Copy(Path.Combine(resourcesDir, "systemd", "duplicati-agent.default"), Path.Combine(sources, "duplicati-agent.default"));
|
|
File.Copy(Path.Combine(resourcesDir, "systemd", "duplicati-agent.preset"), Path.Combine(sources, "duplicati-agent.preset"));
|
|
}
|
|
|
|
// Write custom script to install executable files
|
|
File.WriteAllLines(
|
|
Path.Combine(sources, "duplicati-install-binaries.sh"),
|
|
File.ReadAllLines(Path.Combine(resourcesDir, "duplicati-install-binaries.sh"))
|
|
.SelectMany(line =>
|
|
{
|
|
if (line.StartsWith("REPL: "))
|
|
return executables.Select(x => line.Substring("REPL: ".Length).Replace("%SOURCE%", x).Replace("%TARGET%", x));
|
|
if (line.StartsWith("SYML: "))
|
|
return binaries.Select(x => line.Substring("SYML: ".Length).Replace("%SOURCE%", x).Replace("%TARGET%", x));
|
|
return [line];
|
|
})
|
|
);
|
|
|
|
var rpmarch = target.Arch switch
|
|
{
|
|
ArchType.x64 => "x86_64",
|
|
ArchType.Arm64 => "aarch64",
|
|
ArchType.Arm7 => "armv7hl",
|
|
_ => throw new Exception($"Unsupported arch: {target.Arch}")
|
|
};
|
|
|
|
var specData = File.ReadAllText(Path.Combine(resourcesDir, "duplicati.spec.template.txt"))
|
|
.Replace("%BUILDDATE%", DateTime.UtcNow.ToString("yyyyMMdd"))
|
|
.Replace("%BUILDVERSION%", rtcfg.ReleaseInfo.Version.ToString())
|
|
.Replace("%BUILDTAG%", rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant())
|
|
.Replace("%VERSION%", rtcfg.ReleaseInfo.Version.ToString())
|
|
.Replace("%SERVICENAME%", servicename)
|
|
.Replace("%PROVIDES%", string.Join("\n", executables.Select(x => $"Provides:\t{x}")))
|
|
.Replace("%DEPENDS%", string.Join("\n",
|
|
(target.Interface == InterfaceType.GUI
|
|
? FedoraGUIDepends
|
|
: FedoraCLIDepends).Select(x => $"Requires:\t{x}")));
|
|
|
|
// Remove comment markers, or remove lines
|
|
if (target.Interface == InterfaceType.GUI)
|
|
specData = specData.Replace("#GUI_ONLY#", "");
|
|
else
|
|
specData = string.Join("\n", specData.Split('\n').Where(x => !x.StartsWith("#GUI_ONLY#")));
|
|
|
|
if (target.Interface == InterfaceType.Agent)
|
|
specData = specData.Replace("#AGENT_ONLY#", "");
|
|
else
|
|
specData = string.Join("\n", specData.Split('\n').Where(x => !x.StartsWith("#AGENT_ONLY#")));
|
|
|
|
File.WriteAllText(
|
|
Path.Combine(sources, "duplicati.spec"),
|
|
specData
|
|
);
|
|
|
|
// Install the Docker build file
|
|
File.Copy(
|
|
Path.Combine(resourcesDir, "Dockerfile.build"),
|
|
Path.Combine(tmpbuild, "Dockerfile"),
|
|
true
|
|
);
|
|
|
|
// Build a Docker image to build with
|
|
await ProcessHelper.Execute([
|
|
"docker", "build",
|
|
"-t", "duplicati/fedora-build:latest",
|
|
tmpbuild
|
|
], workingDirectory: tmpbuild);
|
|
|
|
// Install the build script
|
|
// This is required because rpmbuild reads file mode
|
|
// in a way that is not compatible with Docker desktop bind mounts
|
|
File.Copy(
|
|
Path.Combine(resourcesDir, "inside-docker.sh"),
|
|
Path.Combine(tmpbuild, "inside-docker.sh"),
|
|
true
|
|
);
|
|
|
|
// Then build the package itself
|
|
await ProcessHelper.Execute([
|
|
"docker", "run",
|
|
"--workdir", "/build",
|
|
"--volume", $"{tmpbuild}:/build:rw",
|
|
"duplicati/fedora-build:latest",
|
|
|
|
"/bin/bash", "/build/inside-docker.sh", "/build", rpmarch
|
|
|
|
// Sadly, Docker desktop has some issues with permissions that causes wrong exe bits
|
|
// which breaks the build checks, and produces incorrect packages
|
|
// "rpmbuild", "-bb", "--target", rpmarch,
|
|
// "--define", $"_topdir /build", "SOURCES/duplicati.spec"
|
|
]);
|
|
|
|
File.Move(Path.Combine(tmpbuild, "build.rpm"), rpmFile);
|
|
|
|
// Clean up
|
|
Directory.Delete(tmpbuild, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the Docker images for the specified targets with buildx
|
|
/// </summary>
|
|
/// <param name="baseDir">The base directory.</param>
|
|
/// <param name="buildRoot">The build root directory.</param>
|
|
/// <param name="targets">The package target.</param>
|
|
/// <param name="rtcfg">The runtime configuration.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
private static async Task BuildDockerImages(string baseDir, string buildRoot, IEnumerable<PackageTarget> targets, RuntimeConfig rtcfg)
|
|
{
|
|
// Make sure any dangling buildx instances are removed
|
|
try { await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"], codeIsError: _ => false, suppressStdErr: true); }
|
|
catch { }
|
|
|
|
// Prepare multi-build
|
|
await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "create", "--use", "--name", "duplicati-builder"]);
|
|
|
|
// Perform a distict build for each interface type, but keep the buildx instance
|
|
foreach (var interfaceType in Enum.GetValues<InterfaceType>())
|
|
{
|
|
var buildTargets = targets.Where(x => x.Interface == interfaceType);
|
|
if (!buildTargets.Any())
|
|
continue;
|
|
|
|
var dockerArchs = buildTargets.Select(target => target switch
|
|
{
|
|
PackageTarget { Arch: ArchType.x64, OS: OSType.Linux } => "linux/amd64",
|
|
PackageTarget { Arch: ArchType.Arm64, OS: OSType.Linux } => "linux/arm64/v8",
|
|
PackageTarget { Arch: ArchType.Arm7, OS: OSType.Linux } => "linux/arm/v7",
|
|
_ => throw new Exception($"Unsupported Docker target: {target.OS}/{target.Arch} ({target.Interface})")
|
|
})
|
|
.ToList();
|
|
|
|
var resourcesDir = Path.Combine(baseDir, "ReleaseBuilder", "Resources", "Docker");
|
|
var tmpbuild = Path.Combine(buildRoot, "tmp-docker");
|
|
if (Directory.Exists(tmpbuild))
|
|
Directory.Delete(tmpbuild, true);
|
|
Directory.CreateDirectory(tmpbuild);
|
|
|
|
// Copy in the source data
|
|
foreach (var target in buildTargets)
|
|
{
|
|
// Mapping to the Docker TARGETARCH value
|
|
var dockerShortArch = target.Arch switch
|
|
{
|
|
ArchType.x64 => "amd64",
|
|
ArchType.Arm64 => "arm64",
|
|
ArchType.Arm7 => "arm",
|
|
_ => throw new Exception($"Unsupported Docker target: {target.Arch}")
|
|
};
|
|
|
|
var tgfolder = Path.Combine(tmpbuild, dockerShortArch);
|
|
|
|
EnvHelper.CopyDirectory(Path.Combine(buildRoot, target.BuildTargetString), tgfolder, recursive: true);
|
|
await PackageSupport.InstallPackageIdentifier(tgfolder, target);
|
|
await PackageSupport.RenameExecutables(tgfolder);
|
|
await PackageSupport.SetPermissionFlags(tgfolder, rtcfg);
|
|
}
|
|
|
|
var tags = new List<string> { rtcfg.ReleaseInfo.Channel.ToString(), rtcfg.ReleaseInfo.Version.ToString(), $"{rtcfg.ReleaseInfo.Version}-{rtcfg.ReleaseInfo.Channel}" };
|
|
if (rtcfg.ReleaseInfo.Channel == ReleaseChannel.Stable)
|
|
tags.Add("latest");
|
|
|
|
if (!dockerArchs.Any())
|
|
continue;
|
|
|
|
var dockerFile = Path.Combine(resourcesDir, interfaceType == InterfaceType.Agent ? "Dockerfile-agent" : "Dockerfile");
|
|
var repo = $"{rtcfg.DockerRepo}{(interfaceType == InterfaceType.Agent ? "-agent" : "")}";
|
|
|
|
// Build the images
|
|
var args = new List<string> { rtcfg.Configuration.Commands.Docker!, "buildx", "build" };
|
|
args.AddRange(tags.SelectMany(x => new[] { "-t", $"{repo}:{x.ToLowerInvariant()}" }));
|
|
args.AddRange([
|
|
"--platform", string.Join(",", dockerArchs),
|
|
"--build-arg", $"VERSION={rtcfg.ReleaseInfo.Version}",
|
|
"--build-arg", $"CHANNEL={rtcfg.ReleaseInfo.Channel.ToString().ToLowerInvariant()}",
|
|
"--file", dockerFile,
|
|
"--output", $"type=image,push={rtcfg.PushToDocker.ToString().ToLowerInvariant()}",
|
|
"."
|
|
]);
|
|
|
|
// Run the build
|
|
await ProcessHelper.Execute(args, workingDirectory: tmpbuild);
|
|
|
|
// Clean up
|
|
Directory.Delete(tmpbuild, true);
|
|
}
|
|
|
|
// Clean up
|
|
await ProcessHelper.Execute([rtcfg.Configuration.Commands.Docker!, "buildx", "rm", "duplicati-builder"]);
|
|
}
|
|
}
|