using System.Text.RegularExpressions; using CliWrap; using InnovEnergy.Lib.Utils; using static InnovEnergy.Server.FirmwareCiDaemon.Logger; using static InnovEnergy.Server.FirmwareCiDaemon.ExitException; namespace InnovEnergy.Server.FirmwareCiDaemon; public record Build { private static readonly Regex RxTimestamp = new Regex(@"\d{14}"); private static readonly Regex RxVersion = new Regex(@"v\d+\.\d+(~\w+)?"); private const Int32 MaxPublishedSwus = 12; public Device Device { get; init; } public String VeVersion { get; init; } public Branch Branch { get; init; } public String Comment { get; init; } public String Hash { get; init; } public String ShortHash => Hash[..6]; public Channel Channel => Branch switch { Branch.Victron => Channel.Testing, Branch.Release => Channel.Release, Branch.Develop => Channel.Develop, _ => throw new Exception($"Unsupported branch: {Branch}") }; public String VersionId => Branch switch { Branch.Victron => $"{VeVersion}~victron", Branch.Release => $"{VeVersion}~{Comment}", Branch.Develop => $"{VeVersion}~{ShortHash}", _ => throw new Exception($"Unsupported branch: {Branch}") }; public static Build Prepare(CommitInfo ie, (Device device, String veVersion) ve) { return new Build { Device = ve.device, VeVersion = ve.veVersion, Branch = ie.Branch, Comment = ie.Comment, Hash = ie.Hash }; } public void Execute() { Log(); Log("======================================================"); Log("Starting build"); Log("======================================================"); Log(); Log($"VE Version: {VeVersion}"); Log($"Channel : {Channel}"); Log($"Device : {Device}"); Log($"Branch : {Branch}"); Log($"Comment : {Comment}"); Log($"Version ID: {VersionId}"); Log($"SWU Name : {Device.SwuName()}"); Log($"Hash : {Hash}"); Log(); Log("======================================================"); Log(); using var veBaseSwuFile = FwSource.Victron.GetLatestSwuPath(Channel.Release, Device).Apply(DownloadFile); using var ieRepo = Fossil.Checkout(Branch); using var releaseSwu = MergeSwu(veBaseSwuFile, ieRepo.FirmwareDirectory, Device, VersionId); var publishedSwu = PublishSwu(Device, Channel, releaseSwu); RemoveOldFiles(Device, Channel); var zipPath = Channel.ZipPath(); var removeGlob = Device.SwuBase() + "*"; if (zipPath is null) return; Log($"Updating {zipPath[Program.IeBasePath.Length..]}"); zipPath.RemoveFromZip(removeGlob); zipPath.AddToZip(publishedSwu); } public static Disposable DownloadFile(String url) { var fileName = Path.GetFileName(url); var downloadedFile = FileSystem.CreateTempFile(fileName); Log($"downloading {url}"); var curl = Cli .Wrap("curl") .WithArguments(url) .PipeToFile(downloadedFile); if (curl.exitCode != 0) Exit("Failed to download " + url); return downloadedFile; } private static void UpdateVersionFile(String fwDir, String version, String timestamp, String file) { Log($"updating {file}"); var fwFile = fwDir.AppendPath(file); if (!FileSystem.Local.FileExists(fwFile)) Exit($"Cannot find {fwFile}"); var contents = File.ReadAllText(fwFile); contents = RxTimestamp.Replace(contents, timestamp); contents = RxVersion.Replace(contents, version); try { File.WriteAllText(fwFile, contents); } catch { Exit($"Failed to write to {fwFile}"); } } private static void PatchIeFiles(String fossilDir, String mountDir) { Log("applying changes"); var (exitCode, stdOut, stdErr) = Cli .Wrap("rsync") .WithArguments($"-r -t -v -l -i -u -I {fossilDir}/ {mountDir}") // that / is important! .ExecuteSync(); if (exitCode != 0) Exit($"Failed to apply changes!\n{stdErr}\n{stdOut}"); } private static Disposable Unzip(String ext4GzFile) { var ext4GzFileName = Path.GetFileName(ext4GzFile); Log($"extracting {ext4GzFileName}"); var ext4File = ext4GzFileName .RemoveSuffix(".gz") .Apply(FileSystem.CreateTempFile); var zcat = Cli .Wrap("zcat") .WithArguments(ext4GzFile) .PipeToFile(ext4File); if (zcat.exitCode != 0) { ext4File.Dispose(); Exit("Failed to extract " + ext4GzFile); } return ext4File; } private static Disposable Mount(String ext4File) { var ext4FileName = Path.GetFileName(ext4File); Log($"mounting {ext4FileName}"); var mountDir = FileSystem.CreateTmpDir(); var mount = Cli .Wrap("mount") .WithArguments($"-o loop -t ext4 {ext4File} {mountDir}") .ExecuteSync(); if (mount.exitCode != 0) { Log($"\nFailed to mount {ext4File} on {mountDir}:\n{mount.stdErr}"); mountDir.Dispose(); Exit($"\nFailed to mount {ext4File}:\n{mount.stdErr}"); } return mountDir.BeforeDisposeDo(Unmount); void Unmount() { Log($"unmounting {ext4FileName}"); var umount = Cli .Wrap("umount") .WithArguments(mountDir) .ExecuteSync(); if (umount.exitCode != 0) Exit($"Failed to unmount {mountDir}\n{umount.stdErr}"); } } private static void ApplyChanges(String ieFirmwareDir, String ext4File, String timestamp, String version) { using var mountDir = Mount(ext4File); // IMPORTANT: must unmount (dispose) before zipping ext4 file again! PatchIeFiles(ieFirmwareDir, mountDir); UpdateVersionFile(mountDir, version, timestamp, "/etc/issue"); UpdateVersionFile(mountDir, version, timestamp, "/etc/issue.net"); UpdateVersionFile(mountDir, version, timestamp, "/etc/version"); UpdateVersionFile(mountDir, version, timestamp, "/opt/victronenergy/version"); } private static void UpdateSymlink(String newSwuFile, String newSwuLink) { var ln = Cli .Wrap("ln") .WithArguments($"-sfn {newSwuFile} {newSwuLink}") .ExecuteSync(); if (ln.exitCode != 0) Exit($"failed to update symlink {newSwuLink}"); } private static void RemoveOldFiles(Device device, Channel channel) { var oldFiles = Directory .GetFiles(FwSource.InnovEnergy.GetDirectory(channel, device)) .Where(f => f.EndsWith(".swu")) .OrderByDescending(File.GetCreationTimeUtc) .Skip(MaxPublishedSwus); foreach (var file in oldFiles) { Log($"Deleting old swu file {file.Substring(Program.IeBasePath.Length)}"); File.Delete(file); } } private static String PublishSwu(Device device, Channel channel, String releaseSwu) { var swuFileName = Path.GetFileName(releaseSwu); var newSwuFile = FwSource.InnovEnergy.GetDirectory(channel, device).AppendPath(swuFileName); var newSwuLink = FwSource.InnovEnergy.GetLatestSwuPath(channel, device); Log($"publishing {newSwuFile[Program.IeBasePath.Length..]}"); new FileInfo(newSwuFile).Directory?.Create(); // create dir if not exits File.Move(releaseSwu, newSwuFile); UpdateSymlink(newSwuFile, newSwuLink); return newSwuFile; } private static Disposable MergeSwu(String victronBaseSwuFile, String ieFirmwareDir, Device device, String version) { var timestamp = DateTime.Now.ToString("yyyyMMddHHmmss"); using var cpioDir = Cpio.Extract(victronBaseSwuFile); var ext4GzFile = cpioDir.Value.AppendPath(device.ExtGzFileName()); using var ext4File = Unzip(ext4GzFile); UpdateVersionFile(cpioDir, version, timestamp, "/sw-description"); ApplyChanges(ieFirmwareDir, ext4File, timestamp, version); Zip.GZip(ext4File, ext4GzFile); var mergedSwuFile = device .LongSwuFileName(timestamp, version) .Apply(FileSystem.CreateTempFile); Cpio.Write(cpioDir, mergedSwuFile); return mergedSwuFile; } }