summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMelodi <62381002+Melodi17@users.noreply.github.com>2026-09-06 09:02:31 +1000
committerMelodi <62381002+Melodi17@users.noreply.github.com>2026-09-06 09:02:31 +1000
commita9bc03fb01b5d1ac41bf5592f384d22df5425aa4 (patch)
tree16ae13e19d2a3cd7488202c30790d09557559d70 /src
parentc00992ffad24212c4398736efd6d707d4cb0cc0d (diff)
feat: vcs and secret management support
Diffstat (limited to 'src')
-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
-rw-r--r--src/marlin.web/Controllers/JobsController.cs31
-rw-r--r--src/marlin.web/Controllers/VCSController.cs92
-rw-r--r--src/marlin.web/Data/ApplicationDbContext.cs32
-rw-r--r--src/marlin.web/Migrations/20260902214847_InitialCreate.Designer.cs53
-rw-r--r--src/marlin.web/Migrations/20260902214847_InitialCreate.cs35
-rw-r--r--src/marlin.web/Migrations/20260903141622_InitialCreate.Designer.cs259
-rw-r--r--src/marlin.web/Migrations/20260903141622_InitialCreate.cs154
-rw-r--r--src/marlin.web/Migrations/20260905011100_AddCommitModel.Designer.cs376
-rw-r--r--src/marlin.web/Migrations/20260905011100_AddCommitModel.cs126
-rw-r--r--src/marlin.web/Migrations/20260905102259_AddSecretModel.Designer.cs430
-rw-r--r--src/marlin.web/Migrations/20260905102259_AddSecretModel.cs59
-rw-r--r--src/marlin.web/Migrations/20260905104308_BlobNowBinary.Designer.cs430
-rw-r--r--src/marlin.web/Migrations/20260905104308_BlobNowBinary.cs40
-rw-r--r--src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs379
-rw-r--r--src/marlin.web/Models/DatabaseSourceSecretStore.cs27
-rw-r--r--src/marlin.web/Program.cs8
-rw-r--r--src/marlin.web/Services/VersionControlSyncService.cs201
-rw-r--r--src/marlin.web/Views/Jobs/Details.cshtml5
-rw-r--r--src/marlin.web/Views/Jobs/List.cshtml4
-rw-r--r--src/marlin.web/Views/Jobs/Spawn.cshtml56
-rw-r--r--src/marlin.web/Views/Shared/_Layout.cshtml5
-rw-r--r--src/marlin.web/Views/VCS/Create.cshtml8
-rw-r--r--src/marlin.web/Views/VCS/CreateSecret.cshtml12
-rw-r--r--src/marlin.web/Views/VCS/Details.cshtml44
-rw-r--r--src/marlin.web/Views/VCS/List.cshtml2
-rw-r--r--src/marlin.web/Views/VCS/ListSecret.cshtml45
-rw-r--r--src/marlin.web/appsettings.json3
-rw-r--r--src/marlin.web/marlin.web.csproj4
-rw-r--r--src/marlin.web/wwwroot/css/site.css20
45 files changed, 3175 insertions, 198 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
diff --git a/src/marlin.web/Controllers/JobsController.cs b/src/marlin.web/Controllers/JobsController.cs
index 1cfdadb..c86cb2d 100644
--- a/src/marlin.web/Controllers/JobsController.cs
+++ b/src/marlin.web/Controllers/JobsController.cs
@@ -30,10 +30,23 @@ public class JobsController(ApplicationDbContext dbContext) : Controller
return this.View(results);
}
+ public record JobSpawnDetails(
+ ICollection<VersionControlSource> Sources,
+ Guid? Vcs = null,
+ string? Ref = null,
+ string? Commit = null,
+ string? Recipe = null);
+
[HttpGet("spawn")]
- public async Task<IActionResult> Spawn()
+ public async Task<IActionResult> Spawn(CancellationToken ct)
{
- return this.View();
+ var sources = await dbContext.Sources
+ .Include(x => x.Branches)
+ .ThenInclude(x => x.Commit)
+ .ToListAsync(ct);
+
+ var details = new JobSpawnDetails(sources);
+ return this.View(details);
}
public record JobSpawnRequest(Guid Vcs, string Ref, string Commit, string Recipe);
@@ -62,4 +75,18 @@ public class JobsController(ApplicationDbContext dbContext) : Controller
// cancel job
// details job (recipe, status, agent, target, plan, log, artifacts)
+ [HttpGet("details/{id}")]
+ public async Task<IActionResult> Details(Guid id)
+ {
+ var job = await dbContext.Jobs
+ .Include(x => x.Agent)
+ .Include(x => x.Source)
+ .Include(x => x.StatusUpdates)
+ .FirstOrDefaultAsync(x => x.Id == id);
+
+ if (job == null)
+ return this.NotFound();
+
+ return this.View(job);
+ }
} \ No newline at end of file
diff --git a/src/marlin.web/Controllers/VCSController.cs b/src/marlin.web/Controllers/VCSController.cs
index 86f655b..7370e0d 100644
--- a/src/marlin.web/Controllers/VCSController.cs
+++ b/src/marlin.web/Controllers/VCSController.cs
@@ -1,33 +1,47 @@
namespace marlin.web.Controllers;
+using System.Text.Json;
using core;
using core.models;
using Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Models;
+using Services;
[Route("vcs")]
-public class VCSController(ApplicationDbContext dbContext) : Controller
+public class VCSController(ApplicationDbContext dbContext, VersionControlSyncService syncService) : Controller
{
[HttpGet("list")]
public async Task<IActionResult> List(CancellationToken ct)
{
var items = await dbContext.Sources
+ .Include(x => x.Branches)
.ToListAsync(ct);
return this.View(items);
}
+ public record SecretOption(Guid Id, string Label);
+
[HttpGet("details/{id}")]
public async Task<IActionResult> Details(Guid id, CancellationToken ct)
{
var item = await dbContext.Sources
+ .Include(x => x.Branches)
+ .ThenInclude(x => x.Commit)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item == null)
return this.NotFound();
- return this.View(item);
+ // Only Id/Label projected — a secret's Content never needs to leave the secrets pages.
+ var secretOptions = await dbContext.Secrets
+ .Where(x => x.SourceId == id)
+ .Select(x => new SecretOption(x.Id, x.Label))
+ .ToListAsync(ct);
+
+ return this.View((item, secretOptions));
}
[HttpPost("edit/{id}")]
@@ -43,8 +57,10 @@ public class VCSController(ApplicationDbContext dbContext) : Controller
item.BranchFilter = vcs.BranchFilter;
item.MainBranch = vcs.MainBranch;
item.MinPollingInterval = vcs.MinPollingInterval;
+ item.AccessSecretId = vcs.AccessSecretId;
await dbContext.SaveChangesAsync(ct);
+ await syncService.FetchSources(ct);
return this.RedirectToAction("List");
}
@@ -62,7 +78,79 @@ public class VCSController(ApplicationDbContext dbContext) : Controller
await dbContext.AddAsync(vcs, ct);
await dbContext.SaveChangesAsync(ct);
+ await syncService.FetchSources(ct);
return this.RedirectToAction("Details", new { id = vcs.Id });
}
+
+ public record SecretCreateRequest(string Label, string? Kind, bool MayReveal, string ContentJson);
+
+ [HttpGet("details/{sourceId}/secrets/create")]
+ public async Task<IActionResult> CreateSecret(Guid sourceId, CancellationToken ct)
+ {
+ var source = await dbContext.Sources
+ .FirstOrDefaultAsync(x => x.Id == sourceId, ct);
+
+ if (source == null)
+ return this.NotFound();
+
+ return this.View(source);
+ }
+
+ [HttpPost("details/{sourceId}/secrets/create")]
+ public async Task<IActionResult> CreateSecretPost(Guid sourceId, SecretCreateRequest request, CancellationToken ct)
+ {
+ var source = await dbContext.Sources
+ .FirstOrDefaultAsync(x => x.Id == sourceId, ct);
+
+ if (source == null)
+ return this.NotFound();
+
+ var content = JsonSerializer.Deserialize<Dictionary<string, string>>(request.ContentJson) ?? new();
+
+ var secret = new VersionControlSecret
+ {
+ SourceId = sourceId,
+ Label = request.Label,
+ Kind = request.Kind,
+ MayReveal = request.MayReveal,
+ Content = content
+ };
+
+ await dbContext.Secrets.AddAsync(secret, ct);
+ await dbContext.SaveChangesAsync(ct);
+
+ return this.RedirectToAction("ListSecret", new { sourceId });
+ }
+
+ [HttpGet("details/{sourceId}/secrets/list")]
+ public async Task<IActionResult> ListSecret(Guid sourceId, CancellationToken ct)
+ {
+ var source = await dbContext.Sources
+ .FirstOrDefaultAsync(x => x.Id == sourceId, ct);
+
+ if (source == null)
+ return this.NotFound();
+
+ var items = await dbContext.Secrets
+ .Where(x => x.SourceId == source.Id)
+ .ToListAsync<ISecret>(ct);
+
+ return this.View((source, items));
+ }
+
+ [HttpPost("details/{sourceId}/secrets/delete/{id}")]
+ public async Task<IActionResult> DeleteSecret(Guid sourceId, Guid id, CancellationToken ct)
+ {
+ var secret = await dbContext.Secrets
+ .FirstOrDefaultAsync(x => x.SourceId == sourceId && x.Id == id, ct);
+
+ if (secret == null)
+ return this.NotFound();
+
+ dbContext.Secrets.Remove(secret);
+ await dbContext.SaveChangesAsync(ct);
+
+ return this.RedirectToAction("ListSecret", new { sourceId = sourceId });
+ }
} \ No newline at end of file
diff --git a/src/marlin.web/Data/ApplicationDbContext.cs b/src/marlin.web/Data/ApplicationDbContext.cs
index 41ec4bc..2e05835 100644
--- a/src/marlin.web/Data/ApplicationDbContext.cs
+++ b/src/marlin.web/Data/ApplicationDbContext.cs
@@ -1,15 +1,31 @@
namespace marlin.web.Data;
using System.Linq.Expressions;
+using System.Text.Json;
using core;
using core.models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Models;
+
+// user-based session grant
+// agent id/token grant
+// agent job claim session grant
+// service grant
+public interface IActiveGrant
+{
+ bool CanAccessSource(Guid sourceId);
+}
public class ApplicationDbContext : DbContext
{
public DbSet<VersionControlSource> Sources => this.Set<VersionControlSource>();
+ public DbSet<VersionControlledBranch> SourceBranches => this.Set<VersionControlledBranch>();
+ public DbSet<VersionControlledCommit> SourceCommits => this.Set<VersionControlledCommit>();
+ public DbSet<VersionControlledBlob> SourceBlobs => this.Set<VersionControlledBlob>();
+ public DbSet<VersionControlSecret> Secrets => this.Set<VersionControlSecret>();
public DbSet<Job> Jobs => this.Set<Job>();
+ public DbSet<Agent> Agents => this.Set<Agent>();
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
@@ -18,7 +34,21 @@ public class ApplicationDbContext : DbContext
{
modelBuilder
.Entity<Agent>()
- .OwnsOne(p => p.Capabilities, b => b.ToJson());
+ .Property(p => p.Capabilities)
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<CapabilitySet>(v, (JsonSerializerOptions?)null)!);
+
+ modelBuilder
+ .Entity<VersionControlledCommit>()
+ .HasAlternateKey(x => new { x.SourceId, x.Hash });
+
+ modelBuilder
+ .Entity<VersionControlSecret>()
+ .Property(p => p.Content)
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<Dictionary<string, string>>(v, (JsonSerializerOptions?)null)!);
base.OnModelCreating(modelBuilder);
}
diff --git a/src/marlin.web/Migrations/20260902214847_InitialCreate.Designer.cs b/src/marlin.web/Migrations/20260902214847_InitialCreate.Designer.cs
deleted file mode 100644
index 21ab701..0000000
--- a/src/marlin.web/Migrations/20260902214847_InitialCreate.Designer.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-// <auto-generated />
-using System;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using marlin.web.Data;
-
-#nullable disable
-
-namespace marlin.web.Migrations
-{
- [DbContext(typeof(ApplicationDbContext))]
- [Migration("20260902214847_InitialCreate")]
- partial class InitialCreate
- {
- /// <inheritdoc />
- protected override void BuildTargetModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "10.0.11")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("marlin.core.VersionControlSource", b =>
- {
- b.Property<Guid>("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("uuid")
- .HasColumnName("id");
-
- b.Property<string>("BranchFilter")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("branch_filter");
-
- b.Property<string>("UpstreamUrl")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("upstream_url");
-
- b.HasKey("Id")
- .HasName("pk_sources");
-
- b.ToTable("sources", (string)null);
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/src/marlin.web/Migrations/20260902214847_InitialCreate.cs b/src/marlin.web/Migrations/20260902214847_InitialCreate.cs
deleted file mode 100644
index 6c91890..0000000
--- a/src/marlin.web/Migrations/20260902214847_InitialCreate.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using Microsoft.EntityFrameworkCore.Migrations;
-
-#nullable disable
-
-namespace marlin.web.Migrations
-{
- /// <inheritdoc />
- public partial class InitialCreate : Migration
- {
- /// <inheritdoc />
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.CreateTable(
- name: "sources",
- columns: table => new
- {
- id = table.Column<Guid>(type: "uuid", nullable: false),
- upstream_url = table.Column<string>(type: "text", nullable: false),
- branch_filter = table.Column<string>(type: "text", nullable: false)
- },
- constraints: table =>
- {
- table.PrimaryKey("pk_sources", x => x.id);
- });
- }
-
- /// <inheritdoc />
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropTable(
- name: "sources");
- }
- }
-}
diff --git a/src/marlin.web/Migrations/20260903141622_InitialCreate.Designer.cs b/src/marlin.web/Migrations/20260903141622_InitialCreate.Designer.cs
new file mode 100644
index 0000000..ad71457
--- /dev/null
+++ b/src/marlin.web/Migrations/20260903141622_InitialCreate.Designer.cs
@@ -0,0 +1,259 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using marlin.web.Data;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260903141622_InitialCreate")]
+ partial class InitialCreate
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("marlin.core.models.Agent", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Capabilities")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("capabilities");
+
+ b.Property<DateTime>("LastSeen")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen");
+
+ b.Property<int>("MaxConcurrentJobs")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_jobs");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_agents");
+
+ b.ToTable("agents", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<string>("Commit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("commit");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("CreatedReason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_reason");
+
+ b.Property<string>("Recipe")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recipe");
+
+ b.Property<string>("Ref")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ref");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_jobs");
+
+ b.HasIndex("AgentId")
+ .HasDatabaseName("ix_jobs_agent_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("At")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("at");
+
+ b.Property<Guid>("JobId")
+ .HasColumnType("uuid")
+ .HasColumnName("job_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_job_status_update");
+
+ b.HasIndex("JobId")
+ .HasDatabaseName("ix_job_status_update_job_id");
+
+ b.ToTable("job_status_update", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("BranchFilter")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("branch_filter");
+
+ b.Property<string>("MainBranch")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("main_branch");
+
+ b.Property<int>("MinPollingInterval")
+ .HasColumnType("integer")
+ .HasColumnName("min_polling_interval");
+
+ b.Property<string>("UpstreamUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("upstream_url");
+
+ b.HasKey("Id")
+ .HasName("pk_sources");
+
+ b.ToTable("sources", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("HeadCommit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("head_commit");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_branches");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_branches_source_id");
+
+ b.ToTable("source_branches", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.HasOne("marlin.core.models.Agent", "Agent")
+ .WithMany()
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.core.models.Job", "Job")
+ .WithMany("StatusUpdates")
+ .HasForeignKey("JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_job_status_update_jobs_job_id");
+
+ b.Navigation("Job");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany("Branches")
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260903141622_InitialCreate.cs b/src/marlin.web/Migrations/20260903141622_InitialCreate.cs
new file mode 100644
index 0000000..2ebcc67
--- /dev/null
+++ b/src/marlin.web/Migrations/20260903141622_InitialCreate.cs
@@ -0,0 +1,154 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class InitialCreate : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "agents",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: false),
+ max_concurrent_jobs = table.Column<int>(type: "integer", nullable: false),
+ last_seen = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ capabilities = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_agents", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "sources",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ upstream_url = table.Column<string>(type: "text", nullable: false),
+ main_branch = table.Column<string>(type: "text", nullable: false),
+ branch_filter = table.Column<string>(type: "text", nullable: false),
+ min_polling_interval = table.Column<int>(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_sources", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "jobs",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ source_id = table.Column<Guid>(type: "uuid", nullable: false),
+ @ref = table.Column<string>(name: "ref", type: "text", nullable: false),
+ commit = table.Column<string>(type: "text", nullable: false),
+ created_reason = table.Column<string>(type: "text", nullable: false),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ status = table.Column<int>(type: "integer", nullable: false),
+ agent_id = table.Column<Guid>(type: "uuid", nullable: true),
+ recipe = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_jobs", x => x.id);
+ table.ForeignKey(
+ name: "fk_jobs_agents_agent_id",
+ column: x => x.agent_id,
+ principalTable: "agents",
+ principalColumn: "id");
+ table.ForeignKey(
+ name: "fk_jobs_sources_source_id",
+ column: x => x.source_id,
+ principalTable: "sources",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "source_branches",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ source_id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: false),
+ head_commit = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_source_branches", x => x.id);
+ table.ForeignKey(
+ name: "fk_source_branches_sources_source_id",
+ column: x => x.source_id,
+ principalTable: "sources",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "job_status_update",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ job_id = table.Column<Guid>(type: "uuid", nullable: false),
+ at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ status = table.Column<int>(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_job_status_update", x => x.id);
+ table.ForeignKey(
+ name: "fk_job_status_update_jobs_job_id",
+ column: x => x.job_id,
+ principalTable: "jobs",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_job_status_update_job_id",
+ table: "job_status_update",
+ column: "job_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_jobs_agent_id",
+ table: "jobs",
+ column: "agent_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_jobs_source_id",
+ table: "jobs",
+ column: "source_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_source_branches_source_id",
+ table: "source_branches",
+ column: "source_id");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "job_status_update");
+
+ migrationBuilder.DropTable(
+ name: "source_branches");
+
+ migrationBuilder.DropTable(
+ name: "jobs");
+
+ migrationBuilder.DropTable(
+ name: "agents");
+
+ migrationBuilder.DropTable(
+ name: "sources");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905011100_AddCommitModel.Designer.cs b/src/marlin.web/Migrations/20260905011100_AddCommitModel.Designer.cs
new file mode 100644
index 0000000..03f789e
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905011100_AddCommitModel.Designer.cs
@@ -0,0 +1,376 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using marlin.web.Data;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260905011100_AddCommitModel")]
+ partial class AddCommitModel
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("marlin.core.models.Agent", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Capabilities")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("capabilities");
+
+ b.Property<DateTime>("LastSeen")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen");
+
+ b.Property<int>("MaxConcurrentJobs")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_jobs");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_agents");
+
+ b.ToTable("agents", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<string>("Commit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("commit");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("CreatedReason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_reason");
+
+ b.Property<string>("Recipe")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recipe");
+
+ b.Property<string>("Ref")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ref");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_jobs");
+
+ b.HasIndex("AgentId")
+ .HasDatabaseName("ix_jobs_agent_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("At")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("at");
+
+ b.Property<Guid>("JobId")
+ .HasColumnType("uuid")
+ .HasColumnName("job_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_job_status_update");
+
+ b.HasIndex("JobId")
+ .HasDatabaseName("ix_job_status_update_job_id");
+
+ b.ToTable("job_status_update", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("BranchFilter")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("branch_filter");
+
+ b.Property<string>("MainBranch")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("main_branch");
+
+ b.Property<int>("MinPollingInterval")
+ .HasColumnType("integer")
+ .HasColumnName("min_polling_interval");
+
+ b.Property<string>("UpstreamUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("upstream_url");
+
+ b.HasKey("Id")
+ .HasName("pk_sources");
+
+ b.ToTable("sources", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("path");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_blobs");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_blobs_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_blobs_source_id");
+
+ b.ToTable("source_blobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_branches");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_branches_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_branches_source_id");
+
+ b.ToTable("source_branches", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("BlobsFetched")
+ .HasColumnType("boolean")
+ .HasColumnName("blobs_fetched");
+
+ b.Property<string>("Hash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_commits");
+
+ b.HasAlternateKey("SourceId", "Hash")
+ .HasName("ak_source_commits_source_id_hash");
+
+ b.ToTable("source_commits", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.HasOne("marlin.core.models.Agent", "Agent")
+ .WithMany()
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.core.models.Job", "Job")
+ .WithMany("StatusUpdates")
+ .HasForeignKey("JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_job_status_update_jobs_job_id");
+
+ b.Navigation("Job");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Blobs")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Branches")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany("Branches")
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_commits_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Navigation("Blobs");
+
+ b.Navigation("Branches");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905011100_AddCommitModel.cs b/src/marlin.web/Migrations/20260905011100_AddCommitModel.cs
new file mode 100644
index 0000000..78a23d0
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905011100_AddCommitModel.cs
@@ -0,0 +1,126 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddCommitModel : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "head_commit",
+ table: "source_branches");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "commit_id",
+ table: "source_branches",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateTable(
+ name: "source_commits",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ source_id = table.Column<Guid>(type: "uuid", nullable: false),
+ blobs_fetched = table.Column<bool>(type: "boolean", nullable: false),
+ hash = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_source_commits", x => x.id);
+ table.UniqueConstraint("ak_source_commits_source_id_hash", x => new { x.source_id, x.hash });
+ table.ForeignKey(
+ name: "fk_source_commits_sources_source_id",
+ column: x => x.source_id,
+ principalTable: "sources",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "source_blobs",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ source_id = table.Column<Guid>(type: "uuid", nullable: false),
+ commit_id = table.Column<Guid>(type: "uuid", nullable: false),
+ path = table.Column<string>(type: "text", nullable: false),
+ content = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_source_blobs", x => x.id);
+ table.ForeignKey(
+ name: "fk_source_blobs_source_commits_commit_id",
+ column: x => x.commit_id,
+ principalTable: "source_commits",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_source_blobs_sources_source_id",
+ column: x => x.source_id,
+ principalTable: "sources",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_source_branches_commit_id",
+ table: "source_branches",
+ column: "commit_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_source_blobs_commit_id",
+ table: "source_blobs",
+ column: "commit_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_source_blobs_source_id",
+ table: "source_blobs",
+ column: "source_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_source_branches_source_commits_commit_id",
+ table: "source_branches",
+ column: "commit_id",
+ principalTable: "source_commits",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_source_branches_source_commits_commit_id",
+ table: "source_branches");
+
+ migrationBuilder.DropTable(
+ name: "source_blobs");
+
+ migrationBuilder.DropTable(
+ name: "source_commits");
+
+ migrationBuilder.DropIndex(
+ name: "ix_source_branches_commit_id",
+ table: "source_branches");
+
+ migrationBuilder.DropColumn(
+ name: "commit_id",
+ table: "source_branches");
+
+ migrationBuilder.AddColumn<string>(
+ name: "head_commit",
+ table: "source_branches",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905102259_AddSecretModel.Designer.cs b/src/marlin.web/Migrations/20260905102259_AddSecretModel.Designer.cs
new file mode 100644
index 0000000..142703a
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905102259_AddSecretModel.Designer.cs
@@ -0,0 +1,430 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using marlin.web.Data;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260905102259_AddSecretModel")]
+ partial class AddSecretModel
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("marlin.core.models.Agent", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Capabilities")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("capabilities");
+
+ b.Property<DateTime>("LastSeen")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen");
+
+ b.Property<int>("MaxConcurrentJobs")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_jobs");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_agents");
+
+ b.ToTable("agents", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<string>("Commit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("commit");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("CreatedReason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_reason");
+
+ b.Property<string>("Recipe")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recipe");
+
+ b.Property<string>("Ref")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ref");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_jobs");
+
+ b.HasIndex("AgentId")
+ .HasDatabaseName("ix_jobs_agent_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("At")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("at");
+
+ b.Property<Guid>("JobId")
+ .HasColumnType("uuid")
+ .HasColumnName("job_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_job_status_update");
+
+ b.HasIndex("JobId")
+ .HasDatabaseName("ix_job_status_update_job_id");
+
+ b.ToTable("job_status_update", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AccessSecretId")
+ .HasColumnType("uuid")
+ .HasColumnName("access_secret_id");
+
+ b.Property<string>("BranchFilter")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("branch_filter");
+
+ b.Property<string>("MainBranch")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("main_branch");
+
+ b.Property<int>("MinPollingInterval")
+ .HasColumnType("integer")
+ .HasColumnName("min_polling_interval");
+
+ b.Property<string>("UpstreamUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("upstream_url");
+
+ b.HasKey("Id")
+ .HasName("pk_sources");
+
+ b.ToTable("sources", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("path");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_blobs");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_blobs_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_blobs_source_id");
+
+ b.ToTable("source_blobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_branches");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_branches_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_branches_source_id");
+
+ b.ToTable("source_branches", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("BlobsFetched")
+ .HasColumnType("boolean")
+ .HasColumnName("blobs_fetched");
+
+ b.Property<string>("Hash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_commits");
+
+ b.HasAlternateKey("SourceId", "Hash")
+ .HasName("ak_source_commits_source_id_hash");
+
+ b.ToTable("source_commits", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property<string>("Kind")
+ .HasColumnType("text")
+ .HasColumnName("kind");
+
+ b.Property<string>("Label")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("label");
+
+ b.Property<bool>("MayReveal")
+ .HasColumnType("boolean")
+ .HasColumnName("may_reveal");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_secrets");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_secrets_source_id");
+
+ b.ToTable("secrets", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.HasOne("marlin.core.models.Agent", "Agent")
+ .WithMany()
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.core.models.Job", "Job")
+ .WithMany("StatusUpdates")
+ .HasForeignKey("JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_job_status_update_jobs_job_id");
+
+ b.Navigation("Job");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Blobs")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Branches")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany("Branches")
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_commits_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_secrets_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Navigation("Blobs");
+
+ b.Navigation("Branches");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905102259_AddSecretModel.cs b/src/marlin.web/Migrations/20260905102259_AddSecretModel.cs
new file mode 100644
index 0000000..8b09e89
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905102259_AddSecretModel.cs
@@ -0,0 +1,59 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddSecretModel : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<Guid>(
+ name: "access_secret_id",
+ table: "sources",
+ type: "uuid",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "secrets",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ source_id = table.Column<Guid>(type: "uuid", nullable: false),
+ may_reveal = table.Column<bool>(type: "boolean", nullable: false),
+ kind = table.Column<string>(type: "text", nullable: true),
+ label = table.Column<string>(type: "text", nullable: false),
+ content = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_secrets", x => x.id);
+ table.ForeignKey(
+ name: "fk_secrets_sources_source_id",
+ column: x => x.source_id,
+ principalTable: "sources",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_secrets_source_id",
+ table: "secrets",
+ column: "source_id");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "secrets");
+
+ migrationBuilder.DropColumn(
+ name: "access_secret_id",
+ table: "sources");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905104308_BlobNowBinary.Designer.cs b/src/marlin.web/Migrations/20260905104308_BlobNowBinary.Designer.cs
new file mode 100644
index 0000000..6061a9d
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905104308_BlobNowBinary.Designer.cs
@@ -0,0 +1,430 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using marlin.web.Data;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260905104308_BlobNowBinary")]
+ partial class BlobNowBinary
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("marlin.core.models.Agent", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Capabilities")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("capabilities");
+
+ b.Property<DateTime>("LastSeen")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen");
+
+ b.Property<int>("MaxConcurrentJobs")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_jobs");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_agents");
+
+ b.ToTable("agents", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<string>("Commit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("commit");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("CreatedReason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_reason");
+
+ b.Property<string>("Recipe")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recipe");
+
+ b.Property<string>("Ref")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ref");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_jobs");
+
+ b.HasIndex("AgentId")
+ .HasDatabaseName("ix_jobs_agent_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("At")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("at");
+
+ b.Property<Guid>("JobId")
+ .HasColumnType("uuid")
+ .HasColumnName("job_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_job_status_update");
+
+ b.HasIndex("JobId")
+ .HasDatabaseName("ix_job_status_update_job_id");
+
+ b.ToTable("job_status_update", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AccessSecretId")
+ .HasColumnType("uuid")
+ .HasColumnName("access_secret_id");
+
+ b.Property<string>("BranchFilter")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("branch_filter");
+
+ b.Property<string>("MainBranch")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("main_branch");
+
+ b.Property<int>("MinPollingInterval")
+ .HasColumnType("integer")
+ .HasColumnName("min_polling_interval");
+
+ b.Property<string>("UpstreamUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("upstream_url");
+
+ b.HasKey("Id")
+ .HasName("pk_sources");
+
+ b.ToTable("sources", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<byte[]>("Content")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("content");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("path");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_blobs");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_blobs_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_blobs_source_id");
+
+ b.ToTable("source_blobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_branches");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_branches_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_branches_source_id");
+
+ b.ToTable("source_branches", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("BlobsFetched")
+ .HasColumnType("boolean")
+ .HasColumnName("blobs_fetched");
+
+ b.Property<string>("Hash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_commits");
+
+ b.HasAlternateKey("SourceId", "Hash")
+ .HasName("ak_source_commits_source_id_hash");
+
+ b.ToTable("source_commits", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property<string>("Kind")
+ .HasColumnType("text")
+ .HasColumnName("kind");
+
+ b.Property<string>("Label")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("label");
+
+ b.Property<bool>("MayReveal")
+ .HasColumnType("boolean")
+ .HasColumnName("may_reveal");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_secrets");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_secrets_source_id");
+
+ b.ToTable("secrets", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.HasOne("marlin.core.models.Agent", "Agent")
+ .WithMany()
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.core.models.Job", "Job")
+ .WithMany("StatusUpdates")
+ .HasForeignKey("JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_job_status_update_jobs_job_id");
+
+ b.Navigation("Job");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Blobs")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Branches")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany("Branches")
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_commits_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_secrets_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Navigation("Blobs");
+
+ b.Navigation("Branches");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260905104308_BlobNowBinary.cs b/src/marlin.web/Migrations/20260905104308_BlobNowBinary.cs
new file mode 100644
index 0000000..493e698
--- /dev/null
+++ b/src/marlin.web/Migrations/20260905104308_BlobNowBinary.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class BlobNowBinary : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "content",
+ table: "source_blobs");
+
+ migrationBuilder.AddColumn<byte[]>(
+ name: "content",
+ table: "source_blobs",
+ type: "bytea",
+ nullable: false,
+ defaultValue: new byte[0]);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "content",
+ table: "source_blobs");
+
+ migrationBuilder.AddColumn<string>(
+ name: "content",
+ table: "source_blobs",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs b/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
index 4c026e5..8de35de 100644
--- a/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -22,18 +22,145 @@ namespace marlin.web.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("marlin.core.VersionControlSource", b =>
+ modelBuilder.Entity("marlin.core.models.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
+ b.Property<string>("Capabilities")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("capabilities");
+
+ b.Property<DateTime>("LastSeen")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen");
+
+ b.Property<int>("MaxConcurrentJobs")
+ .HasColumnType("integer")
+ .HasColumnName("max_concurrent_jobs");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_agents");
+
+ b.ToTable("agents", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<string>("Commit")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("commit");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("CreatedReason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_reason");
+
+ b.Property<string>("Recipe")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recipe");
+
+ b.Property<string>("Ref")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ref");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_jobs");
+
+ b.HasIndex("AgentId")
+ .HasDatabaseName("ix_jobs_agent_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("At")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("at");
+
+ b.Property<Guid>("JobId")
+ .HasColumnType("uuid")
+ .HasColumnName("job_id");
+
+ b.Property<int>("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_job_status_update");
+
+ b.HasIndex("JobId")
+ .HasDatabaseName("ix_job_status_update_job_id");
+
+ b.ToTable("job_status_update", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AccessSecretId")
+ .HasColumnType("uuid")
+ .HasColumnName("access_secret_id");
+
b.Property<string>("BranchFilter")
.IsRequired()
.HasColumnType("text")
.HasColumnName("branch_filter");
+ b.Property<string>("MainBranch")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("main_branch");
+
+ b.Property<int>("MinPollingInterval")
+ .HasColumnType("integer")
+ .HasColumnName("min_polling_interval");
+
b.Property<string>("UpstreamUrl")
.IsRequired()
.HasColumnType("text")
@@ -44,6 +171,256 @@ namespace marlin.web.Migrations
b.ToTable("sources", (string)null);
});
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<byte[]>("Content")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("content");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("path");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_blobs");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_blobs_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_blobs_source_id");
+
+ b.ToTable("source_blobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_branches");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_source_branches_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_source_branches_source_id");
+
+ b.ToTable("source_branches", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("BlobsFetched")
+ .HasColumnType("boolean")
+ .HasColumnName("blobs_fetched");
+
+ b.Property<string>("Hash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_source_commits");
+
+ b.HasAlternateKey("SourceId", "Hash")
+ .HasName("ak_source_commits_source_id_hash");
+
+ b.ToTable("source_commits", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property<string>("Kind")
+ .HasColumnType("text")
+ .HasColumnName("kind");
+
+ b.Property<string>("Label")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("label");
+
+ b.Property<bool>("MayReveal")
+ .HasColumnType("boolean")
+ .HasColumnName("may_reveal");
+
+ b.Property<Guid>("SourceId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_id");
+
+ b.HasKey("Id")
+ .HasName("pk_secrets");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_secrets_source_id");
+
+ b.ToTable("secrets", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.HasOne("marlin.core.models.Agent", "Agent")
+ .WithMany()
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.core.models.Job", "Job")
+ .WithMany("StatusUpdates")
+ .HasForeignKey("JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_job_status_update_jobs_job_id");
+
+ b.Navigation("Job");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Blobs")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithMany("Branches")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_source_commits_commit_id");
+
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany("Branches")
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_sources_source_id");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_commits_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_secrets_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.core.models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+
+ modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ {
+ b.Navigation("Blobs");
+
+ b.Navigation("Branches");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/src/marlin.web/Models/DatabaseSourceSecretStore.cs b/src/marlin.web/Models/DatabaseSourceSecretStore.cs
new file mode 100644
index 0000000..b4ca61f
--- /dev/null
+++ b/src/marlin.web/Models/DatabaseSourceSecretStore.cs
@@ -0,0 +1,27 @@
+namespace marlin.web.Models;
+
+using core.models;
+using Data;
+using Microsoft.EntityFrameworkCore;
+
+public class DatabaseSourceSecretStore(ApplicationDbContext dbContext) : ISourceSecretStore
+{
+ public async Task<ISecret?> GetAsync(Guid id, CancellationToken ct)
+ {
+ return await dbContext.Secrets
+ .FirstOrDefaultAsync(x => x.Id == id, cancellationToken: ct);
+ }
+}
+
+public class VersionControlSecret : ISecret
+{
+ public Guid Id { get; set; }
+
+ public Guid SourceId { get; set; }
+ public VersionControlSource Source { get; set; }
+
+ public bool MayReveal { get; set; }
+ public string? Kind { get; set; }
+ public string Label { get; set; }
+ public Dictionary<string, string> Content { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.web/Program.cs b/src/marlin.web/Program.cs
index cf330b2..f079a3c 100644
--- a/src/marlin.web/Program.cs
+++ b/src/marlin.web/Program.cs
@@ -1,4 +1,5 @@
using marlin.web.Data;
+using marlin.web.Models;
using marlin.web.Services;
using Microsoft.EntityFrameworkCore;
@@ -10,8 +11,13 @@ builder.Services.AddDbContext<ApplicationDbContext>(options => options
.UseNpgsql(builder.Configuration.GetConnectionString("Master"))
.UseSnakeCaseNamingConvention());
+builder.Services.AddScoped<ISourceSecretStore, DatabaseSourceSecretStore>();
+
builder.Services.AddHostedService<DatabaseUpgrader>();
-builder.Services.AddHostedService<VersionControlSyncService>();
+
+builder.Services.AddSingleton<VersionControlSyncService>();
+builder.Services.AddHostedService<VersionControlSyncService>(sp => sp
+ .GetRequiredService<VersionControlSyncService>());
// Add services to the container.
builder.Services.AddControllersWithViews();
diff --git a/src/marlin.web/Services/VersionControlSyncService.cs b/src/marlin.web/Services/VersionControlSyncService.cs
index ba40662..bf189b0 100644
--- a/src/marlin.web/Services/VersionControlSyncService.cs
+++ b/src/marlin.web/Services/VersionControlSyncService.cs
@@ -4,8 +4,10 @@ using System.Diagnostics;
using System.Text;
using core.functional;
using core.models;
+using core.util;
using Data;
using Microsoft.EntityFrameworkCore;
+using Models;
public class VersionControlSyncService : BackgroundService
{
@@ -13,38 +15,213 @@ public class VersionControlSyncService : BackgroundService
private readonly IServiceProvider _services;
private ICollection<VersionControlSource> _sources;
- private Dictionary<Guid, DateTime> _nextPoll;
+ private Dictionary<Guid, DateTime> _lastPoll;
public VersionControlSyncService(ILogger<VersionControlSyncService> logger, IServiceProvider services)
{
this._logger = logger;
this._services = services;
this._sources = new List<VersionControlSource>();
- this._nextPoll = new();
+ this._lastPoll = new();
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await this.FetchSources(stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
+
+ try
+ {
+ await Tick(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this._logger.LogError(ex, "VCS sync tick failed");
+ }
+ }
}
- public async Task FetchSources()
+ private async Task Tick(CancellationToken ct)
{
- await using var scoped = this._services.CreateAsyncScope();
- var dbContext = scoped.ServiceProvider.GetRequiredService<ApplicationDbContext>();
- this._sources = await dbContext.Sources.ToListAsync();
+ var now = DateTime.UtcNow;
+ var due = this
+ ._sources
+ .Where(x => this._lastPoll.TryGetValue(x.Id, out var lastTime) ? now >= lastTime.AddSeconds(x.MinPollingInterval) : true)
+ .ToArray();
+
+ if (due.Length > 0)
+ {
+ await using var scoped = this._services.CreateAsyncScope();
+ var dbContext = scoped.ServiceProvider.GetRequiredService<ApplicationDbContext>();
+ var sourceSecretStore = scoped.ServiceProvider.GetRequiredService<ISourceSecretStore>();
+
+ foreach (var source in due)
+ {
+ var trackedSource = await dbContext.Sources
+ .Include(x => x.Branches)
+ .ThenInclude(x => x.Commit)
+ .FirstAsync(x => x.Id == source.Id, ct);
+
+ try
+ {
+ await this.SyncRefs(dbContext, sourceSecretStore, trackedSource, ct);
+ await this.SyncBlobs(dbContext, sourceSecretStore, trackedSource, ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ this._logger.LogError(ex, "Failed to sync vcs source '{name}'", source.UpstreamUrl);
+ }
+ finally
+ {
+ this._lastPoll[source.Id] = DateTime.UtcNow;
+ }
+ }
+
+ if (dbContext.ChangeTracker.HasChanges())
+ {
+ await this.Sweep(dbContext, ct);
+
+ await dbContext.SaveChangesAsync(ct);
+ this._logger.LogInformation("Synced {count} vcs roots", due.Length);
+ }
+ }
}
- private async Task SyncSource(VersionControlSource source, ApplicationDbContext db)
+ private async Task<IGitClient> GetClient(VersionControlSource source, ISourceSecretStore store, CancellationToken ct)
+ {
+ IGitCredential? cred = null;
+ if (source.AccessSecretId != null)
+ {
+ ISecret? secret = await store.GetAsync(source.AccessSecretId.Value, ct);
+ if (secret == null)
+ throw new InvalidOperationException($"Could not find access secret for '{source.UpstreamUrl}'");
+
+ if (secret.Kind == SecretKind.SSHKey && secret.Has("public_key", "private_key"))
+ cred = new SshKeyGitCredential(secret.Content["private_key"]);
+
+ else if (secret.Kind == SecretKind.HTTPToken && secret.Has("identity", "token"))
+ cred = new TokenGitCredential(secret.Content["token"], secret.Content["identity"]);
+
+ else
+ throw new InvalidOperationException($"Could not resolve access secret content to credentials for '{source.UpstreamUrl}'");
+ }
+
+ IGitClient client = new GitBinaryClient(cred);
+ return client;
+ }
+
+ private async Task<string> GetLocalPath(IGitClient client, VersionControlSource source)
+ {
+ var dir = Path.Join(Environment.CurrentDirectory, "run", "sources", source.Id.ToString("N"));
+ if (!Directory.Exists(dir))
+ {
+ Directory.CreateDirectory(dir);
+ await client.Init(dir);
+ }
+
+ return dir;
+ }
+
+ public async Task FetchSources(CancellationToken ct)
{
- IGitClient client = new GitBinaryClient();
- var refs = await client.GetRemoteRefs(source.UpstreamUrl);
+ await using var scoped = this._services.CreateAsyncScope();
+ var dbContext = scoped.ServiceProvider.GetRequiredService<ApplicationDbContext>();
+ this._sources = await dbContext.Sources
+ .Include(x => x.Branches)
+ .ToListAsync(ct);
+ await this.Sweep(dbContext, ct);
}
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ private async Task Sweep(ApplicationDbContext dbContext, CancellationToken ct)
{
- await this.FetchSources();
+ await dbContext.SourceCommits
+ .Include(x => x.Branches)
+ .Where(x => !x.Branches.Any())
+ .ExecuteDeleteAsync(ct);
+ }
+
+ private async Task SyncRefs(ApplicationDbContext dbContext, ISourceSecretStore secretStore, VersionControlSource source, CancellationToken ct)
+ {
+ IGitClient client = await this.GetClient(source, secretStore, ct);
+ var newRefs = await client.GetRemoteRefs(source.UpstreamUrl);
+
+ Dictionary<string, string> currentRefs = source.Branches
+ .ToDictionary(x => x.Name, x => x.Commit.Hash);
+ Dictionary<string, VersionControlledBranch> branchesByName = source.Branches
+ .ToDictionary(x => x.Name);
+
+ var added = newRefs.Keys.Except(currentRefs.Keys).ToArray();
+ var removed = currentRefs.Keys.Except(newRefs.Keys).ToArray();
+ var changed = currentRefs.Keys.Intersect(newRefs.Keys)
+ .Where(name => currentRefs[name] != newRefs[name])
+ .ToArray();
+
+ foreach (var name in removed)
+ source.Branches.Remove(branchesByName[name]);
+
+ // Pre-load the commits these refs now point to, so a hash shared across
+ // multiple branches (or seen in an earlier sync) resolves to one row
+ // instead of racing duplicate inserts.
+ var neededHashes = added.Concat(changed)
+ .Select(name => newRefs[name])
+ .Distinct()
+ .ToHashSet();
+
+ var commitsByHash = await dbContext.SourceCommits
+ .Where(c => c.SourceId == source.Id && neededHashes.Contains(c.Hash))
+ .ToDictionaryAsync(c => c.Hash, ct);
+
+ VersionControlledCommit GetOrCreateCommit(string hash)
+ {
+ if (!commitsByHash.TryGetValue(hash, out var commit))
+ {
+ commit = new VersionControlledCommit { SourceId = source.Id, Hash = hash, BlobsFetched = false };
+ commitsByHash[hash] = commit;
+ }
+
+ return commit;
+ }
+
+ foreach (var name in added)
+ source.Branches.Add(new VersionControlledBranch { Name = name, Commit = GetOrCreateCommit(newRefs[name]) });
+
+ foreach (var name in changed)
+ branchesByName[name].Commit = GetOrCreateCommit(newRefs[name]);
+ }
+
+ private async Task SyncBlobs(ApplicationDbContext dbContext, ISourceSecretStore secretStore, VersionControlSource source, CancellationToken ct)
+ {
+ var refs = source.Branches
+ .Where(x => !x.Commit.BlobsFetched)
+ .DistinctBy(x => x.CommitId)
+ .ToArray();
+
+ IGitClient client = await this.GetClient(source, secretStore, ct);
+ var localPath = await GetLocalPath(client, source);
+ await client.FetchTrees(localPath, source.UpstreamUrl, refs.Select(x => x.Name));
- while (!stoppingToken.IsCancellationRequested)
+ foreach (VersionControlledCommit commit in refs.Select(x => x.Commit))
{
+ var blobNames = await client.ListTree(localPath, commit.Hash, ".");
+
+ foreach (string blobName in blobNames)
+ {
+ var content = await client.CatFile(localPath, commit.Hash, blobName);
+ dbContext.SourceBlobs.Add(new VersionControlledBlob
+ {
+ Source = source,
+ Commit = commit,
+ Path = blobName,
+ Content = content
+ });
+ }
+ commit.BlobsFetched = true;
}
}
} \ No newline at end of file
diff --git a/src/marlin.web/Views/Jobs/Details.cshtml b/src/marlin.web/Views/Jobs/Details.cshtml
new file mode 100644
index 0000000..e1fd184
--- /dev/null
+++ b/src/marlin.web/Views/Jobs/Details.cshtml
@@ -0,0 +1,5 @@
+@model marlin.core.models.Job
+
+@Model.Id
+
+<a asp-action="List">Back</a> \ No newline at end of file
diff --git a/src/marlin.web/Views/Jobs/List.cshtml b/src/marlin.web/Views/Jobs/List.cshtml
index 2c10e46..6ae1d96 100644
--- a/src/marlin.web/Views/Jobs/List.cshtml
+++ b/src/marlin.web/Views/Jobs/List.cshtml
@@ -1,5 +1,7 @@
@model ICollection<marlin.core.models.Job>
+<a asp-action="Spawn">Spawn new...</a>
+
<table>
<thead>
<tr>
@@ -10,6 +12,7 @@
<th>vcs</th>
<th>branch</th>
<th>agent</th>
+ <th>created</th>
</tr>
</thead>
<tbody>
@@ -23,6 +26,7 @@
<td>@source.Source.UpstreamUrl</td>
<td>@source.Ref</td>
<td>@(source.Agent?.Name ?? "no agent assigned")</td>
+ <td>@(source.CreatedReason)</td>
</tr>
}
</tbody>
diff --git a/src/marlin.web/Views/Jobs/Spawn.cshtml b/src/marlin.web/Views/Jobs/Spawn.cshtml
new file mode 100644
index 0000000..8f7ee43
--- /dev/null
+++ b/src/marlin.web/Views/Jobs/Spawn.cshtml
@@ -0,0 +1,56 @@
+@using System.Linq
+@using System.Text.Json
+@using marlin.web.Controllers
+@model marlin.web.Controllers.JobsController.JobSpawnDetails
+
+@{
+ var sourcesJson = JsonSerializer.Serialize(Model.Sources.Select(s => new
+ {
+ id = s.Id,
+ label = s.UpstreamUrl,
+ mainBranch = s.MainBranch,
+ branches = s.Branches.Select(b => new { name = b.Name, commit = b.Commit.Hash })
+ }));
+}
+
+<form method="post" asp-action="Spawn">
+ VCS: <select id="vcs" name="@nameof(JobsController.JobSpawnRequest.Vcs)"></select><br/>
+ Ref: <select id="ref" name="@nameof(JobsController.JobSpawnRequest.Ref)"></select><br/>
+ Commit: <input type="text" id="commit" name="@nameof(JobsController.JobSpawnRequest.Commit)" readonly/><br/>
+ Recipe: <input type="text" name="@nameof(JobsController.JobSpawnRequest.Recipe)" value="@Model.Recipe"/><br/>
+ <button type="submit">Save</button>
+</form>
+
+<script>
+ const sources = @Html.Raw(sourcesJson);
+ const vcsSelect = document.getElementById('vcs');
+ const refSelect = document.getElementById('ref');
+ const commitInput = document.getElementById('commit');
+
+ for (const s of sources) {
+ vcsSelect.add(new Option(s.label, s.id));
+ }
+
+ function currentSource() {
+ return sources.find(s => s.id === vcsSelect.value);
+ }
+
+ function populateRefs() {
+ refSelect.innerHTML = '';
+ for (const b of currentSource()?.branches ?? []) {
+ refSelect.add(new Option(b.name, b.name));
+ }
+ refSelect.value = currentSource()?.mainBranch ?? '';
+ populateCommit();
+ }
+
+ function populateCommit() {
+ const branch = currentSource()?.branches.find(b => b.name === refSelect.value);
+ commitInput.value = branch?.commit ?? '';
+ }
+
+ vcsSelect.addEventListener('change', populateRefs);
+ refSelect.addEventListener('change', populateCommit);
+
+ populateRefs();
+</script>
diff --git a/src/marlin.web/Views/Shared/_Layout.cshtml b/src/marlin.web/Views/Shared/_Layout.cshtml
index f060fcf..e8be2af 100644
--- a/src/marlin.web/Views/Shared/_Layout.cshtml
+++ b/src/marlin.web/Views/Shared/_Layout.cshtml
@@ -24,7 +24,10 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
- <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
+ <a class="nav-link text-dark" asp-area="" asp-controller="VCS" asp-action="List">VCS Configuration</a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link text-dark" asp-area="" asp-controller="Jobs" asp-action="List">Jobs</a>
</li>
</ul>
</div>
diff --git a/src/marlin.web/Views/VCS/Create.cshtml b/src/marlin.web/Views/VCS/Create.cshtml
index adaa913..e04592d 100644
--- a/src/marlin.web/Views/VCS/Create.cshtml
+++ b/src/marlin.web/Views/VCS/Create.cshtml
@@ -2,10 +2,10 @@
@using marlin.core.models
<form method="post" asp-action="Create">
- URL: <input type="text" name="@nameof(VersionControlSource.UpstreamUrl)" />
- Filter: <input type="text" name="@nameof(VersionControlSource.BranchFilter)" />
- Main Branch: <input type="text" name="@nameof(VersionControlSource.MainBranch)" />
- Polling Interval: <input type="number" name="@nameof(VersionControlSource.MinPollingInterval)" />
+ URL: <input type="text" name="@nameof(VersionControlSource.UpstreamUrl)" /><br/>
+ Filter: <input type="text" name="@nameof(VersionControlSource.BranchFilter)" /><br/>
+ Main Branch: <input type="text" name="@nameof(VersionControlSource.MainBranch)" /><br/>
+ Polling Interval: <input type="number" name="@nameof(VersionControlSource.MinPollingInterval)" /><br/>
<button type="submit">Create</button>
</form>
diff --git a/src/marlin.web/Views/VCS/CreateSecret.cshtml b/src/marlin.web/Views/VCS/CreateSecret.cshtml
new file mode 100644
index 0000000..6453056
--- /dev/null
+++ b/src/marlin.web/Views/VCS/CreateSecret.cshtml
@@ -0,0 +1,12 @@
+@using marlin.core.models
+@model VersionControlSource
+
+<form method="post" asp-action="CreateSecret" asp-route-sourceId="@Model.Id">
+ Label: <input type="text" name="Label"/><br/>
+ Kind: <input type="text" name="Kind"/><br/>
+ May Reveal: <input type="checkbox" name="MayReveal" value="true"/><br/>
+ Content (JSON): <textarea name="ContentJson">{}</textarea><br/>
+ <button type="submit">Create</button>
+</form>
+
+<a asp-action="ListSecret" asp-route-sourceId="@Model.Id">Back</a>
diff --git a/src/marlin.web/Views/VCS/Details.cshtml b/src/marlin.web/Views/VCS/Details.cshtml
index 8d25472..45c1a8f 100644
--- a/src/marlin.web/Views/VCS/Details.cshtml
+++ b/src/marlin.web/Views/VCS/Details.cshtml
@@ -1,13 +1,43 @@
@using marlin.core
@using marlin.core.models
-@model marlin.core.models.VersionControlSource
+@using marlin.web.Controllers
+@model (VersionControlSource Source, List<VCSController.SecretOption> SecretOptions)
-<form method="post" asp-route-id="@Model.Id" asp-action="Edit">
- URL: <input type="text" name="@nameof(VersionControlSource.UpstreamUrl)" value="@Model.UpstreamUrl"/>
- Filter: <input type="text" name="@nameof(VersionControlSource.BranchFilter)" value="@Model.BranchFilter"/>
- Main Branch: <input type="text" name="@nameof(VersionControlSource.MainBranch)" value="@Model.MainBranch"/>
- Polling Interval: <input type="number" name="@nameof(VersionControlSource.MinPollingInterval)" value="@Model.MinPollingInterval"/>
+<form method="post" asp-route-id="@Model.Source.Id" asp-action="Edit">
+ URL: <input type="text" name="@nameof(VersionControlSource.UpstreamUrl)" value="@Model.Source.UpstreamUrl"/><br/>
+ Filter: <input type="text" name="@nameof(VersionControlSource.BranchFilter)" value="@Model.Source.BranchFilter"/><br/>
+ Main Branch: <input type="text" name="@nameof(VersionControlSource.MainBranch)" value="@Model.Source.MainBranch"/><br/>
+ Polling Interval: <input type="number" name="@nameof(VersionControlSource.MinPollingInterval)" value="@Model.Source.MinPollingInterval"/><br/>
+ Access Secret:
+ <select name="@nameof(VersionControlSource.AccessSecretId)">
+ <option value="">(none)</option>
+ @foreach (var secret in Model.SecretOptions)
+ {
+ <option value="@secret.Id" selected="@(secret.Id == Model.Source.AccessSecretId)">@secret.Label</option>
+ }
+ </select><br/>
<button type="submit">Save</button>
</form>
-<a asp-action="List">Back</a> \ No newline at end of file
+<table>
+ <thead>
+ <tr>
+ <th></th>
+ <th>branch</th>
+ <th>commit</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var branch in Model.Source.Branches)
+ {
+ <tr>
+ <td>@(branch.Name == Model.Source.MainBranch ? "@" : "")</td>
+ <td>@branch.Name</td>
+ <td>@branch.Commit.Hash</td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+<a asp-action="ListSecret" asp-route-sourceId="@Model.Source.Id">Secrets</a>
+<a asp-action="List">Back</a>
diff --git a/src/marlin.web/Views/VCS/List.cshtml b/src/marlin.web/Views/VCS/List.cshtml
index 90c3f48..4c248e4 100644
--- a/src/marlin.web/Views/VCS/List.cshtml
+++ b/src/marlin.web/Views/VCS/List.cshtml
@@ -1,4 +1,4 @@
-@model IReadOnlyList<marlin.core.models.VersionControlSource>
+@model ICollection<marlin.core.models.VersionControlSource>
<a asp-action="Create">New</a>
diff --git a/src/marlin.web/Views/VCS/ListSecret.cshtml b/src/marlin.web/Views/VCS/ListSecret.cshtml
new file mode 100644
index 0000000..aa5f15c
--- /dev/null
+++ b/src/marlin.web/Views/VCS/ListSecret.cshtml
@@ -0,0 +1,45 @@
+@model (marlin.core.models.VersionControlSource Source, List<ISecret> Secrets)
+
+<a asp-action="CreateSecret" asp-route-sourceId="@Model.Source.Id">New</a>
+
+<table>
+ <thead>
+ <tr>
+ <th>id</th>
+ <th>label</th>
+ <th>kind</th>
+ <th>value</th>
+ <th>actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var secret in Model.Secrets)
+ {
+ <tr>
+ <td>@secret.Id</td>
+ <td>@secret.Label</td>
+ <td>@secret.Kind</td>
+ <td>
+ @if (secret.MayReveal)
+ {
+ @foreach (var pair in secret.Content)
+ {
+ <span>@pair.Key = @pair.Value</span>
+ }
+ }
+ else
+ {
+ <span>secret cannot be revealed</span>
+ }
+ </td>
+ <td>
+ <form asp-action="DeleteSecret" method="post" asp-route-sourceId="@Model.Source.Id" asp-route-id="@secret.Id">
+ <button type="submit">delete</button>
+ </form>
+ </td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+<a asp-action="Details", asp-route-id="@Model.Source.Id">back</a>
diff --git a/src/marlin.web/appsettings.json b/src/marlin.web/appsettings.json
index 10f68b8..793d7e4 100644
--- a/src/marlin.web/appsettings.json
+++ b/src/marlin.web/appsettings.json
@@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*"
diff --git a/src/marlin.web/marlin.web.csproj b/src/marlin.web/marlin.web.csproj
index 3160c98..26c8ea3 100644
--- a/src/marlin.web/marlin.web.csproj
+++ b/src/marlin.web/marlin.web.csproj
@@ -21,4 +21,8 @@
<ProjectReference Include="..\marlin.core\marlin.core.csproj" />
</ItemGroup>
+ <ItemGroup>
+ <Folder Include="Migrations\" />
+ </ItemGroup>
+
</Project>
diff --git a/src/marlin.web/wwwroot/css/site.css b/src/marlin.web/wwwroot/css/site.css
index 819f612..c6ecfb7 100644
--- a/src/marlin.web/wwwroot/css/site.css
+++ b/src/marlin.web/wwwroot/css/site.css
@@ -28,4 +28,24 @@ body {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+th, td {
+ border: 1px solid #ccc;
+ text-align: left;
+}
+
+th {
+ background-color: #f0f0f0;
+ font-weight: bold;
+}
+
+th a {
+ text-decoration: none;
+ color: inherit;
} \ No newline at end of file