summaryrefslogtreecommitdiff
path: root/src/marlin.core
diff options
context:
space:
mode:
Diffstat (limited to 'src/marlin.core')
-rw-r--r--src/marlin.core/functional/GitBinaryClient.cs133
-rw-r--r--src/marlin.core/functional/GitCommandException.cs16
-rw-r--r--src/marlin.core/functional/IGitClient.cs69
-rw-r--r--src/marlin.core/functional/IGitCredential.cs18
-rw-r--r--src/marlin.core/functional/NoopAsyncDisposable.cs10
-rw-r--r--src/marlin.core/functional/SshKeyGitCredential.cs57
-rw-r--r--src/marlin.core/functional/TokenGitCredential.cs32
-rw-r--r--src/marlin.core/models/ISecret.cs11
-rw-r--r--src/marlin.core/models/ISourceSecretStore.cs9
-rw-r--r--src/marlin.core/models/Job.cs2
-rw-r--r--src/marlin.core/models/SecretKind.cs7
-rw-r--r--src/marlin.core/models/VersionControlSource.cs13
-rw-r--r--src/marlin.core/models/VersionControlledBlob.cs15
-rw-r--r--src/marlin.core/models/VersionControlledBranch.cs14
-rw-r--r--src/marlin.core/models/VersionControlledCommit.cs15
-rw-r--r--src/marlin.core/util/ISecretExtensions.cs12
16 files changed, 356 insertions, 77 deletions
diff --git a/src/marlin.core/functional/GitBinaryClient.cs b/src/marlin.core/functional/GitBinaryClient.cs
new file mode 100644
index 0000000..001f2c8
--- /dev/null
+++ b/src/marlin.core/functional/GitBinaryClient.cs
@@ -0,0 +1,133 @@
+namespace marlin.core.functional;
+
+using System.Diagnostics;
+
+public class GitBinaryClient : IGitClient
+{
+ private readonly IGitCredential? _credential;
+
+ public GitBinaryClient(IGitCredential? credential = null)
+ {
+ this._credential = credential;
+ }
+
+ public async Task<Dictionary<string, string>> GetRemoteRefs(string remote)
+ {
+ string stdout = await this.RunAsync(
+ authenticate: true,
+ "ls-remote", "--heads", "--tags", remote);
+
+ return ParseRefs(stdout);
+ }
+
+ public async Task Init(string repoPath)
+ {
+ Directory.CreateDirectory(repoPath);
+ await this.RunAsync(authenticate: false, "init", "--bare", repoPath);
+ }
+
+ public async Task FetchTrees(string repoPath, string remote, IEnumerable<string> refs, bool skipBlobs = false)
+ {
+ var refList = refs as IReadOnlyCollection<string> ?? refs.ToArray();
+ if (refList.Count == 0)
+ return;
+
+ var args = new List<string> { "-C", repoPath, "fetch" };
+ if (skipBlobs)
+ args.Add("--filter=blob:none");
+ args.Add(remote);
+ args.AddRange(refList);
+
+ await this.RunAsync(authenticate: true, args.ToArray());
+ }
+
+ public async Task<string[]> ListTree(string repoPath, string hash, string filterPath = ".")
+ {
+ string stdout = await this.RunAsync(
+ authenticate: false,
+ "-C", repoPath, "ls-tree", "-r", "--name-only", hash, "--", filterPath);
+
+ return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ public Task<byte[]> CatFile(string repoPath, string hash, string fileName)
+ {
+ return this.RunRawAsync(
+ authenticate: false,
+ "-C", repoPath, "show", $"{hash}:{fileName}");
+ }
+
+ private Task<string> RunAsync(bool authenticate, params string[] arguments) =>
+ this.RunAsync(authenticate, async stream =>
+ {
+ using var reader = new StreamReader(stream);
+ return await reader.ReadToEndAsync();
+ }, arguments);
+
+ private Task<byte[]> RunRawAsync(bool authenticate, params string[] arguments) =>
+ this.RunAsync(authenticate, async stream =>
+ {
+ using var buffer = new MemoryStream();
+ await stream.CopyToAsync(buffer);
+ return buffer.ToArray();
+ }, arguments);
+
+ private async Task<T> RunAsync<T>(bool authenticate, Func<Stream, Task<T>> readStdout, string[] arguments)
+ {
+ var psi = new ProcessStartInfo("git")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ foreach (string arg in arguments)
+ psi.ArgumentList.Add(arg);
+
+ psi.Environment["GIT_TERMINAL_PROMPT"] = "0";
+
+ IAsyncDisposable? credentialScope = authenticate && this._credential != null
+ ? await this._credential.ApplyAsync(psi)
+ : null;
+
+ try
+ {
+ using var process = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to start git process.");
+
+ Task<T> stdoutTask = readStdout(process.StandardOutput.BaseStream);
+ Task<string> stderrTask = process.StandardError.ReadToEndAsync();
+
+ await process.WaitForExitAsync();
+ T stdout = await stdoutTask;
+ string stderr = await stderrTask;
+
+ if (process.ExitCode != 0)
+ throw new GitCommandException(string.Join(' ', arguments), process.ExitCode, stderr);
+
+ return stdout;
+ }
+ finally
+ {
+ if (credentialScope != null)
+ await credentialScope.DisposeAsync();
+ }
+ }
+
+ private static Dictionary<string, string> ParseRefs(string stdout)
+ {
+ var result = new Dictionary<string, string>();
+
+ foreach (string line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ string[] parts = line.Split('\t');
+ if (parts.Length != 2)
+ continue; // ignore a malformed line rather than throw over one bad entry
+
+ result[parts[1]] = parts[0];
+ }
+
+ return result;
+ }
+}
diff --git a/src/marlin.core/functional/GitCommandException.cs b/src/marlin.core/functional/GitCommandException.cs
new file mode 100644
index 0000000..e2ee42d
--- /dev/null
+++ b/src/marlin.core/functional/GitCommandException.cs
@@ -0,0 +1,16 @@
+namespace marlin.core.functional;
+
+public sealed class GitCommandException : Exception
+{
+ public string Command { get; }
+ public int ExitCode { get; }
+ public string StandardError { get; }
+
+ public GitCommandException(string command, int exitCode, string standardError)
+ : base($"git {command} exited with code {exitCode}: {standardError.Trim()}")
+ {
+ this.Command = command;
+ this.ExitCode = exitCode;
+ this.StandardError = standardError;
+ }
+}
diff --git a/src/marlin.core/functional/IGitClient.cs b/src/marlin.core/functional/IGitClient.cs
index 04dc12a..69cffa3 100644
--- a/src/marlin.core/functional/IGitClient.cs
+++ b/src/marlin.core/functional/IGitClient.cs
@@ -1,71 +1,10 @@
namespace marlin.core.functional;
-using System.Diagnostics;
-
public interface IGitClient
{
Task<Dictionary<string, string>> GetRemoteRefs(string remote);
-}
-
-public class GitBinaryClient : IGitClient
-{
- public virtual async Task<Dictionary<string, string>> GetRemoteRefs(string remote)
- {
- var psi = this.CreateProcess(remote);
- var proc = Process.Start(psi)!;
-
- string stdout = await proc.StandardOutput.ReadToEndAsync();
- await proc.WaitForExitAsync();
-
- return ParseOutput(stdout);
- }
-
- private Dictionary<string, string> ParseOutput(string stdout)
- {
- return stdout
- .Split("\n", StringSplitOptions.RemoveEmptyEntries)
- .Select(x => x.Split('\t'))
- .ToDictionary(k => k[1], k => k[0]);
- }
-
- protected virtual ProcessStartInfo CreateProcess(string remote)
- {
- var psi = new ProcessStartInfo("git", $"ls-remote --heads --tags {remote}")
- {
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- Environment = { ["GIT_TERMINAL_PROMPT"] = "0" }
- };
-
- return psi;
- }
-
- /*private async Task SshAuth(string privateKeyPem)
- {
- string keyPath = Path.Combine(Path.GetTempPath(), $"key_{Guid.NewGuid():N}");
- await File.WriteAllTextAsync(keyPath, privateKeyPem);
- File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
-
- try
- {
- psi.Environment["GIT_SSH_COMMAND"] =
- $"ssh -i {keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new";
- // run ls-remote against the ssh:// or git@ URL
- }
- finally
- {
- File.Delete(keyPath);
- }
- }
-
- private async Task BasicAuth(string accessToken)
- {
- psi.Environment["GIT_CONFIG_COUNT"] = "1";
- psi.Environment["GIT_CONFIG_KEY_0"] = "http.extraheader";
- psi.Environment["GIT_CONFIG_VALUE_0"] =
- $"AUTHORIZATION: basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{accessToken}"))}";
-
- }*/
+ Task Init(string repoPath);
+ Task FetchTrees(string repoPath, string remote, IEnumerable<string> refs, bool skipBlobs = false);
+ Task<string[]> ListTree(string repoPath, string hash, string filterPath = ".");
+ Task<byte[]> CatFile(string repoPath, string hash, string fileName);
} \ No newline at end of file
diff --git a/src/marlin.core/functional/IGitCredential.cs b/src/marlin.core/functional/IGitCredential.cs
new file mode 100644
index 0000000..cf96804
--- /dev/null
+++ b/src/marlin.core/functional/IGitCredential.cs
@@ -0,0 +1,18 @@
+namespace marlin.core.functional;
+
+using System.Diagnostics;
+
+/// <summary>
+/// Supplies whatever git needs to authenticate a single invocation — an HTTP header,
+/// an SSH key, etc. Credentials are applied per-process via environment variables only;
+/// nothing is written to git config, argv, or any credential store.
+/// </summary>
+public interface IGitCredential
+{
+ /// <summary>
+ /// Configures <paramref name="psi"/> so the git process it starts can authenticate.
+ /// Returns a disposable that tears down anything ephemeral (e.g. a temp key file)
+ /// once that process has exited.
+ /// </summary>
+ Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi);
+}
diff --git a/src/marlin.core/functional/NoopAsyncDisposable.cs b/src/marlin.core/functional/NoopAsyncDisposable.cs
new file mode 100644
index 0000000..330a197
--- /dev/null
+++ b/src/marlin.core/functional/NoopAsyncDisposable.cs
@@ -0,0 +1,10 @@
+namespace marlin.core.functional;
+
+internal sealed class NoopAsyncDisposable : IAsyncDisposable
+{
+ public static readonly NoopAsyncDisposable Instance = new();
+
+ private NoopAsyncDisposable() { }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
diff --git a/src/marlin.core/functional/SshKeyGitCredential.cs b/src/marlin.core/functional/SshKeyGitCredential.cs
new file mode 100644
index 0000000..9183a66
--- /dev/null
+++ b/src/marlin.core/functional/SshKeyGitCredential.cs
@@ -0,0 +1,57 @@
+namespace marlin.core.functional;
+
+using System.Diagnostics;
+
+/// <summary>
+/// SSH auth via a private key. The key is written to a per-invocation temp file
+/// (owner-read/write only) and deleted as soon as the git process that used it exits —
+/// ssh has no clean way to take key material via stdin/env, so a short-lived file is
+/// the least-bad option.
+/// </summary>
+public sealed class SshKeyGitCredential : IGitCredential
+{
+ private readonly string _privateKeyPem;
+
+ public SshKeyGitCredential(string privateKeyPem)
+ {
+ this._privateKeyPem = privateKeyPem;
+ }
+
+ public async Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi)
+ {
+ string keyPath = Path.Combine(Path.GetTempPath(), $"marlin_{Guid.NewGuid():N}.key");
+ await File.WriteAllTextAsync(keyPath, this._privateKeyPem);
+
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(keyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+
+ psi.Environment["GIT_SSH_COMMAND"] =
+ $"ssh -i \"{keyPath}\" -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new";
+
+ return new TempKeyFile(keyPath);
+ }
+
+ private sealed class TempKeyFile : IAsyncDisposable
+ {
+ private readonly string _path;
+
+ public TempKeyFile(string path)
+ {
+ this._path = path;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ try
+ {
+ File.Delete(this._path);
+ }
+ catch (IOException)
+ {
+ // best-effort cleanup; a leftover temp file is not worth failing the caller over
+ }
+
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/src/marlin.core/functional/TokenGitCredential.cs b/src/marlin.core/functional/TokenGitCredential.cs
new file mode 100644
index 0000000..5dfef66
--- /dev/null
+++ b/src/marlin.core/functional/TokenGitCredential.cs
@@ -0,0 +1,32 @@
+namespace marlin.core.functional;
+
+using System.Diagnostics;
+using System.Text;
+
+/// <summary>
+/// HTTPS auth via a bearer/basic token. Injected as an Authorization header for a
+/// single git invocation through GIT_CONFIG_* env vars, so it never appears in argv
+/// (visible via ps) or in git's own error output (which echoes the URL, not headers).
+/// </summary>
+public sealed class TokenGitCredential : IGitCredential
+{
+ private readonly string _username;
+ private readonly string _token;
+
+ public TokenGitCredential(string token, string username = "x-access-token")
+ {
+ this._token = token;
+ this._username = username;
+ }
+
+ public Task<IAsyncDisposable> ApplyAsync(ProcessStartInfo psi)
+ {
+ string header = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{this._username}:{this._token}"));
+
+ psi.Environment["GIT_CONFIG_COUNT"] = "1";
+ psi.Environment["GIT_CONFIG_KEY_0"] = "http.extraheader";
+ psi.Environment["GIT_CONFIG_VALUE_0"] = $"AUTHORIZATION: basic {header}";
+
+ return Task.FromResult<IAsyncDisposable>(NoopAsyncDisposable.Instance);
+ }
+}
diff --git a/src/marlin.core/models/ISecret.cs b/src/marlin.core/models/ISecret.cs
new file mode 100644
index 0000000..e41a3b9
--- /dev/null
+++ b/src/marlin.core/models/ISecret.cs
@@ -0,0 +1,11 @@
+namespace marlin.web.Models;
+
+public interface ISecret
+{
+ Guid Id { get; set; }
+ string Label { get; set; }
+ string? Kind { get; set; }
+ bool MayReveal { get; set; }
+
+ Dictionary<string, string> Content { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.core/models/ISourceSecretStore.cs b/src/marlin.core/models/ISourceSecretStore.cs
new file mode 100644
index 0000000..0d5053e
--- /dev/null
+++ b/src/marlin.core/models/ISourceSecretStore.cs
@@ -0,0 +1,9 @@
+namespace marlin.web.Models;
+
+using core.functional;
+using core.models;
+
+public interface ISourceSecretStore
+{
+ Task<ISecret?> GetAsync(Guid id, CancellationToken ct);
+} \ No newline at end of file
diff --git a/src/marlin.core/models/Job.cs b/src/marlin.core/models/Job.cs
index 6b09597..a8c7c5d 100644
--- a/src/marlin.core/models/Job.cs
+++ b/src/marlin.core/models/Job.cs
@@ -14,7 +14,7 @@ public class Job
public JobStatus Status { get; set; }
public ICollection<JobStatusUpdate> StatusUpdates { get; set; }
- public Guid AgentId { get; set; }
+ public Guid? AgentId { get; set; }
public Agent? Agent { get; set; }
public string Recipe { get; set; }
diff --git a/src/marlin.core/models/SecretKind.cs b/src/marlin.core/models/SecretKind.cs
new file mode 100644
index 0000000..02652d5
--- /dev/null
+++ b/src/marlin.core/models/SecretKind.cs
@@ -0,0 +1,7 @@
+namespace marlin.web.Models;
+
+public static class SecretKind
+{
+ public const string HTTPToken = "http-token";
+ public const string SSHKey = "ssh-key";
+} \ No newline at end of file
diff --git a/src/marlin.core/models/VersionControlSource.cs b/src/marlin.core/models/VersionControlSource.cs
index 1d50ef3..daa8d51 100644
--- a/src/marlin.core/models/VersionControlSource.cs
+++ b/src/marlin.core/models/VersionControlSource.cs
@@ -9,16 +9,7 @@ public class VersionControlSource
public string BranchFilter { get; set; }
public int MinPollingInterval { get; set; }
- public ICollection<VersionControlledBranch> Branches { get; set; }
-}
-
-public class VersionControlledBranch
-{
- public Guid Id { get; set; }
-
- public Guid SourceId { get; set; }
- public VersionControlSource Source { get; set; }
+ public Guid? AccessSecretId { get; set; }
- public string Name { get; set; }
- public string HeadCommit { get; set; }
+ public ICollection<VersionControlledBranch> Branches { get; set; }
} \ No newline at end of file
diff --git a/src/marlin.core/models/VersionControlledBlob.cs b/src/marlin.core/models/VersionControlledBlob.cs
new file mode 100644
index 0000000..03915be
--- /dev/null
+++ b/src/marlin.core/models/VersionControlledBlob.cs
@@ -0,0 +1,15 @@
+namespace marlin.core.models;
+
+public class VersionControlledBlob
+{
+ public Guid Id { get; set; }
+
+ public Guid SourceId { get; set; }
+ public VersionControlSource Source { get; set; }
+
+ public Guid CommitId { get; set; }
+ public VersionControlledCommit Commit { get; set; }
+
+ public string Path { get; set; }
+ public byte[] Content { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.core/models/VersionControlledBranch.cs b/src/marlin.core/models/VersionControlledBranch.cs
new file mode 100644
index 0000000..601f527
--- /dev/null
+++ b/src/marlin.core/models/VersionControlledBranch.cs
@@ -0,0 +1,14 @@
+namespace marlin.core.models;
+
+public class VersionControlledBranch
+{
+ public Guid Id { get; set; }
+
+ public Guid SourceId { get; set; }
+ public VersionControlSource Source { get; set; }
+
+ public Guid CommitId { get; set; }
+ public VersionControlledCommit Commit { get; set; }
+
+ public string Name { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.core/models/VersionControlledCommit.cs b/src/marlin.core/models/VersionControlledCommit.cs
new file mode 100644
index 0000000..a7b5e9e
--- /dev/null
+++ b/src/marlin.core/models/VersionControlledCommit.cs
@@ -0,0 +1,15 @@
+namespace marlin.core.models;
+
+public class VersionControlledCommit
+{
+ public Guid Id { get; set; }
+
+ public Guid SourceId { get; set; }
+ public VersionControlSource Source { get; set; }
+ public bool BlobsFetched { get; set; }
+
+ public string Hash { get; set; }
+
+ public ICollection<VersionControlledBranch> Branches { get; set; }
+ public ICollection<VersionControlledBlob> Blobs { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.core/util/ISecretExtensions.cs b/src/marlin.core/util/ISecretExtensions.cs
new file mode 100644
index 0000000..e40c77c
--- /dev/null
+++ b/src/marlin.core/util/ISecretExtensions.cs
@@ -0,0 +1,12 @@
+namespace marlin.core.util;
+
+using System.Runtime.CompilerServices;
+using web.Models;
+
+public static class ISecretExtensions
+{
+ public static bool Has(this ISecret secret, params string[] keys)
+ {
+ return keys.All(x => secret.Content.ContainsKey(x));
+ }
+} \ No newline at end of file