summaryrefslogtreecommitdiff
path: root/src/marlin.web
diff options
context:
space:
mode:
authorMelodi <62381002+Melodi17@users.noreply.github.com>2026-09-13 13:22:20 +1000
committerMelodi <62381002+Melodi17@users.noreply.github.com>2026-09-13 13:22:20 +1000
commitdf0737e55afeb5c01eb39909605970d1d3a73ffc (patch)
tree09823eaa310c60eefd2420dc305acae45c81900f /src/marlin.web
parentee69ce25b2fba871920260028bb10c2d6b62b26f (diff)
refactor: cleanup; feat: connection to agent
Diffstat (limited to 'src/marlin.web')
-rw-r--r--src/marlin.web/Controllers/AgentsController.cs89
-rw-r--r--src/marlin.web/Controllers/DispatchController.cs65
-rw-r--r--src/marlin.web/Controllers/JobsController.cs35
-rw-r--r--src/marlin.web/Controllers/VCSController.cs36
-rw-r--r--src/marlin.web/Data/ApplicationDbContext.cs23
-rw-r--r--src/marlin.web/Data/JsonValueComparer.cs23
-rw-r--r--src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.Designer.cs478
-rw-r--r--src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.cs59
-rw-r--r--src/marlin.web/Migrations/20260906082039_AddAgentToken.Designer.cs488
-rw-r--r--src/marlin.web/Migrations/20260906082039_AddAgentToken.cs29
-rw-r--r--src/marlin.web/Migrations/20260908142748_CommitAlt.Designer.cs514
-rw-r--r--src/marlin.web/Migrations/20260908142748_CommitAlt.cs115
-rw-r--r--src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs204
-rw-r--r--src/marlin.web/Models/Agent.cs17
-rw-r--r--src/marlin.web/Models/Job.cs48
-rw-r--r--src/marlin.web/Models/SecretKind.cs7
-rw-r--r--src/marlin.web/Models/VersionControlSource.cs15
-rw-r--r--src/marlin.web/Models/VersionControlledBlob.cs15
-rw-r--r--src/marlin.web/Models/VersionControlledBranch.cs14
-rw-r--r--src/marlin.web/Models/VersionControlledCommit.cs17
-rw-r--r--src/marlin.web/Models/WorkflowSnapshot.cs14
-rw-r--r--src/marlin.web/Program.cs14
-rw-r--r--src/marlin.web/Properties/launchSettings.json4
-rw-r--r--src/marlin.web/Services/AgentAuthenticationHandler.cs79
-rw-r--r--src/marlin.web/Services/VersionControlSyncService.cs38
-rw-r--r--src/marlin.web/Util/ClaimsPrincipalExtensions.cs22
-rw-r--r--src/marlin.web/Views/Agents/Create.cshtml9
-rw-r--r--src/marlin.web/Views/Agents/Details.cshtml67
-rw-r--r--src/marlin.web/Views/Agents/List.cshtml32
-rw-r--r--src/marlin.web/Views/Jobs/Details.cshtml2
-rw-r--r--src/marlin.web/Views/Jobs/List.cshtml8
-rw-r--r--src/marlin.web/Views/Jobs/Spawn.cshtml37
-rw-r--r--src/marlin.web/Views/Shared/_Layout.cshtml3
-rw-r--r--src/marlin.web/Views/VCS/Blobs.cshtml22
-rw-r--r--src/marlin.web/Views/VCS/Details.cshtml12
-rw-r--r--src/marlin.web/Views/VCS/List.cshtml2
-rw-r--r--src/marlin.web/Views/VCS/ListSecret.cshtml2
-rw-r--r--src/marlin.web/Views/VCS/Workflow.cshtml18
-rw-r--r--src/marlin.web/wwwroot/css/site.css31
39 files changed, 2599 insertions, 108 deletions
diff --git a/src/marlin.web/Controllers/AgentsController.cs b/src/marlin.web/Controllers/AgentsController.cs
new file mode 100644
index 0000000..199acf9
--- /dev/null
+++ b/src/marlin.web/Controllers/AgentsController.cs
@@ -0,0 +1,89 @@
+namespace marlin.web.Controllers;
+
+using core.models;
+using core.util;
+using Data;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Models;
+
+[Route("agents")]
+public class AgentsController(ApplicationDbContext dbContext) : Controller
+{
+ [HttpGet("list")]
+ public async Task<IActionResult> List(CancellationToken ct)
+ {
+ var items = await dbContext
+ .Agents
+ .Include(x => x.Jobs.Where(j => j.Status == JobStatus.Active))
+ .ToListAsync(ct);
+
+ return this.View(items);
+ }
+
+ [HttpGet("details/{id}")]
+ public async Task<IActionResult> Details(Guid id, CancellationToken ct)
+ {
+ var item = await dbContext.Agents
+ .Include(x => x.Jobs.Where(j => j.Status == JobStatus.Active))
+ .FirstOrDefaultAsync(x => x.Id == id, ct);
+
+ if (item == null)
+ return this.NotFound();
+
+ return this.View(item);
+ }
+
+ [HttpPost("edit/{id}")]
+ public async Task<IActionResult> Edit(Guid id, Agent agent, CancellationToken ct)
+ {
+ var item = await dbContext.Agents
+ .FirstOrDefaultAsync(x => x.Id == id, ct);
+
+ if (item == null)
+ return this.NotFound();
+
+ item.Name = agent.Name;
+ item.MaxConcurrentJobs = agent.MaxConcurrentJobs;
+
+ await dbContext.SaveChangesAsync(ct);
+
+ return this.RedirectToAction("Details", new { id });
+ }
+
+ [HttpPost("details/{id}/regenerate-token")]
+ public async Task<IActionResult> RegenerateToken(Guid id, CancellationToken ct)
+ {
+ var item = await dbContext.Agents
+ .FirstOrDefaultAsync(x => x.Id == id, ct);
+
+ if (item == null)
+ return this.NotFound();
+
+ item.AuthToken = SecureTokenGenerator.CreateAgentToken();
+
+ await dbContext.SaveChangesAsync(ct);
+
+ return this.RedirectToAction("Details", new { id });
+ }
+
+ [HttpGet("create")]
+ public IActionResult Create()
+ {
+ return this.View();
+ }
+
+ [HttpPost("create")]
+ public async Task<IActionResult> CreatePost(Agent agent, CancellationToken ct)
+ {
+ agent.Id = Guid.Empty;
+ agent.AuthToken = SecureTokenGenerator.CreateAgentToken();
+ agent.LastSeen = DateTime.UtcNow;
+ agent.Capabilities = new CapabilitySet();
+
+ await dbContext.Agents.AddAsync(agent, ct);
+ await dbContext.SaveChangesAsync(ct);
+
+ return this.RedirectToAction("Details", new { id = agent.Id });
+ }
+} \ No newline at end of file
diff --git a/src/marlin.web/Controllers/DispatchController.cs b/src/marlin.web/Controllers/DispatchController.cs
index 6b2285c..a37d5fa 100644
--- a/src/marlin.web/Controllers/DispatchController.cs
+++ b/src/marlin.web/Controllers/DispatchController.cs
@@ -1,10 +1,71 @@
namespace marlin.web.Controllers;
+using core.models;
+using core.models.meta;
+using core.util;
+using Data;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Models;
+using Services;
+using Util;
[Route("api/dispatch")]
[ApiController]
-public class DispatchController : ControllerBase
+[Authorize(Policy = "Agent")]
+public class DispatchController(ApplicationDbContext dbContext) : ControllerBase
{
+ [HttpPost("handshake")]
+ public async Task<ActionResult<AgentConfiguration>> Handshake(AgentStatus status, CancellationToken ct)
+ {
+ var agentId = User.RequireAgentId();
+
+ var agent = await dbContext.Agents
+ .FirstAsync(x => x.Id == agentId, ct);
+
+ foreach (var capability in status.CapabilitySet.Items)
+ agent.Capabilities.Items[capability.Key] = capability.Value;
+
+ await dbContext.SaveChangesAsync(ct);
+
+ return new AgentConfiguration(
+ Name: agent.Name,
+ MaxConcurrentJobs: agent.MaxConcurrentJobs
+ );
+ }
-} \ No newline at end of file
+ [HttpGet("queue")]
+ public async Task<ActionResult<DispatchJob?>> Queue(CancellationToken ct)
+ {
+ var agentId = User.RequireAgentId();
+
+ var nextJob = await dbContext
+ .Jobs
+ .Include(x => x.Source)
+ .Include(x => x.Branch)
+ .Include(x => x.Commit)
+ .Where(x => x.Status == JobStatus.Pending && x.AgentId == null)
+ .OrderBy(x => x.CreatedAt)
+ .FirstOrDefaultAsync(ct);
+
+ if (nextJob == null)
+ return (DispatchJob?) null;
+
+ string claimToken = SecureTokenGenerator.CreateClaimToken();
+ nextJob.ClaimToken = claimToken;
+ nextJob.AgentId = agentId;
+
+ await dbContext.SaveChangesAsync(ct);
+
+ return new DispatchJob(
+ Id: nextJob.Id,
+ Token: claimToken,
+ Vcs: nextJob.Source.UpstreamUrl,
+ Ref: nextJob.Branch.Name,
+ Commit: nextJob.Commit.Hash,
+ Recipe: nextJob.Recipe
+ );
+ }
+}
+
diff --git a/src/marlin.web/Controllers/JobsController.cs b/src/marlin.web/Controllers/JobsController.cs
index c86cb2d..552b56c 100644
--- a/src/marlin.web/Controllers/JobsController.cs
+++ b/src/marlin.web/Controllers/JobsController.cs
@@ -4,25 +4,29 @@ using core.models;
using Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Models;
using Util;
[Route("jobs")]
public class JobsController(ApplicationDbContext dbContext) : Controller
{
-
// list jobs (pending, active, cancelled, success, failed), filter to vcs, filter to recipe
- public record JobListFilter(JobStatus? Status = null, Guid? Vcs = null, string? Ref = null, string? Recipe = null);
-
+ public record JobListFilter(JobStatus? Status = null, Guid? Vcs = null, Guid? Branch = null, string? Recipe = null, Guid? Agent = null);
+
[HttpGet("list")]
public async Task<IActionResult> List([FromQuery] JobListFilter filter, CancellationToken ct)
{
- var results = await dbContext.Jobs
+ var results = await dbContext
+ .Jobs
.WhereIf(filter.Status != null, x => x.Status == filter.Status)
.WhereIf(filter.Vcs != null, x => x.SourceId == filter.Vcs)
- .WhereIf(filter.Ref != null, x => x.Ref == filter.Ref)
+ .WhereIf(filter.Branch != null, x => x.BranchId == filter.Branch)
.WhereIf(filter.Recipe != null, x => x.Recipe == filter.Recipe)
+ .WhereIf(filter.Agent != null, x => x.AgentId == filter.Agent)
.OrderByDescending(x => x.CreatedAt)
-
+
+ .Include(x => x.Commit)
+ .Include(x => x.Branch)
.Include(x => x.Source)
.Include(x => x.Agent)
.ToListAsync(ct);
@@ -40,25 +44,34 @@ public class JobsController(ApplicationDbContext dbContext) : Controller
[HttpGet("spawn")]
public async Task<IActionResult> Spawn(CancellationToken ct)
{
- var sources = await dbContext.Sources
+ var sources = await dbContext
+ .Sources
.Include(x => x.Branches)
.ThenInclude(x => x.Commit)
+ .ThenInclude(x => x.WorkflowSnapshot)
.ToListAsync(ct);
var details = new JobSpawnDetails(sources);
return this.View(details);
}
- public record JobSpawnRequest(Guid Vcs, string Ref, string Commit, string Recipe);
+ public record JobSpawnRequest(Guid Vcs, Guid Branch, string Recipe);
[HttpPost("spawn")]
public async Task<IActionResult> SpawnPost(JobSpawnRequest request, CancellationToken ct)
{
+ var branch = await dbContext
+ .SourceBranches
+ .FirstOrDefaultAsync(x => x.Id == request.Branch, ct);
+
+ if (branch == null)
+ return this.NotFound();
+
Job job = new()
{
- SourceId = request.Vcs,
- Ref = request.Ref,
- Commit = request.Commit,
+ SourceId = branch.SourceId,
+ BranchId = branch.Id,
+ CommitId = branch.CommitId,
Recipe = request.Recipe,
CreatedAt = DateTime.UtcNow,
diff --git a/src/marlin.web/Controllers/VCSController.cs b/src/marlin.web/Controllers/VCSController.cs
index 7370e0d..8086e99 100644
--- a/src/marlin.web/Controllers/VCSController.cs
+++ b/src/marlin.web/Controllers/VCSController.cs
@@ -27,15 +27,16 @@ public class VCSController(ApplicationDbContext dbContext, VersionControlSyncSer
[HttpGet("details/{id}")]
public async Task<IActionResult> Details(Guid id, CancellationToken ct)
{
- var item = await dbContext.Sources
+ var item = await dbContext
+ .Sources
.Include(x => x.Branches)
.ThenInclude(x => x.Commit)
+ .ThenInclude(x => x.WorkflowSnapshot)
.FirstOrDefaultAsync(x => x.Id == id, ct);
if (item == null)
return this.NotFound();
- // 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))
@@ -44,6 +45,37 @@ public class VCSController(ApplicationDbContext dbContext, VersionControlSyncSer
return this.View((item, secretOptions));
}
+ [HttpGet("details/{sourceId}/{commitId}/blobs")]
+ public async Task<IActionResult> Blobs(Guid sourceId, Guid commitId)
+ {
+ var blobs = await dbContext
+ .SourceBlobs
+ .Include(x => x.Commit)
+ .ThenInclude(x => x.Source)
+ .Where(x => x.CommitId == commitId && x.SourceId == sourceId)
+ .ToListAsync();
+
+ if (!blobs.Any())
+ return this.NotFound();
+
+ return this.View(blobs);
+ }
+
+ [HttpGet("details/{sourceId}/{commitId}/workflow")]
+ public async Task<IActionResult> Workflow(Guid sourceId, Guid commitId)
+ {
+ var snapshot = await dbContext
+ .WorkflowSnapshots
+ .Include(x => x.Commit)
+ .ThenInclude(x => x.Source)
+ .FirstOrDefaultAsync(x => x.CommitId == commitId && x.Commit.SourceId == sourceId);
+
+ if (snapshot == null)
+ return this.NotFound();
+
+ return this.View(snapshot);
+ }
+
[HttpPost("edit/{id}")]
public async Task<IActionResult> Edit(Guid id, VersionControlSource vcs, CancellationToken ct)
{
diff --git a/src/marlin.web/Data/ApplicationDbContext.cs b/src/marlin.web/Data/ApplicationDbContext.cs
index 2e05835..cb6b05b 100644
--- a/src/marlin.web/Data/ApplicationDbContext.cs
+++ b/src/marlin.web/Data/ApplicationDbContext.cs
@@ -3,6 +3,7 @@ namespace marlin.web.Data;
using System.Linq.Expressions;
using System.Text.Json;
using core;
+using core.functional;
using core.models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -26,6 +27,7 @@ public class ApplicationDbContext : DbContext
public DbSet<VersionControlSecret> Secrets => this.Set<VersionControlSecret>();
public DbSet<Job> Jobs => this.Set<Job>();
public DbSet<Agent> Agents => this.Set<Agent>();
+ public DbSet<WorkflowSnapshot> WorkflowSnapshots => this.Set<WorkflowSnapshot>();
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
@@ -35,9 +37,7 @@ public class ApplicationDbContext : DbContext
modelBuilder
.Entity<Agent>()
.Property(p => p.Capabilities)
- .HasConversion(
- v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
- v => JsonSerializer.Deserialize<CapabilitySet>(v, (JsonSerializerOptions?)null)!);
+ .HasJsonConversion();
modelBuilder
.Entity<VersionControlledCommit>()
@@ -46,10 +46,19 @@ public class ApplicationDbContext : DbContext
modelBuilder
.Entity<VersionControlSecret>()
.Property(p => p.Content)
- .HasConversion(
- v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
- v => JsonSerializer.Deserialize<Dictionary<string, string>>(v, (JsonSerializerOptions?)null)!);
-
+ .HasJsonConversion();
+
+ modelBuilder
+ .Entity<VersionControlledCommit>()
+ .HasOne(x => x.WorkflowSnapshot)
+ .WithOne(x => x.Commit)
+ .HasForeignKey<WorkflowSnapshot>(x => x.CommitId);
+
+ modelBuilder
+ .Entity<WorkflowSnapshot>()
+ .Property(p => p.Workflow)
+ .HasJsonConversion();
+
base.OnModelCreating(modelBuilder);
}
} \ No newline at end of file
diff --git a/src/marlin.web/Data/JsonValueComparer.cs b/src/marlin.web/Data/JsonValueComparer.cs
new file mode 100644
index 0000000..f07c133
--- /dev/null
+++ b/src/marlin.web/Data/JsonValueComparer.cs
@@ -0,0 +1,23 @@
+namespace marlin.web.Data;
+
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public static class JsonValueComparer
+{
+ public static ValueComparer<T> Create<T>() => new(
+ (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
+ v => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!);
+}
+
+public static class PropertyBuilderExtensions
+{
+ public static PropertyBuilder<T> HasJsonConversion<T>(this PropertyBuilder<T> builder) =>
+ builder.HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!,
+ JsonValueComparer.Create<T>());
+}
diff --git a/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.Designer.cs b/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.Designer.cs
new file mode 100644
index 0000000..e50bdbd
--- /dev/null
+++ b/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.Designer.cs
@@ -0,0 +1,478 @@
+// <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("20260906003038_AddWorkflowSnapshot")]
+ partial class AddWorkflowSnapshot
+ {
+ /// <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.Property<bool>("WorkflowScanned")
+ .HasColumnType("boolean")
+ .HasColumnName("workflow_scanned");
+
+ 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.WorkflowSnapshot", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<DateTime>("ScannedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("scanned_at");
+
+ b.Property<string>("Workflow")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("workflow");
+
+ b.HasKey("Id")
+ .HasName("pk_workflow_snapshots");
+
+ b.HasIndex("CommitId")
+ .IsUnique()
+ .HasDatabaseName("ix_workflow_snapshots_commit_id");
+
+ b.ToTable("workflow_snapshots", (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.core.models.WorkflowSnapshot", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithOne("WorkflowSnapshot")
+ .HasForeignKey("marlin.core.models.WorkflowSnapshot", "CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_workflow_snapshots_source_commits_commit_id");
+
+ b.Navigation("Commit");
+ });
+
+ 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");
+
+ b.Navigation("WorkflowSnapshot");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.cs b/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.cs
new file mode 100644
index 0000000..d47ee9e
--- /dev/null
+++ b/src/marlin.web/Migrations/20260906003038_AddWorkflowSnapshot.cs
@@ -0,0 +1,59 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddWorkflowSnapshot : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<bool>(
+ name: "workflow_scanned",
+ table: "source_commits",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.CreateTable(
+ name: "workflow_snapshots",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ commit_id = table.Column<Guid>(type: "uuid", nullable: false),
+ scanned_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ workflow = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_workflow_snapshots", x => x.id);
+ table.ForeignKey(
+ name: "fk_workflow_snapshots_source_commits_commit_id",
+ column: x => x.commit_id,
+ principalTable: "source_commits",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_workflow_snapshots_commit_id",
+ table: "workflow_snapshots",
+ column: "commit_id",
+ unique: true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "workflow_snapshots");
+
+ migrationBuilder.DropColumn(
+ name: "workflow_scanned",
+ table: "source_commits");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260906082039_AddAgentToken.Designer.cs b/src/marlin.web/Migrations/20260906082039_AddAgentToken.Designer.cs
new file mode 100644
index 0000000..c39db32
--- /dev/null
+++ b/src/marlin.web/Migrations/20260906082039_AddAgentToken.Designer.cs
@@ -0,0 +1,488 @@
+// <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("20260906082039_AddAgentToken")]
+ partial class AddAgentToken
+ {
+ /// <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>("AuthToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("auth_token");
+
+ 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.Property<bool>("WorkflowScanned")
+ .HasColumnType("boolean")
+ .HasColumnName("workflow_scanned");
+
+ 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.WorkflowSnapshot", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<DateTime>("ScannedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("scanned_at");
+
+ b.Property<string>("Workflow")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("workflow");
+
+ b.HasKey("Id")
+ .HasName("pk_workflow_snapshots");
+
+ b.HasIndex("CommitId")
+ .IsUnique()
+ .HasDatabaseName("ix_workflow_snapshots_commit_id");
+
+ b.ToTable("workflow_snapshots", (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("Jobs")
+ .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.WorkflowSnapshot", b =>
+ {
+ b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ .WithOne("WorkflowSnapshot")
+ .HasForeignKey("marlin.core.models.WorkflowSnapshot", "CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_workflow_snapshots_source_commits_commit_id");
+
+ b.Navigation("Commit");
+ });
+
+ 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.Agent", b =>
+ {
+ b.Navigation("Jobs");
+ });
+
+ 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");
+
+ b.Navigation("WorkflowSnapshot");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260906082039_AddAgentToken.cs b/src/marlin.web/Migrations/20260906082039_AddAgentToken.cs
new file mode 100644
index 0000000..6725186
--- /dev/null
+++ b/src/marlin.web/Migrations/20260906082039_AddAgentToken.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddAgentToken : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<string>(
+ name: "auth_token",
+ table: "agents",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "auth_token",
+ table: "agents");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260908142748_CommitAlt.Designer.cs b/src/marlin.web/Migrations/20260908142748_CommitAlt.Designer.cs
new file mode 100644
index 0000000..5b64d5d
--- /dev/null
+++ b/src/marlin.web/Migrations/20260908142748_CommitAlt.Designer.cs
@@ -0,0 +1,514 @@
+// <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("20260908142748_CommitAlt")]
+ partial class CommitAlt
+ {
+ /// <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.web.Models.Agent", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("AuthToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("auth_token");
+
+ 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.web.Models.Job", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("AgentId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_id");
+
+ b.Property<Guid>("BranchId")
+ .HasColumnType("uuid")
+ .HasColumnName("branch_id");
+
+ b.Property<string>("ClaimToken")
+ .HasColumnType("text")
+ .HasColumnName("claim_token");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ 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<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("BranchId")
+ .HasDatabaseName("ix_jobs_branch_id");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_jobs_commit_id");
+
+ b.HasIndex("SourceId")
+ .HasDatabaseName("ix_jobs_source_id");
+
+ b.ToTable("jobs", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.web.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.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.web.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.web.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.web.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.web.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.Property<bool>("WorkflowScanned")
+ .HasColumnType("boolean")
+ .HasColumnName("workflow_scanned");
+
+ 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.WorkflowSnapshot", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
+
+ b.Property<DateTime>("ScannedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("scanned_at");
+
+ b.Property<string>("Workflow")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("workflow");
+
+ b.HasKey("Id")
+ .HasName("pk_workflow_snapshots");
+
+ b.HasIndex("CommitId")
+ .IsUnique()
+ .HasDatabaseName("ix_workflow_snapshots_commit_id");
+
+ b.ToTable("workflow_snapshots", (string)null);
+ });
+
+ modelBuilder.Entity("marlin.web.Models.Job", b =>
+ {
+ b.HasOne("marlin.web.Models.Agent", "Agent")
+ .WithMany("Jobs")
+ .HasForeignKey("AgentId")
+ .HasConstraintName("fk_jobs_agents_agent_id");
+
+ b.HasOne("marlin.web.Models.VersionControlledBranch", "Branch")
+ .WithMany()
+ .HasForeignKey("BranchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_source_branches_branch_id");
+
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithMany()
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_source_commits_commit_id");
+
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_sources_source_id");
+
+ b.Navigation("Agent");
+
+ b.Navigation("Branch");
+
+ b.Navigation("Commit");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.JobStatusUpdate", b =>
+ {
+ b.HasOne("marlin.web.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.web.Models.VersionControlSecret", b =>
+ {
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_secrets_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithMany("Blobs")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_blobs_source_commits_commit_id");
+
+ b.HasOne("marlin.web.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.web.Models.VersionControlledBranch", b =>
+ {
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithMany("Branches")
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_source_branches_source_commits_commit_id");
+
+ b.HasOne("marlin.web.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.web.Models.VersionControlledCommit", b =>
+ {
+ b.HasOne("marlin.web.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.WorkflowSnapshot", b =>
+ {
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithOne("WorkflowSnapshot")
+ .HasForeignKey("marlin.web.Models.WorkflowSnapshot", "CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_workflow_snapshots_source_commits_commit_id");
+
+ b.Navigation("Commit");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.Agent", b =>
+ {
+ b.Navigation("Jobs");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.Job", b =>
+ {
+ b.Navigation("StatusUpdates");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlSource", b =>
+ {
+ b.Navigation("Branches");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlledCommit", b =>
+ {
+ b.Navigation("Blobs");
+
+ b.Navigation("Branches");
+
+ b.Navigation("WorkflowSnapshot");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/20260908142748_CommitAlt.cs b/src/marlin.web/Migrations/20260908142748_CommitAlt.cs
new file mode 100644
index 0000000..b607d31
--- /dev/null
+++ b/src/marlin.web/Migrations/20260908142748_CommitAlt.cs
@@ -0,0 +1,115 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace marlin.web.Migrations
+{
+ /// <inheritdoc />
+ public partial class CommitAlt : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "commit",
+ table: "jobs");
+
+ migrationBuilder.DropColumn(
+ name: "ref",
+ table: "jobs");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "branch_id",
+ table: "jobs",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<string>(
+ name: "claim_token",
+ table: "jobs",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "commit_id",
+ table: "jobs",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateIndex(
+ name: "ix_jobs_branch_id",
+ table: "jobs",
+ column: "branch_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_jobs_commit_id",
+ table: "jobs",
+ column: "commit_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_jobs_source_branches_branch_id",
+ table: "jobs",
+ column: "branch_id",
+ principalTable: "source_branches",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_jobs_source_commits_commit_id",
+ table: "jobs",
+ column: "commit_id",
+ principalTable: "source_commits",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_jobs_source_branches_branch_id",
+ table: "jobs");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_jobs_source_commits_commit_id",
+ table: "jobs");
+
+ migrationBuilder.DropIndex(
+ name: "ix_jobs_branch_id",
+ table: "jobs");
+
+ migrationBuilder.DropIndex(
+ name: "ix_jobs_commit_id",
+ table: "jobs");
+
+ migrationBuilder.DropColumn(
+ name: "branch_id",
+ table: "jobs");
+
+ migrationBuilder.DropColumn(
+ name: "claim_token",
+ table: "jobs");
+
+ migrationBuilder.DropColumn(
+ name: "commit_id",
+ table: "jobs");
+
+ migrationBuilder.AddColumn<string>(
+ name: "commit",
+ table: "jobs",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddColumn<string>(
+ name: "ref",
+ table: "jobs",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+ }
+}
diff --git a/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs b/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
index 8de35de..411706b 100644
--- a/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/marlin.web/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -22,13 +22,18 @@ namespace marlin.web.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
- modelBuilder.Entity("marlin.core.models.Agent", b =>
+ modelBuilder.Entity("marlin.web.Models.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
+ b.Property<string>("AuthToken")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("auth_token");
+
b.Property<string>("Capabilities")
.IsRequired()
.HasColumnType("text")
@@ -53,7 +58,7 @@ namespace marlin.web.Migrations
b.ToTable("agents", (string)null);
});
- modelBuilder.Entity("marlin.core.models.Job", b =>
+ modelBuilder.Entity("marlin.web.Models.Job", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -64,10 +69,17 @@ namespace marlin.web.Migrations
.HasColumnType("uuid")
.HasColumnName("agent_id");
- b.Property<string>("Commit")
- .IsRequired()
+ b.Property<Guid>("BranchId")
+ .HasColumnType("uuid")
+ .HasColumnName("branch_id");
+
+ b.Property<string>("ClaimToken")
.HasColumnType("text")
- .HasColumnName("commit");
+ .HasColumnName("claim_token");
+
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
@@ -83,11 +95,6 @@ namespace marlin.web.Migrations
.HasColumnType("text")
.HasColumnName("recipe");
- b.Property<string>("Ref")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("ref");
-
b.Property<Guid>("SourceId")
.HasColumnType("uuid")
.HasColumnName("source_id");
@@ -102,13 +109,19 @@ namespace marlin.web.Migrations
b.HasIndex("AgentId")
.HasDatabaseName("ix_jobs_agent_id");
+ b.HasIndex("BranchId")
+ .HasDatabaseName("ix_jobs_branch_id");
+
+ b.HasIndex("CommitId")
+ .HasDatabaseName("ix_jobs_commit_id");
+
b.HasIndex("SourceId")
.HasDatabaseName("ix_jobs_source_id");
b.ToTable("jobs", (string)null);
});
- modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ modelBuilder.Entity("marlin.web.Models.JobStatusUpdate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -136,7 +149,45 @@ namespace marlin.web.Migrations
b.ToTable("job_status_update", (string)null);
});
- modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ 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.web.Models.VersionControlSource", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -172,7 +223,7 @@ namespace marlin.web.Migrations
b.ToTable("sources", (string)null);
});
- modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledBlob", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -209,7 +260,7 @@ namespace marlin.web.Migrations
b.ToTable("source_blobs", (string)null);
});
- modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledBranch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -241,7 +292,7 @@ namespace marlin.web.Migrations
b.ToTable("source_branches", (string)null);
});
- modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledCommit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -261,6 +312,10 @@ namespace marlin.web.Migrations
.HasColumnType("uuid")
.HasColumnName("source_id");
+ b.Property<bool>("WorkflowScanned")
+ .HasColumnType("boolean")
+ .HasColumnName("workflow_scanned");
+
b.HasKey("Id")
.HasName("pk_source_commits");
@@ -270,52 +325,58 @@ namespace marlin.web.Migrations
b.ToTable("source_commits", (string)null);
});
- modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ modelBuilder.Entity("marlin.web.Models.WorkflowSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
- b.Property<string>("Content")
- .IsRequired()
- .HasColumnType("text")
- .HasColumnName("content");
+ b.Property<Guid>("CommitId")
+ .HasColumnType("uuid")
+ .HasColumnName("commit_id");
- b.Property<string>("Kind")
- .HasColumnType("text")
- .HasColumnName("kind");
+ b.Property<DateTime>("ScannedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("scanned_at");
- b.Property<string>("Label")
+ b.Property<string>("Workflow")
.IsRequired()
.HasColumnType("text")
- .HasColumnName("label");
-
- b.Property<bool>("MayReveal")
- .HasColumnType("boolean")
- .HasColumnName("may_reveal");
-
- b.Property<Guid>("SourceId")
- .HasColumnType("uuid")
- .HasColumnName("source_id");
+ .HasColumnName("workflow");
b.HasKey("Id")
- .HasName("pk_secrets");
+ .HasName("pk_workflow_snapshots");
- b.HasIndex("SourceId")
- .HasDatabaseName("ix_secrets_source_id");
+ b.HasIndex("CommitId")
+ .IsUnique()
+ .HasDatabaseName("ix_workflow_snapshots_commit_id");
- b.ToTable("secrets", (string)null);
+ b.ToTable("workflow_snapshots", (string)null);
});
- modelBuilder.Entity("marlin.core.models.Job", b =>
+ modelBuilder.Entity("marlin.web.Models.Job", b =>
{
- b.HasOne("marlin.core.models.Agent", "Agent")
- .WithMany()
+ b.HasOne("marlin.web.Models.Agent", "Agent")
+ .WithMany("Jobs")
.HasForeignKey("AgentId")
.HasConstraintName("fk_jobs_agents_agent_id");
- b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ b.HasOne("marlin.web.Models.VersionControlledBranch", "Branch")
+ .WithMany()
+ .HasForeignKey("BranchId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_source_branches_branch_id");
+
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithMany()
+ .HasForeignKey("CommitId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_jobs_source_commits_commit_id");
+
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -324,12 +385,16 @@ namespace marlin.web.Migrations
b.Navigation("Agent");
+ b.Navigation("Branch");
+
+ b.Navigation("Commit");
+
b.Navigation("Source");
});
- modelBuilder.Entity("marlin.core.models.JobStatusUpdate", b =>
+ modelBuilder.Entity("marlin.web.Models.JobStatusUpdate", b =>
{
- b.HasOne("marlin.core.models.Job", "Job")
+ b.HasOne("marlin.web.Models.Job", "Job")
.WithMany("StatusUpdates")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
@@ -339,16 +404,28 @@ namespace marlin.web.Migrations
b.Navigation("Job");
});
- modelBuilder.Entity("marlin.core.models.VersionControlledBlob", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
{
- b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
+ .WithMany()
+ .HasForeignKey("SourceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_secrets_sources_source_id");
+
+ b.Navigation("Source");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.VersionControlledBlob", b =>
+ {
+ b.HasOne("marlin.web.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")
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -360,16 +437,16 @@ namespace marlin.web.Migrations
b.Navigation("Source");
});
- modelBuilder.Entity("marlin.core.models.VersionControlledBranch", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledBranch", b =>
{
- b.HasOne("marlin.core.models.VersionControlledCommit", "Commit")
+ b.HasOne("marlin.web.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")
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
.WithMany("Branches")
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -381,9 +458,9 @@ namespace marlin.web.Migrations
b.Navigation("Source");
});
- modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledCommit", b =>
{
- b.HasOne("marlin.core.models.VersionControlSource", "Source")
+ b.HasOne("marlin.web.Models.VersionControlSource", "Source")
.WithMany()
.HasForeignKey("SourceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -393,33 +470,40 @@ namespace marlin.web.Migrations
b.Navigation("Source");
});
- modelBuilder.Entity("marlin.web.Models.VersionControlSecret", b =>
+ modelBuilder.Entity("marlin.web.Models.WorkflowSnapshot", b =>
{
- b.HasOne("marlin.core.models.VersionControlSource", "Source")
- .WithMany()
- .HasForeignKey("SourceId")
+ b.HasOne("marlin.web.Models.VersionControlledCommit", "Commit")
+ .WithOne("WorkflowSnapshot")
+ .HasForeignKey("marlin.web.Models.WorkflowSnapshot", "CommitId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
- .HasConstraintName("fk_secrets_sources_source_id");
+ .HasConstraintName("fk_workflow_snapshots_source_commits_commit_id");
- b.Navigation("Source");
+ b.Navigation("Commit");
});
- modelBuilder.Entity("marlin.core.models.Job", b =>
+ modelBuilder.Entity("marlin.web.Models.Agent", b =>
+ {
+ b.Navigation("Jobs");
+ });
+
+ modelBuilder.Entity("marlin.web.Models.Job", b =>
{
b.Navigation("StatusUpdates");
});
- modelBuilder.Entity("marlin.core.models.VersionControlSource", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlSource", b =>
{
b.Navigation("Branches");
});
- modelBuilder.Entity("marlin.core.models.VersionControlledCommit", b =>
+ modelBuilder.Entity("marlin.web.Models.VersionControlledCommit", b =>
{
b.Navigation("Blobs");
b.Navigation("Branches");
+
+ b.Navigation("WorkflowSnapshot");
});
#pragma warning restore 612, 618
}
diff --git a/src/marlin.web/Models/Agent.cs b/src/marlin.web/Models/Agent.cs
new file mode 100644
index 0000000..1faea9d
--- /dev/null
+++ b/src/marlin.web/Models/Agent.cs
@@ -0,0 +1,17 @@
+namespace marlin.web.Models;
+
+using core.models;
+
+public class Agent
+{
+ public Guid Id { get; set; }
+ public string AuthToken { get; set; }
+
+ public string Name { get; set; }
+ public int MaxConcurrentJobs { get; set; }
+ public DateTime LastSeen { get; set; }
+
+ public CapabilitySet Capabilities { get; set; }
+
+ public ICollection<Job> Jobs { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.web/Models/Job.cs b/src/marlin.web/Models/Job.cs
new file mode 100644
index 0000000..bd60773
--- /dev/null
+++ b/src/marlin.web/Models/Job.cs
@@ -0,0 +1,48 @@
+namespace marlin.web.Models;
+
+public class Job
+{
+ public Guid Id { get; set; }
+
+ public Guid SourceId { get; set; }
+ public VersionControlSource Source { get; set; }
+
+ public Guid BranchId { get; set; }
+ public VersionControlledBranch Branch { get; set; }
+
+ public Guid CommitId { get; set; }
+ public VersionControlledCommit Commit { get; set; }
+
+ public string CreatedReason { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public JobStatus Status { get; set; }
+ public ICollection<JobStatusUpdate> StatusUpdates { get; set; }
+
+ public Guid? AgentId { get; set; }
+ public Agent? Agent { get; set; }
+
+ public string? ClaimToken { get; set; }
+
+ public string Recipe { get; set; }
+ // plan
+}
+
+public class JobStatusUpdate
+{
+ public Guid Id { get; set; }
+
+ public Guid JobId { get; set; }
+ public Job Job { get; set; }
+
+ public DateTime At { get; set; }
+ public JobStatus Status { get; set; }
+}
+
+public enum JobStatus
+{
+ Pending,
+ Active,
+ Cancelled,
+ Success,
+ Failed
+} \ No newline at end of file
diff --git a/src/marlin.web/Models/SecretKind.cs b/src/marlin.web/Models/SecretKind.cs
new file mode 100644
index 0000000..02652d5
--- /dev/null
+++ b/src/marlin.web/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.web/Models/VersionControlSource.cs b/src/marlin.web/Models/VersionControlSource.cs
new file mode 100644
index 0000000..270df12
--- /dev/null
+++ b/src/marlin.web/Models/VersionControlSource.cs
@@ -0,0 +1,15 @@
+namespace marlin.web.Models;
+
+public class VersionControlSource
+{
+ public Guid Id { get; set; }
+
+ public string UpstreamUrl { get; set; }
+ public string MainBranch { get; set; }
+ public string BranchFilter { get; set; }
+ public int MinPollingInterval { get; set; }
+
+ public Guid? AccessSecretId { get; set; }
+
+ public ICollection<VersionControlledBranch> Branches { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.web/Models/VersionControlledBlob.cs b/src/marlin.web/Models/VersionControlledBlob.cs
new file mode 100644
index 0000000..5b9017f
--- /dev/null
+++ b/src/marlin.web/Models/VersionControlledBlob.cs
@@ -0,0 +1,15 @@
+namespace marlin.web.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.web/Models/VersionControlledBranch.cs b/src/marlin.web/Models/VersionControlledBranch.cs
new file mode 100644
index 0000000..a2973f7
--- /dev/null
+++ b/src/marlin.web/Models/VersionControlledBranch.cs
@@ -0,0 +1,14 @@
+namespace marlin.web.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.web/Models/VersionControlledCommit.cs b/src/marlin.web/Models/VersionControlledCommit.cs
new file mode 100644
index 0000000..9062156
--- /dev/null
+++ b/src/marlin.web/Models/VersionControlledCommit.cs
@@ -0,0 +1,17 @@
+namespace marlin.web.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 bool WorkflowScanned { get; set; }
+
+ public string Hash { get; set; }
+
+ public ICollection<VersionControlledBranch> Branches { get; set; }
+ public ICollection<VersionControlledBlob> Blobs { get; set; }
+ public WorkflowSnapshot? WorkflowSnapshot { get; set; }
+} \ No newline at end of file
diff --git a/src/marlin.web/Models/WorkflowSnapshot.cs b/src/marlin.web/Models/WorkflowSnapshot.cs
new file mode 100644
index 0000000..2ea7bce
--- /dev/null
+++ b/src/marlin.web/Models/WorkflowSnapshot.cs
@@ -0,0 +1,14 @@
+namespace marlin.web.Models;
+
+using marlin.core.models.meta;
+
+public class WorkflowSnapshot
+{
+ public Guid Id { get; set; }
+
+ public Guid CommitId { get; set; }
+ public VersionControlledCommit Commit { get; set; }
+
+ public DateTime ScannedAt { get; set; }
+ public Workflow Workflow { get; set; }
+}
diff --git a/src/marlin.web/Program.cs b/src/marlin.web/Program.cs
index f079a3c..700e696 100644
--- a/src/marlin.web/Program.cs
+++ b/src/marlin.web/Program.cs
@@ -13,6 +13,16 @@ builder.Services.AddDbContext<ApplicationDbContext>(options => options
builder.Services.AddScoped<ISourceSecretStore, DatabaseSourceSecretStore>();
+builder.Services
+ .AddAuthentication(AgentSchemeOptions.Name)
+ .AddScheme<AgentSchemeOptions, AgentAuthenticationHandler>(AgentSchemeOptions.Name, null);
+
+builder.Services.AddAuthorization(options =>
+{
+ options.AddPolicy("Agent", p => p.RequireClaim("agent_id"));
+ options.AddPolicy("JobScoped", p => p.RequireClaim("job_id"));
+});
+
builder.Services.AddHostedService<DatabaseUpgrader>();
builder.Services.AddSingleton<VersionControlSyncService>();
@@ -20,7 +30,8 @@ builder.Services.AddHostedService<VersionControlSyncService>(sp => sp
.GetRequiredService<VersionControlSyncService>());
// Add services to the container.
-builder.Services.AddControllersWithViews();
+builder.Services.AddControllersWithViews(options =>
+ options.OutputFormatters.RemoveType<Microsoft.AspNetCore.Mvc.Formatters.HttpNoContentOutputFormatter>());
var app = builder.Build();
@@ -35,6 +46,7 @@ if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseRouting();
+app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
diff --git a/src/marlin.web/Properties/launchSettings.json b/src/marlin.web/Properties/launchSettings.json
index b5224c4..e781de7 100644
--- a/src/marlin.web/Properties/launchSettings.json
+++ b/src/marlin.web/Properties/launchSettings.json
@@ -4,7 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"applicationUrl": "http://localhost:5090",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -13,7 +13,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"applicationUrl": "https://localhost:7201;http://localhost:5090",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/src/marlin.web/Services/AgentAuthenticationHandler.cs b/src/marlin.web/Services/AgentAuthenticationHandler.cs
new file mode 100644
index 0000000..519d109
--- /dev/null
+++ b/src/marlin.web/Services/AgentAuthenticationHandler.cs
@@ -0,0 +1,79 @@
+namespace marlin.web.Services;
+
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Data;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Primitives;
+
+public class AgentSchemeOptions : AuthenticationSchemeOptions
+{
+ public const string Name = "Agent";
+ public string AgentHeader { get; set; } = "Agent";
+ public string JobHeader { get; set; } = "Job";
+}
+
+public class AgentAuthenticationHandler : AuthenticationHandler<AgentSchemeOptions>
+{
+ private readonly ApplicationDbContext _dbContext;
+
+ public AgentAuthenticationHandler(
+ IOptionsMonitor<AgentSchemeOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ApplicationDbContext dbContext) : base(options, logger, encoder)
+ {
+ this._dbContext = dbContext;
+ }
+
+ private bool TryReadPair(string headerName, out Guid id, out string token)
+ {
+ id = Guid.Empty;
+ token = string.Empty;
+
+ if (!Request.Headers.TryGetValue(headerName, out StringValues header))
+ return false;
+
+ var parts = header.ToString().Split(' ', 2);
+ if (parts.Length != 2 || parts[1].Length == 0)
+ return false;
+
+ token = parts[1];
+ return Guid.TryParse(parts[0], out id);
+ }
+
+ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
+ {
+ var claims = new List<Claim>();
+
+ if (!TryReadPair(Options.AgentHeader, out var agentId, out var agentToken))
+ return AuthenticateResult.NoResult();
+
+ var agent = await this._dbContext.Agents
+ .FirstOrDefaultAsync(x => x.Id == agentId && x.AuthToken == agentToken);
+
+ if (agent == null)
+ return AuthenticateResult.Fail("Invalid agent credentials.");
+
+ claims.Add(new Claim("agent_id", agent.Id.ToString()));
+
+ if (TryReadPair(Options.JobHeader, out var jobId, out var jobToken))
+ {
+ var job = await this._dbContext.Jobs
+ .FirstOrDefaultAsync(x => x.Id == jobId && x.AgentId == agent.Id && x.ClaimToken == jobToken);
+
+ if (job == null)
+ return AuthenticateResult.Fail("Invalid job claim token.");
+
+ claims.Add(new Claim("job_id", job.Id.ToString()));
+ }
+
+ var identity = new ClaimsIdentity(claims, this.Scheme.Name);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, this.Scheme.Name);
+
+ return AuthenticateResult.Success(ticket);
+ }
+}
diff --git a/src/marlin.web/Services/VersionControlSyncService.cs b/src/marlin.web/Services/VersionControlSyncService.cs
index bf189b0..cd75ed6 100644
--- a/src/marlin.web/Services/VersionControlSyncService.cs
+++ b/src/marlin.web/Services/VersionControlSyncService.cs
@@ -207,8 +207,9 @@ public class VersionControlSyncService : BackgroundService
foreach (VersionControlledCommit commit in refs.Select(x => x.Commit))
{
- var blobNames = await client.ListTree(localPath, commit.Hash, ".");
-
+ var blobNames = await client.ListTree(localPath, commit.Hash, ".marlin");
+ var workflowFiles = new List<string>();
+
foreach (string blobName in blobNames)
{
var content = await client.CatFile(localPath, commit.Hash, blobName);
@@ -219,9 +220,40 @@ public class VersionControlSyncService : BackgroundService
Path = blobName,
Content = content
});
+
+ if (blobName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase) ||
+ blobName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase))
+ workflowFiles.Add(Encoding.UTF8.GetString(content));
}
-
+
commit.BlobsFetched = true;
+
+ this.ScanWorkflow(dbContext, commit, workflowFiles);
+ }
+ }
+
+ private void ScanWorkflow(ApplicationDbContext dbContext, VersionControlledCommit commit, List<string> workflowFiles)
+ {
+ try
+ {
+ var builder = new WorkflowBuilder();
+ foreach (var file in workflowFiles)
+ builder.LoadFromString(file);
+
+ dbContext.WorkflowSnapshots.Add(new WorkflowSnapshot
+ {
+ Commit = commit,
+ ScannedAt = DateTime.UtcNow,
+ Workflow = builder.Build()
+ });
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogWarning(ex, "Failed to parse workflow for commit '{hash}'", commit.Hash);
+ }
+ finally
+ {
+ commit.WorkflowScanned = true;
}
}
} \ No newline at end of file
diff --git a/src/marlin.web/Util/ClaimsPrincipalExtensions.cs b/src/marlin.web/Util/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..3f4c307
--- /dev/null
+++ b/src/marlin.web/Util/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,22 @@
+namespace marlin.web.Util;
+
+using System.Security.Claims;
+
+public static class ClaimsPrincipalExtensions
+{
+ public static Guid? GetAgentId(this ClaimsPrincipal user) => user.GetGuidClaim("agent_id");
+ public static Guid? GetJobId(this ClaimsPrincipal user) => user.GetGuidClaim("job_id");
+ public static Guid? GetUserId(this ClaimsPrincipal user) => user.GetGuidClaim("user_id");
+
+ public static Guid RequireAgentId(this ClaimsPrincipal user) =>
+ user.GetAgentId() ?? throw new InvalidOperationException("Request has no agent_id claim.");
+
+ public static Guid RequireJobId(this ClaimsPrincipal user) =>
+ user.GetJobId() ?? throw new InvalidOperationException("Request has no job_id claim.");
+
+ public static Guid RequireUserId(this ClaimsPrincipal user) =>
+ user.GetUserId() ?? throw new InvalidOperationException("Request has no user_id claim.");
+
+ private static Guid? GetGuidClaim(this ClaimsPrincipal user, string type) =>
+ Guid.TryParse(user.FindFirstValue(type), out var value) ? value : null;
+}
diff --git a/src/marlin.web/Views/Agents/Create.cshtml b/src/marlin.web/Views/Agents/Create.cshtml
new file mode 100644
index 0000000..4f0f31a
--- /dev/null
+++ b/src/marlin.web/Views/Agents/Create.cshtml
@@ -0,0 +1,9 @@
+@using marlin.core.models
+
+<form method="post" asp-action="Create">
+ Name: <input type="text" name="@nameof(Agent.Name)" /><br/>
+ Max Concurrent Jobs: <input type="number" name="@nameof(Agent.MaxConcurrentJobs)" value="1"/><br/>
+ <button type="submit">Create</button>
+</form>
+
+<a asp-action="List">Back</a>
diff --git a/src/marlin.web/Views/Agents/Details.cshtml b/src/marlin.web/Views/Agents/Details.cshtml
new file mode 100644
index 0000000..3512a98
--- /dev/null
+++ b/src/marlin.web/Views/Agents/Details.cshtml
@@ -0,0 +1,67 @@
+@using marlin.core.models
+@model Agent
+
+<div class="row">
+ <div class="col">
+ <h3>Details</h3>
+ <hr/>
+ <form method="post" asp-route-id="@Model.Id" asp-action="Edit">
+ <dl>
+ <dt>Name</dt>
+ <dd>
+ <input type="text" name="@nameof(Agent.Name)" value="@Model.Name"/>
+ </dd>
+
+ <dt>Max Concurrent Jobs</dt>
+ <dd>
+ <input type="number" name="@nameof(Agent.MaxConcurrentJobs)" value="@Model.MaxConcurrentJobs"/>
+ </dd>
+
+ <dt>Last Seen</dt>
+ <dd>
+ @Model.LastSeen.ToString("G")
+ </dd>
+
+ <button type="submit">Save</button>
+ </dl>
+ </form>
+ </div>
+
+ <div class="col">
+ <h3>Setup</h3>
+ <hr/>
+ <form method="post" asp-route-id="@Model.Id" asp-action="RegenerateToken">
+ <dl>
+ <dt>Agent ID</dt>
+ <dd>
+ @Model.Id
+ </dd>
+
+ <dt>Auth Token</dt>
+ <dd>
+ @Model.AuthToken
+ </dd>
+
+ <button type="submit">Regenerate</button>
+ </dl>
+ </form>
+ </div>
+</div>
+
+<h3>Capabilities</h3>
+<hr/>
+<table class="vertable">
+ <tbody>
+ @foreach (var capability in Model.Capabilities.Items)
+ {
+ <tr>
+ <td>@capability.Key</td>
+ <td>@capability.Value</td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+<a asp-controller="Jobs" asp-action="List" asp-route-Agent="@Model.Id" asp-route-Status="@JobStatus.Active">Active
+ jobs</a>
+<a asp-action="List">Back</a>
diff --git a/src/marlin.web/Views/Agents/List.cshtml b/src/marlin.web/Views/Agents/List.cshtml
new file mode 100644
index 0000000..cb0e0df
--- /dev/null
+++ b/src/marlin.web/Views/Agents/List.cshtml
@@ -0,0 +1,32 @@
+@model ICollection<Agent>
+
+<a asp-action="Create">New</a>
+
+<table>
+ <thead>
+ <tr>
+ <th>name</th>
+ <th>seen</th>
+ <th>status</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var agent in Model)
+ {
+ <tr>
+ <td><a asp-action="Details" asp-route-id="@agent.Id">@agent.Name</a></td>
+ <td>@agent.LastSeen.ToString("G")</td>
+ <td>
+ @if (agent.Jobs.Any())
+ {
+ <span>processing @agent.Jobs.Count jobs</span>
+ }
+ else
+ {
+ <span>idling</span>
+ }
+ </td>
+ </tr>
+ }
+ </tbody>
+</table> \ No newline at end of file
diff --git a/src/marlin.web/Views/Jobs/Details.cshtml b/src/marlin.web/Views/Jobs/Details.cshtml
index e1fd184..ebba94d 100644
--- a/src/marlin.web/Views/Jobs/Details.cshtml
+++ b/src/marlin.web/Views/Jobs/Details.cshtml
@@ -1,4 +1,4 @@
-@model marlin.core.models.Job
+@model Job
@Model.Id
diff --git a/src/marlin.web/Views/Jobs/List.cshtml b/src/marlin.web/Views/Jobs/List.cshtml
index 6ae1d96..968a94f 100644
--- a/src/marlin.web/Views/Jobs/List.cshtml
+++ b/src/marlin.web/Views/Jobs/List.cshtml
@@ -1,11 +1,10 @@
-@model ICollection<marlin.core.models.Job>
+@model ICollection<Job>
<a asp-action="Spawn">Spawn new...</a>
<table>
<thead>
<tr>
- <th>link</th>
<th>id</th>
<th>status</th>
<th>recipe</th>
@@ -19,12 +18,11 @@
@foreach (var source in Model)
{
<tr>
- <td><a asp-action="Details" asp-route-id="@source.Id">open</a></td>
- <td>@source.Id</td>
+ <td><a asp-action="Details" asp-route-id="@source.Id">@source.Id</a></td>
<td>@source.Status</td>
<td>@source.Recipe</td>
<td>@source.Source.UpstreamUrl</td>
- <td>@source.Ref</td>
+ <td>@source.Branch.Name</td>
<td>@(source.Agent?.Name ?? "no agent assigned")</td>
<td>@(source.CreatedReason)</td>
</tr>
diff --git a/src/marlin.web/Views/Jobs/Spawn.cshtml b/src/marlin.web/Views/Jobs/Spawn.cshtml
index 8f7ee43..848cae4 100644
--- a/src/marlin.web/Views/Jobs/Spawn.cshtml
+++ b/src/marlin.web/Views/Jobs/Spawn.cshtml
@@ -9,15 +9,21 @@
id = s.Id,
label = s.UpstreamUrl,
mainBranch = s.MainBranch,
- branches = s.Branches.Select(b => new { name = b.Name, commit = b.Commit.Hash })
+ branches = s.Branches.Select(b => new
+ {
+ id = b.Id,
+ name = b.Name,
+ commit = b.Commit.Hash,
+ recipes = b.Commit.WorkflowSnapshot?.Workflow.Recipes.Values.Select(r => new { id = r.Id, name = r.Name })
+ ?? Enumerable.Empty<object>()
+ })
}));
}
<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/>
+ Ref: <select id="ref" name="@nameof(JobsController.JobSpawnRequest.Branch)"></select><br/>
+ Recipe: <select id="recipe" name="@nameof(JobsController.JobSpawnRequest.Recipe)"></select><br/>
<button type="submit">Save</button>
</form>
@@ -25,7 +31,7 @@
const sources = @Html.Raw(sourcesJson);
const vcsSelect = document.getElementById('vcs');
const refSelect = document.getElementById('ref');
- const commitInput = document.getElementById('commit');
+ const recipeSelect = document.getElementById('recipe');
for (const s of sources) {
vcsSelect.add(new Option(s.label, s.id));
@@ -35,22 +41,29 @@
return sources.find(s => s.id === vcsSelect.value);
}
+ function currentBranch() {
+ return currentSource()?.branches.find(b => b.id === refSelect.value);
+ }
+
function populateRefs() {
refSelect.innerHTML = '';
for (const b of currentSource()?.branches ?? []) {
- refSelect.add(new Option(b.name, b.name));
+ refSelect.add(new Option(b.name, b.id));
}
- refSelect.value = currentSource()?.mainBranch ?? '';
- populateCommit();
+ const mainBranch = currentSource()?.branches.find(b => b.name === currentSource()?.mainBranch);
+ refSelect.value = mainBranch?.id ?? '';
+ populateRecipes();
}
- function populateCommit() {
- const branch = currentSource()?.branches.find(b => b.name === refSelect.value);
- commitInput.value = branch?.commit ?? '';
+ function populateRecipes() {
+ recipeSelect.innerHTML = '';
+ for (const r of currentBranch()?.recipes ?? []) {
+ recipeSelect.add(new Option(r.name, r.id));
+ }
}
vcsSelect.addEventListener('change', populateRefs);
- refSelect.addEventListener('change', populateCommit);
+ refSelect.addEventListener('change', populateRecipes);
populateRefs();
</script>
diff --git a/src/marlin.web/Views/Shared/_Layout.cshtml b/src/marlin.web/Views/Shared/_Layout.cshtml
index e8be2af..8ece676 100644
--- a/src/marlin.web/Views/Shared/_Layout.cshtml
+++ b/src/marlin.web/Views/Shared/_Layout.cshtml
@@ -29,6 +29,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Jobs" asp-action="List">Jobs</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link text-dark" asp-area="" asp-controller="Agents" asp-action="List">Agents</a>
+ </li>
</ul>
</div>
</div>
diff --git a/src/marlin.web/Views/VCS/Blobs.cshtml b/src/marlin.web/Views/VCS/Blobs.cshtml
new file mode 100644
index 0000000..ab35260
--- /dev/null
+++ b/src/marlin.web/Views/VCS/Blobs.cshtml
@@ -0,0 +1,22 @@
+@using System.Text
+@model ICollection<VersionControlledBlob>
+
+<table>
+ <thead>
+ <tr>
+ <th>path</th>
+ <th>size</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var source in Model)
+ {
+ <tr>
+ <td>@source.Path</td>
+ <td>@source.Content.Length bytes</td>
+ </tr>
+ }
+ </tbody>
+</table>
+
+<a asp-action="Details" asp-route-id="@Model.First().SourceId">back</a>
diff --git a/src/marlin.web/Views/VCS/Details.cshtml b/src/marlin.web/Views/VCS/Details.cshtml
index 45c1a8f..a9ef0ea 100644
--- a/src/marlin.web/Views/VCS/Details.cshtml
+++ b/src/marlin.web/Views/VCS/Details.cshtml
@@ -25,6 +25,7 @@
<th></th>
<th>branch</th>
<th>commit</th>
+ <th>status</th>
</tr>
</thead>
<tbody>
@@ -34,6 +35,17 @@
<td>@(branch.Name == Model.Source.MainBranch ? "@" : "")</td>
<td>@branch.Name</td>
<td>@branch.Commit.Hash</td>
+ <td>
+ @if (branch.Commit.BlobsFetched)
+ {
+ <a asp-action="Blobs" asp-route-commitId="@branch.CommitId" asp-route-sourceId="@branch.SourceId">blobs</a>
+ }
+
+ @if (branch.Commit.WorkflowScanned)
+ {
+ <a asp-action="Workflow" asp-route-commitId="@branch.CommitId" asp-route-sourceId="@branch.SourceId">workflow</a>
+ }
+ </td>
</tr>
}
</tbody>
diff --git a/src/marlin.web/Views/VCS/List.cshtml b/src/marlin.web/Views/VCS/List.cshtml
index 4c248e4..f109c92 100644
--- a/src/marlin.web/Views/VCS/List.cshtml
+++ b/src/marlin.web/Views/VCS/List.cshtml
@@ -1,4 +1,4 @@
-@model ICollection<marlin.core.models.VersionControlSource>
+@model ICollection<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
index aa5f15c..25db7fe 100644
--- a/src/marlin.web/Views/VCS/ListSecret.cshtml
+++ b/src/marlin.web/Views/VCS/ListSecret.cshtml
@@ -1,4 +1,4 @@
-@model (marlin.core.models.VersionControlSource Source, List<ISecret> Secrets)
+@model (VersionControlSource Source, List<ISecret> Secrets)
<a asp-action="CreateSecret" asp-route-sourceId="@Model.Source.Id">New</a>
diff --git a/src/marlin.web/Views/VCS/Workflow.cshtml b/src/marlin.web/Views/VCS/Workflow.cshtml
new file mode 100644
index 0000000..da32ab1
--- /dev/null
+++ b/src/marlin.web/Views/VCS/Workflow.cshtml
@@ -0,0 +1,18 @@
+@model WorkflowSnapshot
+
+<h2>Workflow snapshot for @Model.Commit.Hash</h2>
+
+@foreach (var recipe in Model.Workflow.Recipes)
+{
+ <section>
+ <h3>@recipe.Value.Name</h3>
+ <ol>
+ @foreach (var step in recipe.Value.Steps)
+ {
+ <li>@step.Value.Name</li>
+ }
+ </ol>
+ </section>
+}
+
+<a asp-action="Details" asp-route-id="@Model.Commit.SourceId">back</a>
diff --git a/src/marlin.web/wwwroot/css/site.css b/src/marlin.web/wwwroot/css/site.css
index c6ecfb7..fa66346 100644
--- a/src/marlin.web/wwwroot/css/site.css
+++ b/src/marlin.web/wwwroot/css/site.css
@@ -8,6 +8,28 @@ html {
}
}
+input[readonly] {
+ background-color: lightgray;
+}
+
+dl {
+ display: grid;
+ grid-template-columns: max-content auto;
+ grid-column-gap: 10px;
+}
+
+dl > form {
+ display: contents;
+}
+
+dt {
+ grid-column-start: 1;
+}
+
+dd {
+ grid-column-start: 2;
+}
+
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
@@ -35,6 +57,15 @@ table {
width: 100%;
}
+table.vertable td:first-child {
+ background-color: #f0f0f0;
+ font-weight: bold;
+}
+
+table tr:hover {
+ background-color: #fafafa
+}
+
th, td {
border: 1px solid #ccc;
text-align: left;