Files
aeqw89.tools.Publish/aeqw89.tools.Publish/Destination.cs
T
qwsdcvghyu89 2490fb260d
Nightly Build and Release / Build and Release Nightly (push) Failing after 14s
Fixed destination logic; updated action to use .net11 preview 4; fixed broken .csproj.
2026-06-05 01:03:48 +10:00

204 lines
9.2 KiB
C#

using System.Diagnostics;
using System.Text;
using Renci.SshNet;
using Spectre.Console;
namespace aeqw89.tools.Publish;
sealed record DestinationContext(
ProgressTask Task,
ProgressContext ProgressContext,
MemoryStream Reader,
FileInfo PackageFile,
string Name,
long BufferSize,
long PackageSize
);
interface IDestination {
Task<Result<Success, ReadableError>> WaitForCompletion(CancellationToken ct = default);
}
sealed record LocalDestination(DestinationContext Context) : IDestination {
public async Task<Result<Success, ReadableError>> WaitForCompletion(CancellationToken ct = default) {
// Path = "$USER/local-%ctx.name/%pkg.filename"
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Context.Name, Context.PackageFile.Name);
try {
Directory.CreateDirectory(Path.GetDirectoryName(path)!); // create the directory
await using var writer = File.OpenWrite(path);
var buffer = new byte[Context.BufferSize];
int read;
do {
read = await Context.Reader.ReadAsync(buffer, ct);
writer.Write(buffer, 0, read);
Context.Task.Increment(read);
} while (read > 0);
return Success.AsResult();
} catch (Exception e) {
return new ReadableError(string.Format(Exceptions.generic_error, e), false);
}
}
}
sealed record CloudDestiantion(DestinationContext Context) : IDestination {
public async Task<Result<Success, ReadableError>> WaitForCompletion(CancellationToken ct = default) {
var connectionTask = Context.ProgressContext.AddTaskBefore($"Preparing cloud-{Context.Name}",
new ProgressTaskSettings() {
MaxValue = 100
}, Context.Task);
if (!SshHosts.TryGetHost(Context.Name, out var host)) {
return new ReadableError(string.Format(Exceptions.cloud_host_not_found.EscapeMarkup(), Context.Name), false);
}
var connectionInfo = SshHosts.GetConnection(Context.Name);
using var sshClient = new SshClient(connectionInfo);
if (!sshClient.IsConnected)
await sshClient.ConnectAsync(ct);
connectionTask.Increment(33); // ? One-Third of the way done (connection success).
var winC = sshClient.RunCommand("cmd /c ver");
var othC = sshClient.RunCommand("uname -s");
var os = (winC.ExitStatus, othC.ExitStatus) switch {
(0, _) => "windows",
(_, 0) => "linux",
_ => "unknown"
};
string remoteDirectory;
string packageFileDirectory;
if (os == "windows") {
var userDirC = sshClient.RunCommand("cmd /c echo %USERPROFILE%");
if (userDirC.ExitStatus != 0) { // * missing env variable case (could not work out where to place packages in remote).
return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os,
userDirC.Result), false);
}
var userDir = userDirC.Result.Trim();
remoteDirectory = RemotePath.Combine(RemoteOs.Windows, userDir, "dotnet-packages");
packageFileDirectory = RemotePath.Combine(RemoteOs.Windows, remoteDirectory, Context.PackageFile.Name);
var mkdirC = sshClient.RunCommand($"cmd /c if not exist \"{remoteDirectory}\" mkdir \"{remoteDirectory}\"");
if (mkdirC.ExitStatus != 0) { // * MKDir error case (make directory command failed on remote).
return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, remoteDirectory, Context.PackageFile.Name, os,
mkdirC.Result), false);
}
}
else if (os == "linux") {
var homeDirC = sshClient.RunCommand("printf %s \"$HOME\"");
if (homeDirC.ExitStatus != 0) { // * missing env variable case 2 (could not work out where to place packages in remote).
return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os,
homeDirC.Result), false);
}
var homeDir = homeDirC.Result.Trim(); // no CRLF on unix, but Trim() is safest
remoteDirectory = RemotePath.Combine(RemoteOs.Unix, homeDir, ".dotnet-packages");
packageFileDirectory = RemotePath.Combine(RemoteOs.Unix, remoteDirectory,
Context.PackageFile.Name);
// Use -p and single quotes to handle spaces safely
var mkdirC = sshClient.RunCommand($"mkdir -p '{remoteDirectory}'");
if (mkdirC.ExitStatus != 0) { // * MKDir error case (make directory command failed on remote).
return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, remoteDirectory, Context.PackageFile.Name,
os, mkdirC.Result), false);
}
}
else {
return new ReadableError(string.Format(Exceptions.failed_to_prepare_server_directory, "n/a", Context.PackageFile.Name, os,
"Unsupported OS"), false); // * MacOS failure path (or other unrecognizable oses).
}
connectionTask.Increment(33); // ? Two-Thirds of the way done (directory located and ready).
sshClient.Disconnect();
using var client = new SftpClient(connectionInfo);
if (!client.IsConnected)
await client.ConnectAsync(ct);
connectionTask.Increment(33); // ? Connection task complete (now proceeding to upload task).
connectionTask.StopTask();
await using var writer = client.OpenWrite(packageFileDirectory);
byte[] buffer = new byte[Context.BufferSize];
int read;
do {
read = await Context.Reader.ReadAsync(buffer, ct);
writer.Write(buffer, 0, read);
Context.Task.Increment(read);
} while (read > 0);
return Success.AsResult();
}
}
sealed record GitDestination(DestinationContext Context, string Source, string ApiKey, bool Verbose = false) : IDestination {
private static Result<string, ReadableError> GetApiKey(string host) {
var key = Environment.GetEnvironmentVariable($"git-{host}-packages-key");
if (key is null) return new ReadableError(string.Format("No key stored in EnvironmentVariables with name {0}", $"git-{host}-packages-key"), false);
return key.Ok();
}
public static Result<GitDestination, ReadableError> CreateForGitea(DestinationContext context, string repoOwner, bool verbose = false) {
// gitea source structure = https://gitea.example.com/api/packages/{owner}/nuget/index.json
var keyResult = GetApiKey("gitea");
if (keyResult is ReadableError error) return error;
return new GitDestination(context, $"https://git.main.qwsdcvghyu.com/api/packages/{repoOwner}/nuget/index.json", keyResult.Unwrap(), verbose).Ok();
}
public static Result<GitDestination, ReadableError> CreateForGithub(DestinationContext context, string repoOwner, bool verbose = false) {
// github source structure = https://nuget.pkg.github.com/NAMESPACE/index.json
// namespace usually = repoOwner
var keyResult = GetApiKey("github");
if (keyResult is ReadableError error) return error;
return new GitDestination(context, $"https://nuget.pkg.github.com/{repoOwner}/index.json", keyResult.Unwrap(), verbose).Ok();
}
public async Task<Result<Success, ReadableError>> WaitForCompletion(CancellationToken ct = default) {
using var p = Process.Start(new ProcessStartInfo() {
FileName = "dotnet",
Arguments = $"nuget push \"{Context.PackageFile.FullName}\" -s {Source} -k {ApiKey}",
WorkingDirectory = Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
});
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
StringBuilder errorLines = new();
p?.ErrorDataReceived += (sender, eventArgs) => {
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
errorLines.Append(eventArgs.Data);
};
p?.OutputDataReceived += (sender, eventArgs) => {
if (eventArgs.Data?.ToLower().Contains("press any key") == true)
cts.Cancel();
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
};
p?.BeginOutputReadLine();
p?.BeginErrorReadLine();
Context.Task.Increment(Context.PackageSize / 2);
if (p != null)
try {
await (p?.WaitForExitAsync(cts.Token) ?? Task.CompletedTask);
}
catch (OperationCanceledException) {
p?.Kill();
await (p?.WaitForExitAsync(ct));
}
if (p?.ExitCode != 0) {
Context.Task.StopTask();
return new ReadableError(errorLines.ToString().EscapeMarkup() + "\n" + string.Format(Exceptions.dotnet_nuget_push_failure, p?.ExitCode ?? -1), false);
}
Context.Task.Increment(Context.PackageSize / 2);
return Success.AsResult();
}
}