diff --git a/docs/decisions/0030-stack-device-mappings.md b/docs/decisions/0030-stack-device-mappings.md new file mode 100644 index 0000000..a4c3685 --- /dev/null +++ b/docs/decisions/0030-stack-device-mappings.md @@ -0,0 +1,68 @@ +# ADR-0030: Host device mappings are a per-stack setting, injected through the generated override + +## Status + +Accepted + +## Context + +Some workloads need a host device inside a container — the canonical case is GPU-accelerated +transcoding via `/dev/dri/renderD128`. Compose expresses this with the service-level `devices:` key, +but the value is inherently **host-specific**: which render node exists (and whether one exists at +all) differs per machine, while the compose file in the product's repository is shared by every stack +of that product on every host (ADR-0026). Committing a device path to the repository either breaks +the deployments that lack the device or forces every consumer to define pass-through variables for +something that is not the application's concern. + +Watchtower already has exactly one mechanism for adding configuration to somebody else's compose file +without touching the repository: the generated override file merged in as a second `--file` +(ADR-0012), which today carries injected environment variables and release image pins (ADR-0026 +decision 6). And it has a house precedent for per-service UI configuration: the backup service +overrides of ADR-0020, stored per `(stack, service)` and replaced whole on save. + +Compose's merge rule for `devices:` (like `volumes:`) merges entries **by container path**: entries +in a later file with a new target are appended, and an entry with the same target replaces the +earlier one. So an override file can add devices to a service that declares none, and coexist with a +repository that declares some. + +## Decision + +1. **Device mappings are stored per stack, keyed by compose service name** — a + `stack_device_mappings` row per device: service, host path, container path, optional cgroup + permissions (`r`/`w`/`m`). The set is replaced atomically via `stacks.setDevices` (the + `stacks.setEnv` shape), read via `stacks.getDevices`, and edited in the stack's Settings tab. Rows + are keyed by service name, not container id, so they survive redeploys and apply to every replica. + +2. **The deploy renders them into the ADR-0012 generated override** as a `devices:` list under the + service. The policy half is a `DeviceMappingPlan` — runtime-neutral per ADR-0010's seam rule: it + names no Compose concept, so a future Kubernetes engine could apply the same plan as + `volumeDevices`/CDI annotations — and only `ComposeOverrideFile` knows what YAML it becomes. + +3. **A mapping for a service the resolved project does not contain is a warning, not a failure** — + the same tolerance as image pinning: services come and go with the repository, and failing the + deploy would break a fleet over a leftover row. The warning lands in the deploy output; so does + one line per applied device, so "why does this container see the GPU" is answerable from the + deploy log alone. + +4. **On a container-path collision, the Watchtower mapping wins** (Compose's own merge semantics). + This deliberately inverts ADR-0020's "labels win": device paths are per-host facts, and the + per-host value must be able to override a repository default — the repo may declare a generic + `/dev/dri`, one host may need a specific card. The deploy log names every applied device, so + nothing is silently overridden. ADR-0014's actual hazard — a UI edit that silently never takes + effect — cannot occur here, because the override always applies. + +5. **No template-level twin, no NVIDIA `gpus`/CDI support for now.** Device paths are host-specific, + which is the opposite of what a template shares across tenants; a fleet-wide default can be added + later if a real need appears. NVIDIA GPUs want `deploy.resources.reservations.devices` (a + different mechanism); out of scope until asked for. + +## Consequences + +- A host GPU (or serial port, TPU, …) reaches a stack's container with zero repository changes, and + the same repository deploys unchanged on hosts without the device. +- The mapping grants the container access to a host device node — an operator-level capability, so + the change is audit-logged like other stack lifecycle operations. +- The generated override is no longer byte-identical to its pre-device form only when mappings exist; + a stack with none renders exactly what it rendered before (the ADR-0012/0026 invariant holds). +- Devices configured here are invisible to `docker compose` invocations made outside Watchtower — + consistent with ADR-0012, which already accepted that Watchtower owns the whole invocation. diff --git a/docs/decisions/0031-host-gpu-passthrough-by-intent.md b/docs/decisions/0031-host-gpu-passthrough-by-intent.md new file mode 100644 index 0000000..e29a08f --- /dev/null +++ b/docs/decisions/0031-host-gpu-passthrough-by-intent.md @@ -0,0 +1,76 @@ +# ADR-0031: "Map host GPUs" is a per-service intent, resolved by probing the Docker host at deploy time + +## Status + +Accepted + +## Context + +ADR-0030 lets an operator map a host device like `/dev/dri/renderD128` into a stack's container by +literal path. For the case that motivated it — GPU-accelerated transcoding — literal paths are still +one notch too concrete: + +- `renderD128` merely means "the first GPU in probe order". On a single-GPU host it is stable; the + number is an implementation detail either way, and the operator should not need to know it. +- The value differs per host, so a literal path cannot be shared — which is why ADR-0030 rejected + template-level mappings. An *intent* ("this service wants the host GPUs") is host-neutral and + could be shared. +- Mapping the node is not always enough: the container's user must be in the device node's owning + group (`render`/`video`), whose **GID differs per host**. This is the classic "device mapped but + VAAPI still fails" trap, and no literal-path UI can solve it. + +The kernel makes the concrete facts cheaply and *deterministically* discoverable. DRM render nodes +are always `/dev/dri/renderD` (minors from 128), and per node sysfs reports the PCI vendor id +(`/sys/class/drm/renderD/device/vendor` — `0x8086` Intel, `0x1002` AMD, `0x10de` NVIDIA), the +bound driver (`uevent`, e.g. `i915`, `amdgpu`), and the PCI address; `stat` on the node gives the +owning group's GID. Watchtower's own container does not see the host's `/dev` — but the backup +feature already established the pattern for that: a short-lived helper container (ADR-0016's +`busybox:stable`, operator-configurable) with the needed paths bind-mounted. + +NVIDIA is the deliberate odd one out: mapping `/dev/nvidia*` nodes is not sufficient (the container +also needs the toolkit-injected user-space driver), so a device mapping would *look* supported and +fail inconsistently — the worst outcome. + +## Decision + +1. **GPU passthrough is stored as an intent, keyed `(stack, service)`** — a `stack_gpu_mappings` + row meaning "map every mappable host GPU into this service". No paths are stored; the row is + host-neutral. It is edited in the same Settings section and replaced atomically by the same + `stacks.setDevices` call as the literal mappings (one save, one audit entry). + +2. **A deploy resolves the intent against a live host probe.** `HostGpuProbe` runs the backup + helper image with the host's `/dev` and `/sys` bind-mounted read-only (`NetworkMode: none`, no + device grants — the default device cgroup denies opening the nodes; the probe only lists and + stats). It reports each render node's path, vendor, driver, PCI address and owning GID, cached + for a few minutes. A probe failure is a deploy-log warning and an empty catalog — never a failed + deploy, and never a blocker for stacks that use no GPU intent. + +3. **Resolution maps render nodes and injects the group.** Every non-NVIDIA render node becomes a + `devices:` entry (same generated-override mechanism as ADR-0030), and the union of the mapped + nodes' GIDs becomes a `group_add:` list on the service — Compose appends `group_add`, so the + repository's own entries survive. NVIDIA nodes are skipped with a deploy-log note naming the + toolkit (`gpus:`/CDI) as the supported route — a later ADR when someone needs it. + +4. **A host without a mappable GPU is a note, not a warning.** That is the feature working as + designed — the same stack deploys everywhere and gets the GPU where one exists. Unknown service + names keep ADR-0030's warning treatment. + +5. **The plan stays runtime-neutral** (ADR-0010): `DeviceMappingPlan` gains GPU intents, the probed + catalog, and per-service supplemental group ids — all concepts Kubernetes expresses natively + (`volumeDevices`/CDI, `supplementalGroups`). Only `ComposeOverrideFile` knows about `devices:` + and `group_add:` syntax. + +## Consequences + +- The Settings UI can offer "map host GPU(s)" per service plus a read-out of what the probe found + ("renderD128 — intel, i915, 0000:00:02.0"), including the honest empty state on GPU-less hosts + and on Docker Desktop. +- Because the intent is host-neutral, template-level sharing becomes possible later — the reason + ADR-0030 rejected it (literal paths) does not apply to intents. Not built yet. +- Multi-GPU hosts map *all* mappable GPUs. Selecting a specific one (which would need the stable + `/dev/dri/by-path/pci-…-render` alias to survive probe-order shuffles) is deferred until a real + multi-GPU need appears; the literal-path editor covers it meanwhile. +- The probe's device list is only as fresh as its cache and container hot-plug does not exist in + Docker's model anyway: a GPU that appears or vanishes takes effect on the next deploy. +- One more place runs the helper image; it inherits the backup feature's pull-on-first-use and + operator-configurable image reference. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9e779c5..afddd43 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -48,3 +48,5 @@ live here. - [ADR-0027: Watchtower backs itself up, and a bundle restores it somewhere else](0027-full-instance-backup-and-restore.md) - [ADR-0028: CI runners carry the host's BuildKit knowledge — a generated default buildkitd config, and a reusable docker-driver workflow](0028-ci-buildkit-defaults.md) - [ADR-0029: Zero-downtime deploys — routed services warm up in a new generation, then traffic swaps](0029-blue-green-stack-deploys.md) — *proposed* +- [ADR-0030: Host device mappings are a per-stack setting, injected through the generated override](0030-stack-device-mappings.md) +- [ADR-0031: "Map host GPUs" is a per-service intent, resolved by probing the Docker host at deploy time](0031-host-gpu-passthrough-by-intent.md) diff --git a/rpc-schema.json b/rpc-schema.json index 9cb5193..59a50e3 100644 --- a/rpc-schema.json +++ b/rpc-schema.json @@ -10922,6 +10922,67 @@ ] } }, + "stacks.getDevices": { + "params": { + "type": "object", + "properties": { + "stackId": { + "type": "integer" + } + }, + "required": [ + "stackId" + ] + }, + "result": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "service": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "permissions": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "service", + "hostPath", + "containerPath", + "permissions" + ] + } + }, + "gpuServices": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "devices", + "gpuServices" + ] + } + }, "stacks.getEnv": { "params": { "type": "object", @@ -10965,6 +11026,60 @@ ] } }, + "stacks.hostGpus": { + "params": { + "type": "object" + }, + "result": { + "type": "object", + "properties": { + "gpus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "pciAddress": { + "type": "string" + }, + "mappable": { + "type": "boolean" + } + }, + "required": [ + "name", + "path", + "vendor", + "driver", + "pciAddress", + "mappable" + ] + } + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "gpus", + "error" + ] + } + }, "stacks.list": { "params": { "type": "object" @@ -11238,6 +11353,110 @@ ] } }, + "stacks.setDevices": { + "params": { + "type": "object", + "properties": { + "stackId": { + "type": "integer" + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "service": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": [ + "string", + "null" + ], + "default": null + }, + "permissions": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "service", + "hostPath" + ] + } + }, + "gpuServices": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "default": null + } + }, + "required": [ + "stackId", + "devices" + ] + }, + "result": { + "type": "object", + "properties": { + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "service": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "permissions": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "service", + "hostPath", + "containerPath", + "permissions" + ] + } + }, + "gpuServices": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "devices", + "gpuServices" + ] + } + }, "stacks.setEnv": { "params": { "type": "object", diff --git a/src/Watchtower.Application.Tests/ComposeOverrideFileTests.cs b/src/Watchtower.Application.Tests/ComposeOverrideFileTests.cs index 2978b57..6354586 100644 --- a/src/Watchtower.Application.Tests/ComposeOverrideFileTests.cs +++ b/src/Watchtower.Application.Tests/ComposeOverrideFileTests.cs @@ -326,6 +326,89 @@ public void Render_EscapesAPinnedImageAgainstComposeInterpolation() { StringComparison.Ordinal); } + // ── Merging the device-mapping plan into the same override ─────────────────────────────────── + + /// + /// All three policies land in the one document (ADR-0030): devices: renders after + /// image: and environment:, in Compose's host:container[:permissions] string + /// form, and a service only the device plan names still gets its own entry. + /// + [Fact] + public void Render_MergesDeviceMappingsIntoTheSameDocument() { + var plan = EnvInjectionPlan.Create(new EnvInjectionRequest( + [new EnvInjectionService("web", "true")], StackId: 7, AppApiToken: "wtapp_abc")); + var devicePlan = new DeviceMappingPlan( + [new ServiceDeviceMappings("transcoder", + [new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", null), + new ServiceDevice("/dev/ttyUSB0", "/dev/ttyUSB1", "rw")]), + new ServiceDeviceMappings("web", + [new ServiceDevice("/dev/fuse", "/dev/fuse", "rwm")])], + []); + + Assert.Equal( + """ + # Generated by Watchtower for this deploy — not part of the repository. + services: + 'transcoder': + devices: + - '/dev/dri/renderD128:/dev/dri/renderD128' + - '/dev/ttyUSB0:/dev/ttyUSB1:rw' + 'web': + environment: + 'WATCHTOWER_APP_TOKEN': 'wtapp_abc' + 'WATCHTOWER_STACK_ID': '7' + devices: + - '/dev/fuse:/dev/fuse:rwm' + + """.ReplaceLineEndings("\n"), + ComposeOverrideFile.Render(plan, imagePlan: null, devicePlan)); + } + + /// + /// A GPU-resolved service (ADR-0031) carries the nodes' owning groups too: group_add: + /// renders after devices:, each GID as a quoted string — an unquoted number is looked up + /// as a group name inside the container by some runtimes. + /// + [Fact] + public void Render_WritesGroupAddForAServiceWithGroupIds() { + var devicePlan = new DeviceMappingPlan( + [new ServiceDeviceMappings("transcoder", + [new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", null)]) { + GroupIds = [44, 105], + }], + []); + + Assert.Equal( + """ + # Generated by Watchtower for this deploy — not part of the repository. + services: + 'transcoder': + devices: + - '/dev/dri/renderD128:/dev/dri/renderD128' + group_add: + - '44' + - '105' + + """.ReplaceLineEndings("\n"), + ComposeOverrideFile.Render(EnvInjectionPlan.Empty, imagePlan: null, devicePlan)); + } + + /// + /// The ADR-0030 back-compat guarantee: a stack with no device rows renders exactly its pre-device + /// document — an empty device plan and an absent one are the same bytes, and cannot alone produce + /// a file. + /// + [Fact] + public void Render_IsUnchangedWithoutADevicePlan() { + var plan = EnvInjectionPlan.Create(new EnvInjectionRequest( + [new EnvInjectionService("app")], StackId: 1, AppApiToken: "wtapp_x")); + + Assert.Equal( + ComposeOverrideFile.Render(plan), + ComposeOverrideFile.Render(plan, imagePlan: null, DeviceMappingPlan.Empty)); + Assert.Null(ComposeOverrideFile.Render(EnvInjectionPlan.Empty, null, DeviceMappingPlan.Empty)); + } + private static string RenderSingleService(string token) { var plan = EnvInjectionPlan.Create(new EnvInjectionRequest( [new EnvInjectionService("app")], StackId: 1, AppApiToken: token)); diff --git a/src/Watchtower.Application.Tests/DeployConcurrencyGateTests.cs b/src/Watchtower.Application.Tests/DeployConcurrencyGateTests.cs index c6ebb94..41503f2 100644 --- a/src/Watchtower.Application.Tests/DeployConcurrencyGateTests.cs +++ b/src/Watchtower.Application.Tests/DeployConcurrencyGateTests.cs @@ -136,6 +136,7 @@ private static DeployQueueService CreateQueue(AuthTestHost host, ComposeCliServi host.Services.GetRequiredService(), host.Services.GetRequiredService(), host.Services.GetRequiredService(), + host.Services.GetRequiredService(), host.Services.GetRequiredService>(), NullLogger.Instance); diff --git a/src/Watchtower.Application.Tests/DeployEnvInjectionTests.cs b/src/Watchtower.Application.Tests/DeployEnvInjectionTests.cs index 5c8c30f..b64ced4 100644 --- a/src/Watchtower.Application.Tests/DeployEnvInjectionTests.cs +++ b/src/Watchtower.Application.Tests/DeployEnvInjectionTests.cs @@ -268,6 +268,7 @@ private static async Task RunDeployAsync( host.Services.GetRequiredService(), host.Services.GetRequiredService(), host.Services.GetRequiredService(), + host.Services.GetRequiredService(), host.Services.GetRequiredService>(), NullLogger.Instance)) { await queue.ExecuteDeployAsync(stackId, eventId, DeployTriggers.Manual, removeVolumes: null, ct); diff --git a/src/Watchtower.Application.Tests/DeviceMappingPlanTests.cs b/src/Watchtower.Application.Tests/DeviceMappingPlanTests.cs new file mode 100644 index 0000000..d120c84 --- /dev/null +++ b/src/Watchtower.Application.Tests/DeviceMappingPlanTests.cs @@ -0,0 +1,182 @@ +using Watchtower.Application.Entities; +using Watchtower.Application.Services; +using Xunit; + +namespace Watchtower.Application.Tests; + +/// +/// Covers — placing a stack's stored device mappings (ADR-0030) onto +/// the services the engine resolved. Pure policy, like : the tolerance cases +/// are the point, because a leftover row must warn rather than fail a deploy. +/// +public sealed class DeviceMappingPlanTests { + private static readonly IReadOnlyList Services = + [new EnvInjectionService("web"), new EnvInjectionService("transcoder")]; + + private static StackDeviceMapping Row( + string service, string host, string? container = null, string? permissions = null) => + new() { Service = service, HostPath = host, ContainerPath = container ?? host, Permissions = permissions }; + + [Fact] + public void Create_ReturnsEmptyForNoMappings() => + Assert.Same(DeviceMappingPlan.Empty, DeviceMappingPlan.Create(Services, [])); + + /// + /// Services in ordinal name order, each service's devices ordered by container then host path — + /// deterministic, so a rendered override is diffable between deploys. + /// + [Fact] + public void Create_OrdersServicesAndDevicesDeterministically() { + var plan = DeviceMappingPlan.Create(Services, [ + Row("web", "/dev/fuse"), + Row("transcoder", "/dev/ttyUSB0", "/dev/ttyUSB1", "rw"), + Row("transcoder", "/dev/dri/renderD128"), + ]); + + Assert.Empty(plan.Warnings); + Assert.Equal(["transcoder", "web"], plan.Services.Select(s => s.ServiceName)); + Assert.Equal( + [new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", null), + new ServiceDevice("/dev/ttyUSB0", "/dev/ttyUSB1", "rw")], + plan.Services[0].Devices); + Assert.Equal([new ServiceDevice("/dev/fuse", "/dev/fuse", null)], plan.Services[1].Devices); + } + + /// + /// A mapping for a service the resolved project does not contain warns and is skipped — services + /// come and go with the repository, and failing the deploy over a leftover row would take a fleet + /// down (the tolerance rule). + /// + [Fact] + public void Create_WarnsAndSkipsAMappingForAnUnknownService() { + var plan = DeviceMappingPlan.Create(Services, [ + Row("removed-service", "/dev/dri/renderD128"), + Row("web", "/dev/fuse"), + ]); + + var placed = Assert.Single(plan.Services); + Assert.Equal("web", placed.ServiceName); + Assert.Equal([new ServiceDevice("/dev/fuse", "/dev/fuse", null)], placed.Devices); + var warning = Assert.Single(plan.Warnings); + Assert.Contains("'removed-service'", warning, StringComparison.Ordinal); + Assert.Contains("not applied", warning, StringComparison.Ordinal); + } + + /// Nothing placeable still reports why — an all-stale plan is warnings, not silence. + [Fact] + public void Create_ReturnsWarningsOnlyWhenNothingIsPlaceable() { + var plan = DeviceMappingPlan.Create(Services, [Row("gone", "/dev/fuse")]); + + Assert.Empty(plan.Services); + Assert.Single(plan.Warnings); + } + + // ── GPU intents (ADR-0031) ─────────────────────────────────────────────────────────────────── + + private static readonly HostGpu IntelGpu = + new("renderD128", "/dev/dri/renderD128", HostGpu.IntelVendorId, "i915", "0000:00:02.0", 105); + private static readonly HostGpu AmdGpu = + new("renderD129", "/dev/dri/renderD129", HostGpu.AmdVendorId, "amdgpu", "0000:03:00.0", 44); + private static readonly HostGpu NvidiaGpu = + new("renderD130", "/dev/dri/renderD130", HostGpu.NvidiaVendorId, "nvidia", "0000:04:00.0", 44); + + /// + /// A GPU intent resolves to every mappable render node plus the nodes' owning groups — the GID + /// half is the "device mapped but VAAPI still fails" trap this feature exists to remove. + /// + [Fact] + public void Create_ResolvesAGpuIntentToMappableNodesAndTheirGroups() { + var plan = DeviceMappingPlan.Create( + Services, [], + [new StackGpuMapping { Service = "transcoder" }], + [IntelGpu, AmdGpu]); + + var placed = Assert.Single(plan.Services); + Assert.Equal("transcoder", placed.ServiceName); + Assert.Equal( + [new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", null), + new ServiceDevice("/dev/dri/renderD129", "/dev/dri/renderD129", null)], + placed.Devices); + Assert.Equal([44, 105], placed.GroupIds); + Assert.Empty(plan.Warnings); + Assert.Empty(plan.Notes); + } + + /// + /// NVIDIA is skipped with a note (ADR-0031 decision 3): the bare node without the toolkit's + /// user-space driver fails inconsistently, which is worse than not mapping it. + /// + [Fact] + public void Create_SkipsNvidiaNodesWithANote() { + var plan = DeviceMappingPlan.Create( + Services, [], [new StackGpuMapping { Service = "web" }], [IntelGpu, NvidiaGpu]); + + var placed = Assert.Single(plan.Services); + Assert.Equal([new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", null)], placed.Devices); + Assert.Equal([105], placed.GroupIds); + var note = Assert.Single(plan.Notes); + Assert.Contains("NVIDIA", note, StringComparison.Ordinal); + Assert.Contains("'renderD130'", note, StringComparison.Ordinal); + Assert.Empty(plan.Warnings); + } + + /// + /// A GPU-less host is the feature working, not a problem: a note names the services, no warning + /// is raised, and nothing is mapped — the same stack deploys everywhere. + /// + [Fact] + public void Create_NotesWithoutWarningWhenTheHostHasNoMappableGpu() { + var plan = DeviceMappingPlan.Create( + Services, [], [new StackGpuMapping { Service = "web" }], hostGpus: []); + + Assert.Empty(plan.Services); + Assert.Empty(plan.Warnings); + var note = Assert.Single(plan.Notes); + Assert.Contains("No mappable host GPU", note, StringComparison.Ordinal); + Assert.Contains("'web'", note, StringComparison.Ordinal); + } + + /// A GPU intent for a service the project lacks keeps ADR-0030's warning treatment. + [Fact] + public void Create_WarnsForAGpuIntentOnAnUnknownService() { + var plan = DeviceMappingPlan.Create( + Services, [], [new StackGpuMapping { Service = "gone" }], [IntelGpu]); + + Assert.Empty(plan.Services); + var warning = Assert.Single(plan.Warnings); + Assert.Contains("GPU passthrough", warning, StringComparison.Ordinal); + Assert.Contains("'gone'", warning, StringComparison.Ordinal); + } + + /// + /// On a shared container path the explicit row wins — it is the more deliberate statement — but + /// the GPU's group still travels, so the device stays openable either way. + /// + [Fact] + public void Create_ExplicitPathWinsOverAGpuNodeOnTheSameTarget() { + var plan = DeviceMappingPlan.Create( + Services, + [Row("web", "/dev/dri/renderD128", permissions: "rw")], + [new StackGpuMapping { Service = "web" }], + [IntelGpu]); + + var placed = Assert.Single(plan.Services); + Assert.Equal( + [new ServiceDevice("/dev/dri/renderD128", "/dev/dri/renderD128", "rw")], + placed.Devices); + Assert.Equal([105], placed.GroupIds); + } + + /// Exact duplicate rows collapse silently — they cannot disagree about anything. + [Fact] + public void Create_CollapsesExactDuplicates() { + var plan = DeviceMappingPlan.Create(Services, [ + Row("web", "/dev/fuse", permissions: "rw"), + Row("web", "/dev/fuse", permissions: "rw"), + ]); + + var placed = Assert.Single(plan.Services); + Assert.Equal([new ServiceDevice("/dev/fuse", "/dev/fuse", "rw")], placed.Devices); + Assert.Empty(plan.Warnings); + } +} diff --git a/src/Watchtower.Application.Tests/HostGpuProbeTests.cs b/src/Watchtower.Application.Tests/HostGpuProbeTests.cs new file mode 100644 index 0000000..cadf10a --- /dev/null +++ b/src/Watchtower.Application.Tests/HostGpuProbeTests.cs @@ -0,0 +1,57 @@ +using Watchtower.Application.Services; +using Xunit; + +namespace Watchtower.Application.Tests; + +/// +/// Covers — the pure half of the ADR-0031 probe. The +/// container side is a fixed BusyBox script; this is where its output contract is pinned. +/// +public sealed class HostGpuProbeTests { + /// The shape the probe script actually emits, one line per render node. + [Fact] + public void Parse_ReadsWellFormedLines() { + var gpus = HostGpuProbe.ParseProbeOutput([ + "gpu|renderD129|0x1002|amdgpu|0000:03:00.0|44", + "gpu|renderD128|0x8086|i915|0000:00:02.0|105", + ]); + + Assert.Equal( + [new HostGpu("renderD128", "/dev/dri/renderD128", "0x8086", "i915", "0000:00:02.0", 105), + new HostGpu("renderD129", "/dev/dri/renderD129", "0x1002", "amdgpu", "0000:03:00.0", 44)], + gpus); + } + + /// + /// Anything that is not a complete gpu line — shell noise, a node whose stat failed and + /// left the GID empty — is skipped, not failed on: two good GPUs and one oddity is two GPUs. + /// A GID-less node in particular must not be mapped, because group_add is what makes the + /// mapping actually work. + /// + [Fact] + public void Parse_SkipsNoiseAndIncompleteLines() { + var gpus = HostGpuProbe.ParseProbeOutput([ + "sh: something unrelated", + "gpu|renderD128|0x8086|i915|0000:00:02.0|", + "gpu|renderD129|0x8086|i915|0000:00:02.0", + "gpu|renderD130|0x8086|i915|0000:00:02.0|105", + ]); + + Assert.Equal([new HostGpu("renderD130", "/dev/dri/renderD130", "0x8086", "i915", "0000:00:02.0", 105)], gpus); + } + + [Fact] + public void Parse_ReturnsNothingForNoOutput() => Assert.Empty(HostGpuProbe.ParseProbeOutput([])); + + /// NVIDIA is identified by vendor id or driver — either alone marks the node unmappable. + [Theory] + [InlineData("0x10de", "nvidia", false)] + [InlineData("0x10de", "nouveau", false)] + [InlineData("0x8086", "i915", true)] + [InlineData("0x8086", "xe", true)] + [InlineData("0x1002", "amdgpu", true)] + public void IsMappable_ExcludesNvidia(string vendorId, string driver, bool mappable) => + Assert.Equal( + mappable, + new HostGpu("renderD128", "/dev/dri/renderD128", vendorId, driver, "0000:00:02.0", 105).IsMappable); +} diff --git a/src/Watchtower.Application.Tests/ReleaseDeployTests.cs b/src/Watchtower.Application.Tests/ReleaseDeployTests.cs index acc89a9..467b113 100644 --- a/src/Watchtower.Application.Tests/ReleaseDeployTests.cs +++ b/src/Watchtower.Application.Tests/ReleaseDeployTests.cs @@ -557,6 +557,7 @@ private static DeployQueueService CreateQueue( host.Services.GetRequiredService(), host.Services.GetRequiredService(), host.Services.GetRequiredService(), + host.Services.GetRequiredService(), host.Services.GetRequiredService>(), NullLogger.Instance); diff --git a/src/Watchtower.Application.Tests/ReleaseRolloutTests.cs b/src/Watchtower.Application.Tests/ReleaseRolloutTests.cs index 531e713..d62fa88 100644 --- a/src/Watchtower.Application.Tests/ReleaseRolloutTests.cs +++ b/src/Watchtower.Application.Tests/ReleaseRolloutTests.cs @@ -116,8 +116,9 @@ internal sealed class RecordingDeployQueue : DeployQueueService { private RecordingDeployQueue( IServiceScopeFactory scopeFactory, GitCloneService git, ComposeCliService compose, DockerEngineClient docker, DeployOutputBroadcaster broadcaster, IProxyProvider proxy, + HostGpuProbe gpuProbe, Microsoft.Extensions.Options.IOptionsMonitor options) - : base(scopeFactory, git, compose, docker, broadcaster, proxy, options, + : base(scopeFactory, git, compose, docker, broadcaster, proxy, gpuProbe, options, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) => _scopeFactory = scopeFactory; @@ -136,6 +137,7 @@ public static RecordingDeployQueue Create(IServiceProvider services) => services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService< Microsoft.Extensions.Options.IOptionsMonitor>()); diff --git a/src/Watchtower.Application.Tests/StackRevivalTests.cs b/src/Watchtower.Application.Tests/StackRevivalTests.cs index ba30546..13a2fa5 100644 --- a/src/Watchtower.Application.Tests/StackRevivalTests.cs +++ b/src/Watchtower.Application.Tests/StackRevivalTests.cs @@ -232,6 +232,7 @@ internal sealed class TerminalDeployQueue(IServiceProvider services, string stat services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService>(), NullLogger.Instance) { private readonly List _enqueued = []; diff --git a/src/Watchtower.Application.Tests/TenancyTestDoubles.cs b/src/Watchtower.Application.Tests/TenancyTestDoubles.cs index 9134e6c..8b3ab91 100644 --- a/src/Watchtower.Application.Tests/TenancyTestDoubles.cs +++ b/src/Watchtower.Application.Tests/TenancyTestDoubles.cs @@ -30,9 +30,10 @@ public QueuedOnlyDeployQueueService( DockerEngineClient docker, DeployOutputBroadcaster broadcaster, CaddyManager caddy, + HostGpuProbe gpuProbe, IOptionsMonitor options, ILogger logger) - : base(scopeFactory, git, compose, docker, broadcaster, caddy, options, logger) => + : base(scopeFactory, git, compose, docker, broadcaster, caddy, gpuProbe, options, logger) => _scopeFactory = scopeFactory; /// Every enqueue this queue was asked for, in order. diff --git a/src/Watchtower.Application/Entities/StackDeviceMapping.cs b/src/Watchtower.Application/Entities/StackDeviceMapping.cs new file mode 100644 index 0000000..06ca098 --- /dev/null +++ b/src/Watchtower.Application/Entities/StackDeviceMapping.cs @@ -0,0 +1,29 @@ +namespace Watchtower.Application.Entities; + +/// +/// One host device mapped into one compose service of a stack (ADR-0030) — what the service's +/// devices: entry would say, stored in Watchtower instead of the repository because the value +/// is host-specific (which /dev/dri render node exists differs per machine, the compose file +/// is shared by every stack of the product). Applied on deploy through the ADR-0012 generated +/// override. Keyed by service name, so the row survives redeploys and applies to every replica. +/// +public sealed class StackDeviceMapping { + public int Id { get; set; } + public int StackId { get; set; } + public Stack? Stack { get; set; } + /// The compose service name (com.docker.compose.service). + public required string Service { get; set; } + /// Absolute device path on the host, e.g. /dev/dri/renderD128. + public required string HostPath { get; set; } + /// + /// Absolute device path inside the container. Stored resolved — the set handler defaults it to + /// — so "host and container disagree" is always readable off the row. + /// + public required string ContainerPath { get; set; } + /// + /// Cgroup permissions (some subset of rwm, e.g. "rw"), or null for Docker's + /// default (rwm). Null rather than a stored default so the rendered override only says + /// what the operator actually chose. + /// + public string? Permissions { get; set; } +} diff --git a/src/Watchtower.Application/Entities/StackGpuMapping.cs b/src/Watchtower.Application/Entities/StackGpuMapping.cs new file mode 100644 index 0000000..54854fa --- /dev/null +++ b/src/Watchtower.Application/Entities/StackGpuMapping.cs @@ -0,0 +1,16 @@ +namespace Watchtower.Application.Entities; + +/// +/// The "map host GPU(s)" intent for one compose service of a stack (ADR-0031). Deliberately +/// path-free: which render nodes exist is the deploying host's business, resolved by +/// at deploy time — which is what makes the same row valid on +/// every host, GPU or not. Sits beside 's literal paths and is +/// replaced by the same atomic stacks.setDevices call. +/// +public sealed class StackGpuMapping { + public int Id { get; set; } + public int StackId { get; set; } + public Stack? Stack { get; set; } + /// The compose service name (com.docker.compose.service). + public required string Service { get; set; } +} diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/GetHostGpus.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/GetHostGpus.cs new file mode 100644 index 0000000..373cfbd --- /dev/null +++ b/src/Watchtower.Application/Modules/Stacks/Handlers/GetHostGpus.cs @@ -0,0 +1,35 @@ +using Watchtower.Application.Services; + +namespace Watchtower.Application.Modules.Stacks.Handlers; + +/// +/// Reports the Docker host's GPU render nodes (ADR-0031) — what the Settings tab shows next to the +/// "map host GPU(s)" control, so an operator sees what the intent would resolve to on this host +/// before deploying. Served from 's short-lived cache; a probe failure is +/// data (), not an RPC error, because "we could not look" is a state the +/// UI must render rather than toast away. +/// +[Handler("stacks.hostGpus")] +public sealed class GetHostGpus(HostGpuProbe probe) + : IHandler> { + public sealed record Query; + /// The render nodes found; empty on a GPU-less host. + /// Why the probe could not run, or null when it did. + public sealed record Response(IReadOnlyList Gpus, string? Error); + + public async ValueTask> HandleAsync(Query query, CancellationToken ct) { + var catalog = await probe.GetAsync(ct); + return new Response( + [.. catalog.Gpus.Select(g => new HostGpuDto( + g.Name, g.Path, VendorLabel(g.VendorId), g.Driver, g.PciAddress, g.IsMappable))], + catalog.Error); + } + + /// The label the UI prints; the raw id stays server-side. + private static string VendorLabel(string vendorId) => vendorId switch { + HostGpu.IntelVendorId => "intel", + HostGpu.AmdVendorId => "amd", + HostGpu.NvidiaVendorId => "nvidia", + _ => "unknown", + }; +} diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/GetStackDevices.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/GetStackDevices.cs new file mode 100644 index 0000000..df42ed1 --- /dev/null +++ b/src/Watchtower.Application/Modules/Stacks/Handlers/GetStackDevices.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Watchtower.Application.Persistence; + +namespace Watchtower.Application.Modules.Stacks.Handlers; + +/// +/// Returns all host device mappings configured for a stack: the literal paths (ADR-0030) and the +/// services with the "map host GPU(s)" intent (ADR-0031). +/// +[Handler("stacks.getDevices")] +public sealed class GetStackDevices(WatchtowerDbContext db) + : IHandler> { + public sealed record Query(int StackId); + /// The literal path mappings. + /// Service names with the GPU-passthrough intent, ordered. + public sealed record Response( + IReadOnlyList Devices, IReadOnlyList GpuServices); + + public async ValueTask> HandleAsync(Query query, CancellationToken ct) { + if (!await db.Stacks.AnyAsync(s => s.Id == query.StackId, ct)) + return AppError.NotFound($"Stack {query.StackId} not found"); + + var devices = await db.StackDeviceMappings.AsNoTracking() + .Where(m => m.StackId == query.StackId) + .OrderBy(m => m.Service).ThenBy(m => m.HostPath) + .Select(m => new StackDeviceMappingDto(m.Id, m.Service, m.HostPath, m.ContainerPath, m.Permissions)) + .ToListAsync(ct); + var gpuServices = await db.StackGpuMappings.AsNoTracking() + .Where(m => m.StackId == query.StackId) + .OrderBy(m => m.Service) + .Select(m => m.Service) + .ToListAsync(ct); + return new Response(devices, gpuServices); + } +} diff --git a/src/Watchtower.Application/Modules/Stacks/Handlers/SetStackDevices.cs b/src/Watchtower.Application/Modules/Stacks/Handlers/SetStackDevices.cs new file mode 100644 index 0000000..22e95ee --- /dev/null +++ b/src/Watchtower.Application/Modules/Stacks/Handlers/SetStackDevices.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; +using Elarion.Abstractions.Identity; +using Microsoft.EntityFrameworkCore; +using Watchtower.Application.Entities; +using Watchtower.Application.Persistence; +using Watchtower.Application.Services; + +namespace Watchtower.Application.Modules.Stacks.Handlers; + +/// +/// Atomically replaces all host device mappings of a stack — the literal devices: entries the +/// deploy renders into the generated compose override (ADR-0030) and the per-service "map host +/// GPU(s)" intents that resolve against the host probe at deploy time (ADR-0031). Both are kept in +/// Watchtower because the values are host-specific and must not live in the product's repository. +/// Pass empty lists to clear them. A mapping may name a service the current compose file does not +/// contain — services come and go with the repository — so the deploy warns rather than this handler +/// refusing; validation here is limited to what can never be right. +/// +/// +/// Audited: mapping a host device into a container is an operator-level grant of host access, unlike +/// the plain configuration stacks.setEnv replaces silently. +/// +[Handler("stacks.setDevices")] +public sealed partial class SetStackDevices(WatchtowerDbContext db, AuditLog audit, ICurrentUser currentUser) + : IHandler> { + /// The literal path mappings; replaces the stored set. + /// Service names to receive the host's GPUs; replaces the stored set. Null reads as empty. + public sealed record Command( + int StackId, + IReadOnlyList Devices, + IReadOnlyList? GpuServices = null); + public sealed record Response( + IReadOnlyList Devices, IReadOnlyList GpuServices); + + public async ValueTask> HandleAsync(Command command, CancellationToken ct) { + var stack = await db.Stacks.FirstOrDefaultAsync(s => s.Id == command.StackId, ct); + if (stack is null) + return AppError.NotFound($"Stack {command.StackId} not found"); + + var mappings = new List(command.Devices.Count); + // Duplicate host paths per service are never meaningful; duplicate container paths would + // make Docker refuse the container at start — both are caught here, where the operator is. + var hostSeen = new HashSet<(string, string)>(); + var containerSeen = new HashSet<(string, string)>(); + foreach (var input in command.Devices) { + var service = input.Service.Trim(); + if (service.Length is 0 or > 128 || !ServiceNameRegex().IsMatch(service)) + return AppError.Validation( + $"'{input.Service}' is not a valid compose service name (letters, digits, '.', '_', '-'; at most 128 characters)."); + + if (ValidateDevicePath(input.HostPath, "host path") is { } hostError) return hostError; + var hostPath = input.HostPath.Trim(); + + var containerPath = string.IsNullOrWhiteSpace(input.ContainerPath) ? hostPath : input.ContainerPath.Trim(); + if (ValidateDevicePath(containerPath, "container path") is { } containerError) return containerError; + + var permissions = string.IsNullOrWhiteSpace(input.Permissions) + ? null : input.Permissions.Trim().ToLowerInvariant(); + if (permissions is not null && !PermissionsRegex().IsMatch(permissions)) + return AppError.Validation( + $"Invalid device permissions '{input.Permissions}' — expected a combination of 'r', 'w' and 'm' (e.g. \"rwm\")."); + + if (!hostSeen.Add((service, hostPath))) + return AppError.Validation($"Duplicate device: '{hostPath}' is mapped twice into service '{service}'."); + if (!containerSeen.Add((service, containerPath))) + return AppError.Validation( + $"Duplicate target: two devices of service '{service}' map to '{containerPath}' in the container."); + + mappings.Add(new StackDeviceMapping { + StackId = stack.Id, Service = service, + HostPath = hostPath, ContainerPath = containerPath, Permissions = permissions, + }); + } + + var gpuServices = new List(); + var gpuSeen = new HashSet(StringComparer.Ordinal); + foreach (var input in command.GpuServices ?? []) { + var service = input.Trim(); + if (service.Length is 0 or > 128 || !ServiceNameRegex().IsMatch(service)) + return AppError.Validation( + $"'{input}' is not a valid compose service name (letters, digits, '.', '_', '-'; at most 128 characters)."); + if (!gpuSeen.Add(service)) + return AppError.Validation($"Service '{service}' is listed twice for GPU passthrough."); + gpuServices.Add(service); + } + + await using var tx = await db.Database.BeginTransactionAsync(ct); + await db.StackDeviceMappings.Where(m => m.StackId == stack.Id).ExecuteDeleteAsync(ct); + db.StackDeviceMappings.AddRange(mappings); + await db.StackGpuMappings.Where(m => m.StackId == stack.Id).ExecuteDeleteAsync(ct); + db.StackGpuMappings.AddRange(gpuServices.Select(s => new StackGpuMapping { StackId = stack.Id, Service = s })); + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + await audit.RecordAsync(StackLifecycle.AuditCategory, "stack.devices.update", stack.Name, + mappings.Count == 0 && gpuServices.Count == 0 + ? "device mappings cleared" + : "device mappings replaced: " + + string.Join(", ", mappings.Select(m => + $"{m.Service} ← {m.HostPath}" + + (m.ContainerPath == m.HostPath ? "" : $" at {m.ContainerPath}") + + (m.Permissions is { } p ? $" ({p})" : "")) + .Concat(gpuServices.Select(s => $"{s} ← host GPUs"))), + actor: await audit.ActorAsync(currentUser, ct), ct: ct); + + var saved = await db.StackDeviceMappings.AsNoTracking() + .Where(m => m.StackId == stack.Id) + .OrderBy(m => m.Service).ThenBy(m => m.HostPath) + .Select(m => new StackDeviceMappingDto(m.Id, m.Service, m.HostPath, m.ContainerPath, m.Permissions)) + .ToListAsync(ct); + var savedGpus = await db.StackGpuMappings.AsNoTracking() + .Where(m => m.StackId == stack.Id) + .OrderBy(m => m.Service) + .Select(m => m.Service) + .ToListAsync(ct); + return new Response(saved, savedGpus); + } + + /// + /// Rejects what can never be a device path: relative (Docker requires absolute), containing the + /// ':' that delimits Compose's host:container:permissions string form, or a line + /// break. Existence on the host is deliberately not checked — the row may be written on a + /// machine other than the one that deploys, and the deploy is where absence surfaces. + /// + private static AppError? ValidateDevicePath(string? value, string what) { + var path = value?.Trim(); + if (string.IsNullOrEmpty(path)) return AppError.Validation($"A device {what} is required."); + if (!path.StartsWith('/')) return AppError.Validation($"The device {what} '{value}' must be absolute (start with '/')."); + if (path.Contains(':')) return AppError.Validation($"The device {what} '{value}' must not contain ':'."); + if (path.AsSpan().ContainsAny('\n', '\r')) return AppError.Validation($"The device {what} must not contain line breaks."); + if (path.Length > 512) return AppError.Validation($"The device {what} is too long (at most 512 characters)."); + return null; + } + + /// The compose-spec service name constraint, ^[a-zA-Z0-9._-]+$. + [GeneratedRegex("^[a-zA-Z0-9._-]+$")] + private static partial Regex ServiceNameRegex(); + + /// Some non-empty subset of rwm, each at most once. + [GeneratedRegex("^(?!.*(.).*\\1)[rwm]{1,3}$")] + private static partial Regex PermissionsRegex(); +} diff --git a/src/Watchtower.Application/Modules/Stacks/StacksContracts.cs b/src/Watchtower.Application/Modules/Stacks/StacksContracts.cs index 05e17b2..209d993 100644 --- a/src/Watchtower.Application/Modules/Stacks/StacksContracts.cs +++ b/src/Watchtower.Application/Modules/Stacks/StacksContracts.cs @@ -94,6 +94,31 @@ public sealed record StackEnvVarDto(int Id, string Key, string Value); /// One entry in a batch-replace request for stack environment variables. public sealed record StackEnvVarInput(string Key, string Value); +/// One host device mapped into a compose service of a stack (ADR-0030). +public sealed record StackDeviceMappingDto( + int Id, string Service, string HostPath, string ContainerPath, string? Permissions); + +/// One entry in a batch-replace request for stack device mappings. +/// The compose service name. +/// Absolute device path on the host. +/// Absolute device path in the container; null/blank defaults to . +/// Cgroup permissions (subset of rwm); null/blank for the Docker default. +public sealed record StackDeviceMappingInput( + string Service, string HostPath, string? ContainerPath = null, string? Permissions = null); + +/// One GPU render node the Docker host exposes, as the probe saw it (ADR-0031). +/// Node name, e.g. renderD128. +/// Host device path, e.g. /dev/dri/renderD128. +/// intel, amd, nvidia or unknown. +/// The bound kernel driver, e.g. i915. +/// PCI address, e.g. 0000:00:02.0. +/// +/// Whether "map host GPU(s)" would map this node — false for NVIDIA, which needs the container +/// toolkit rather than a device mapping. +/// +public sealed record HostGpuDto( + string Name, string Path, string Vendor, string Driver, string PciAddress, bool Mappable); + /// Returned immediately after a deploy is accepted. public sealed record DeployAcceptedDto(int DeployEventId, string Status); diff --git a/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs b/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs index a6200a5..76a915a 100644 --- a/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs +++ b/src/Watchtower.Application/Modules/Stacks/StacksJsonContext.cs @@ -32,6 +32,15 @@ namespace Watchtower.Application.Modules.Stacks; [JsonSerializable(typeof(StartStack.Response), TypeInfoPropertyName = "StartStackResponse")] [JsonSerializable(typeof(ListDeployEvents.Query), TypeInfoPropertyName = "ListDeployEventsQuery")] [JsonSerializable(typeof(ListDeployEvents.Response), TypeInfoPropertyName = "ListDeployEventsResponse")] +[JsonSerializable(typeof(StackDeviceMappingDto))] +[JsonSerializable(typeof(StackDeviceMappingInput))] +[JsonSerializable(typeof(HostGpuDto))] +[JsonSerializable(typeof(GetHostGpus.Query), TypeInfoPropertyName = "GetHostGpusQuery")] +[JsonSerializable(typeof(GetHostGpus.Response), TypeInfoPropertyName = "GetHostGpusResponse")] +[JsonSerializable(typeof(GetStackDevices.Query), TypeInfoPropertyName = "GetStackDevicesQuery")] +[JsonSerializable(typeof(GetStackDevices.Response), TypeInfoPropertyName = "GetStackDevicesResponse")] +[JsonSerializable(typeof(SetStackDevices.Command), TypeInfoPropertyName = "SetStackDevicesCommand")] +[JsonSerializable(typeof(SetStackDevices.Response), TypeInfoPropertyName = "SetStackDevicesResponse")] [JsonSerializable(typeof(GetStackEnv.Query), TypeInfoPropertyName = "GetStackEnvQuery")] [JsonSerializable(typeof(GetStackEnv.Response), TypeInfoPropertyName = "GetStackEnvResponse")] [JsonSerializable(typeof(SetStackEnv.Command), TypeInfoPropertyName = "SetStackEnvCommand")] diff --git a/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs b/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs index ef84649..6728941 100644 --- a/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs +++ b/src/Watchtower.Application/Persistence/Configurations/WatchtowerEntityConfigurations.cs @@ -432,6 +432,39 @@ public void Configure(EntityTypeBuilder b) { } } +[EntityConfiguration] +public sealed class StackDeviceMappingConfiguration : IEntityTypeConfiguration { + public void Configure(EntityTypeBuilder b) { + b.ToTable("stack_device_mappings"); + b.HasKey(x => x.Id); + b.Property(x => x.Service).IsRequired(); + b.Property(x => x.HostPath).IsRequired(); + b.Property(x => x.ContainerPath).IsRequired(); + // One row per (stack, service, host device): the set handler replaces the stack's whole set + // atomically, and the same host device mapped twice into one service is never meaningful. + b.HasIndex(x => new { x.StackId, x.Service, x.HostPath }).IsUnique(); + b.HasOne(x => x.Stack) + .WithMany() + .HasForeignKey(x => x.StackId) + .OnDelete(DeleteBehavior.Cascade); + } +} + +[EntityConfiguration] +public sealed class StackGpuMappingConfiguration : IEntityTypeConfiguration { + public void Configure(EntityTypeBuilder b) { + b.ToTable("stack_gpu_mappings"); + b.HasKey(x => x.Id); + b.Property(x => x.Service).IsRequired(); + // The row *is* the intent, so one per (stack, service) — a second would mean nothing. + b.HasIndex(x => new { x.StackId, x.Service }).IsUnique(); + b.HasOne(x => x.Stack) + .WithMany() + .HasForeignKey(x => x.StackId) + .OnDelete(DeleteBehavior.Cascade); + } +} + [EntityConfiguration] public sealed class StackEnvVarConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder b) { diff --git a/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.Designer.cs b/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.Designer.cs new file mode 100644 index 0000000..eaf19b6 --- /dev/null +++ b/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.Designer.cs @@ -0,0 +1,2465 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Watchtower.Application.Persistence; + +#nullable disable + +namespace Watchtower.Application.Persistence.Migrations +{ + [DbContext(typeof(WatchtowerDbContext))] + [Migration("20260828102057_AddStackDeviceMappings")] + partial class AddStackDeviceMappings + { + /// + 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("Elarion.Coordination.PostgreSql.RoleLeaseEntity", b => + { + b.Property("Role") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("role"); + + b.Property("Address") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("address"); + + b.Property("ExpiresOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on_utc"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner"); + + b.HasKey("Role") + .HasName("pk_elarion_role_leases"); + + b.ToTable("elarion_role_leases", (string)null); + }); + + modelBuilder.Entity("Elarion.Scheduling.EntityFrameworkCore.SchedulerClaimEntity", b => + { + b.Property("JobName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("job_name"); + + b.Property("OccurrenceUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurrence_utc"); + + b.Property("ClaimedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_at_utc"); + + b.HasKey("JobName", "OccurrenceUtc") + .HasName("pk_elarion_scheduler_claims"); + + b.HasIndex("OccurrenceUtc") + .HasDatabaseName("ix_elarion_scheduler_claims_purge"); + + b.ToTable("elarion_scheduler_claims", (string)null); + }); + + modelBuilder.Entity("Elarion.Settings.EntityFrameworkCore.Setting", b => + { + b.Property("Kind") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("kind"); + + b.Property("Owner") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner"); + + b.Property("Key") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("key"); + + b.Property("UpdatedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on_utc"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Kind", "Owner", "Key") + .HasName("pk_elarion_settings"); + + b.ToTable("elarion_settings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AcmeAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountUrl") + .HasColumnType("text") + .HasColumnName("account_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DirectoryUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("directory_url"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.HasKey("Id") + .HasName("pk_acme_accounts"); + + b.HasIndex("DirectoryUrl") + .IsUnique() + .HasDatabaseName("ix_acme_accounts_directory_url"); + + b.ToTable("acme_accounts", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AcmeHttpChallenge", b => + { + b.Property("Token") + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Host") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("KeyAuthorization") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_authorization"); + + b.HasKey("Token") + .HasName("pk_acme_http_challenges"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_acme_http_challenges_expires_at"); + + b.ToTable("acme_http_challenges", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Detail") + .HasColumnType("text") + .HasColumnName("detail"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("Category") + .HasDatabaseName("ix_audit_events_category"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_events_created_at"); + + b.ToTable("audit_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuthSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_auth_sessions_expires_at"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_auth_sessions_route_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_sessions_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_sessions_user_id"); + + b.ToTable("auth_sessions", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("RemotePath") + .HasColumnType("text") + .HasColumnName("remote_path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TriggeredBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_backup_events"); + + b.HasIndex("Status") + .HasDatabaseName("ix_backup_events_status"); + + b.HasIndex("StackId", "StartedAt") + .HasDatabaseName("ix_backup_events_stack_id_started_at"); + + b.ToTable("backup_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupPausedContainer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_id"); + + b.Property("ContainerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_name"); + + b.Property("PausedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_at"); + + b.Property("StackName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("stack_name"); + + b.HasKey("Id") + .HasName("pk_backup_paused_containers"); + + b.HasIndex("ContainerId") + .HasDatabaseName("ix_backup_paused_containers_container_id"); + + b.ToTable("backup_paused_containers", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.CiRepo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowDockerSocket") + .HasColumnType("boolean") + .HasColumnName("allow_docker_socket"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExtraLabels") + .HasColumnType("text") + .HasColumnName("extra_labels"); + + b.Property("LastRegistrySyncError") + .HasColumnType("text") + .HasColumnName("last_registry_sync_error"); + + b.Property("LastWarmError") + .HasColumnType("text") + .HasColumnName("last_warm_error"); + + b.Property("LastWarmedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_warmed_at"); + + b.Property("MaxConcurrentRunners") + .HasColumnType("integer") + .HasColumnName("max_concurrent_runners"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RegistrySyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("registry_synced_at"); + + b.Property("RegistrySyncedHash") + .HasColumnType("text") + .HasColumnName("registry_synced_hash"); + + b.Property("RunnerImage") + .HasColumnType("text") + .HasColumnName("runner_image"); + + b.Property("SyncRegistryUrl") + .HasColumnType("text") + .HasColumnName("sync_registry_url"); + + b.Property("ToolchainDetectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("toolchain_detected_at"); + + b.Property("ToolchainProfileJson") + .HasColumnType("text") + .HasColumnName("toolchain_profile_json"); + + b.Property("WarmedProfileHash") + .HasColumnType("text") + .HasColumnName("warmed_profile_hash"); + + b.HasKey("Id") + .HasName("pk_ci_repos"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_ci_repos_credential_id"); + + b.HasIndex("Owner", "Name") + .IsUnique() + .HasDatabaseName("ix_ci_repos_owner_name"); + + b.ToTable("ci_repos", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_credentials"); + + b.HasIndex("Name") + .HasDatabaseName("ix_credentials_name"); + + b.ToTable("credentials", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.DeployEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("ReleaseId") + .HasColumnType("integer") + .HasColumnName("release_id"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TriggeredBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_deploy_events"); + + b.HasIndex("ReleaseId") + .HasDatabaseName("ix_deploy_events_release_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_deploy_events_status"); + + b.HasIndex("StackId", "StartedAt") + .HasDatabaseName("ix_deploy_events_stack_id_started_at"); + + b.ToTable("deploy_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_name"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_groups"); + + b.HasIndex("RealmId", "NormalizedName") + .IsUnique() + .HasDatabaseName("ix_groups_realm_id_normalized_name"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("integer") + .HasColumnName("group_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_group_members"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_group_members_user_id"); + + b.HasIndex("GroupId", "UserId") + .IsUnique() + .HasDatabaseName("ix_group_members_group_id_user_id"); + + b.ToTable("group_members", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.LoginCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RedirectUri") + .IsRequired() + .HasColumnType("text") + .HasColumnName("redirect_uri"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_login_codes"); + + b.HasIndex("CodeHash") + .IsUnique() + .HasDatabaseName("ix_login_codes_code_hash"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_login_codes_expires_at"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_login_codes_route_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_login_codes_user_id"); + + b.ToTable("login_codes", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.MetricContainerSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_name"); + + b.Property("CpuPercent") + .HasColumnType("double precision") + .HasColumnName("cpu_percent"); + + b.Property("MemLimitBytes") + .HasColumnType("bigint") + .HasColumnName("mem_limit_bytes"); + + b.Property("MemUsedBytes") + .HasColumnType("bigint") + .HasColumnName("mem_used_bytes"); + + b.Property("StackName") + .HasColumnType("text") + .HasColumnName("stack_name"); + + b.Property("TUnixSeconds") + .HasColumnType("bigint") + .HasColumnName("t_unix_seconds"); + + b.Property("TierSeconds") + .HasColumnType("integer") + .HasColumnName("tier_seconds"); + + b.HasKey("Id") + .HasName("pk_metric_container_samples"); + + b.HasIndex("TierSeconds", "TUnixSeconds", "ContainerName") + .IsUnique() + .HasDatabaseName("ix_metric_container_samples_tier_seconds_t_unix_seconds_contai"); + + b.ToTable("metric_container_samples", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.MetricHostSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CpuPercent") + .HasColumnType("double precision") + .HasColumnName("cpu_percent"); + + b.Property("LoadAvg1") + .HasColumnType("double precision") + .HasColumnName("load_avg1"); + + b.Property("LoadAvg5") + .HasColumnType("double precision") + .HasColumnName("load_avg5"); + + b.Property("MemPercent") + .HasColumnType("double precision") + .HasColumnName("mem_percent"); + + b.Property("MemUsedBytes") + .HasColumnType("bigint") + .HasColumnName("mem_used_bytes"); + + b.Property("TUnixSeconds") + .HasColumnType("bigint") + .HasColumnName("t_unix_seconds"); + + b.Property("TierSeconds") + .HasColumnType("integer") + .HasColumnName("tier_seconds"); + + b.HasKey("Id") + .HasName("pk_metric_host_samples"); + + b.HasIndex("TierSeconds", "TUnixSeconds") + .IsUnique() + .HasDatabaseName("ix_metric_host_samples_tier_seconds_t_unix_seconds"); + + b.ToTable("metric_host_samples", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionsSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("actions_synced_at"); + + b.Property("ActionsSyncedHash") + .HasColumnType("text") + .HasColumnName("actions_synced_hash"); + + b.Property("CiRepoId") + .HasColumnType("integer") + .HasColumnName("ci_repo_id"); + + b.Property("ComposeFilePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("compose_file_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("DefaultBranch") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_branch"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("LastActionsSyncError") + .HasColumnType("text") + .HasColumnName("last_actions_sync_error"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ReleaseMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Git") + .HasColumnName("release_mode"); + + b.Property("ReleaseWebhookEnabled") + .HasColumnType("boolean") + .HasColumnName("release_webhook_enabled"); + + b.Property("ReleaseWebhookToken") + .HasColumnType("text") + .HasColumnName("release_webhook_token"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_url"); + + b.Property("RetainReleases") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(50) + .HasColumnName("retain_releases"); + + b.Property("SyncReleaseSecrets") + .HasColumnType("boolean") + .HasColumnName("sync_release_secrets"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("CiRepoId") + .HasDatabaseName("ix_products_ci_repo_id"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_products_credential_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_products_name"); + + b.HasIndex("ReleaseWebhookToken") + .IsUnique() + .HasDatabaseName("ix_products_release_webhook_token"); + + b.HasIndex(new[] { "CiRepoId" }, "ix_products_ci_repo_id_sync_release_secrets") + .IsUnique() + .HasDatabaseName("ix_products_ci_repo_id_sync_release_secrets") + .HasFilter("\"sync_release_secrets\""); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ProxyCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CertificatePem") + .IsRequired() + .HasColumnType("text") + .HasColumnName("certificate_pem"); + + b.Property("Host") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("installed_at"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("text") + .HasColumnName("issuer"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("not_after"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone") + .HasColumnName("not_before"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Thumbprint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbprint"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_proxy_certificates"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ix_proxy_certificates_host"); + + b.ToTable("proxy_certificates", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("LoginRouteId") + .HasColumnType("integer") + .HasColumnName("login_route_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_realms"); + + b.HasIndex("LoginRouteId") + .IsUnique() + .HasDatabaseName("ix_realms_login_route_id") + .HasFilter("\"login_route_id\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_realms_slug"); + + b.ToTable("realms", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsSystem = true, + Name = "Operator", + Slug = "operator", + Xmin = 0u + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Registry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_registries"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_registries_credential_id"); + + b.HasIndex("Name") + .HasDatabaseName("ix_registries_name"); + + b.ToTable("registries", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Branch") + .IsRequired() + .HasColumnType("text") + .HasColumnName("branch"); + + b.Property("CommitSha") + .HasColumnType("text") + .HasColumnName("commit_sha"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedVia") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_via"); + + b.Property("Fingerprint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fingerprint"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("SourceRunUrl") + .HasColumnType("text") + .HasColumnName("source_run_url"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_releases"); + + b.HasIndex("ProductId", "Fingerprint") + .IsUnique() + .HasDatabaseName("ix_releases_product_id_fingerprint"); + + b.HasIndex("ProductId", "Id") + .HasDatabaseName("ix_releases_product_id_id"); + + b.HasIndex("ProductId", "Version") + .IsUnique() + .HasDatabaseName("ix_releases_product_id_version"); + + b.ToTable("releases", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ReleaseImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Digest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("digest"); + + b.Property("ReleaseId") + .HasColumnType("integer") + .HasColumnName("release_id"); + + b.Property("Repository") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.HasKey("Id") + .HasName("pk_release_images"); + + b.HasIndex("ReleaseId", "Repository") + .IsUnique() + .HasDatabaseName("ix_release_images_release_id_repository"); + + b.ToTable("release_images", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public") + .HasColumnName("access_mode"); + + b.Property("BypassPaths") + .HasColumnType("text") + .HasColumnName("bypass_paths"); + + b.Property("CertNotAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("cert_not_after"); + + b.Property("ContainerPort") + .HasColumnType("integer") + .HasColumnName("container_port"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("IdentityHeaderMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("None") + .HasColumnName("identity_header_mode"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service_name"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StatusDetail") + .HasColumnType("text") + .HasColumnName("status_detail"); + + b.Property("Target") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Service") + .HasColumnName("target"); + + b.Property("TlsEnabled") + .HasColumnType("boolean") + .HasColumnName("tls_enabled"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_routes"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_routes_domain"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_routes_realm_id"); + + b.HasIndex("StackId") + .HasDatabaseName("ix_routes_stack_id"); + + b.ToTable("routes", null, t => + { + t.HasCheckConstraint("ck_routes_target", "(\"target\" = 'Watchtower' AND \"stack_id\" IS NULL AND \"realm_id\" IS NOT NULL AND \"access_mode\" = 'Public')\nOR (\"target\" = 'Service' AND \"stack_id\" IS NOT NULL AND \"realm_id\" IS NULL)"); + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.RouteAccessGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("integer") + .HasColumnName("group_id"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_route_access_grants"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_route_access_grants_group_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_route_access_grants_user_id"); + + b.HasIndex("RouteId", "GroupId") + .IsUnique() + .HasDatabaseName("ix_route_access_grants_route_id_group_id") + .HasFilter("\"group_id\" IS NOT NULL"); + + b.HasIndex("RouteId", "UserId") + .IsUnique() + .HasDatabaseName("ix_route_access_grants_route_id_user_id") + .HasFilter("\"user_id\" IS NOT NULL"); + + b.ToTable("route_access_grants", null, t => + { + t.HasCheckConstraint("ck_route_access_grants_subject", "(\"user_id\" IS NOT NULL) <> (\"group_id\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.SigningKey", b => + { + b.Property("Purpose") + .HasColumnType("text") + .HasColumnName("purpose"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_id"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.HasKey("Purpose") + .HasName("pk_signing_keys"); + + b.ToTable("signing_keys", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppApiEnabled") + .HasColumnType("boolean") + .HasColumnName("app_api_enabled"); + + b.Property("AppApiToken") + .HasColumnType("text") + .HasColumnName("app_api_token"); + + b.Property("AutoDeployMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auto_deploy_mode"); + + b.Property("AutoDeployTime") + .HasColumnType("text") + .HasColumnName("auto_deploy_time"); + + b.Property("BackupCron") + .HasColumnType("text") + .HasColumnName("backup_cron"); + + b.Property("BackupDirectory") + .HasColumnType("text") + .HasColumnName("backup_directory"); + + b.Property("BackupEnabled") + .HasColumnType("boolean") + .HasColumnName("backup_enabled"); + + b.Property("BackupQuiesceMode") + .HasColumnType("text") + .HasColumnName("backup_quiesce_mode"); + + b.Property("BackupStopContainers") + .HasColumnType("boolean") + .HasColumnName("backup_stop_containers"); + + b.Property("BranchOverride") + .HasColumnType("text") + .HasColumnName("branch_override"); + + b.Property("ComposeProjectName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("compose_project_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DesiredState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Running") + .HasColumnName("desired_state"); + + b.Property("LastDeployStatus") + .HasColumnType("text") + .HasColumnName("last_deploy_status"); + + b.Property("LastDeployedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_deployed_at"); + + b.Property("LastDeployedCommit") + .HasColumnType("text") + .HasColumnName("last_deployed_commit"); + + b.Property("LastDeployedReleaseId") + .HasColumnType("integer") + .HasColumnName("last_deployed_release_id"); + + b.Property("LastScheduledBackupAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_scheduled_backup_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("PinnedReleaseId") + .HasColumnType("integer") + .HasColumnName("pinned_release_id"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.Property("TenantSlug") + .HasColumnType("text") + .HasColumnName("tenant_slug"); + + b.Property("WebhookEnabled") + .HasColumnType("boolean") + .HasColumnName("webhook_enabled"); + + b.Property("WebhookToken") + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_stacks"); + + b.HasIndex("AppApiToken") + .IsUnique() + .HasDatabaseName("ix_stacks_app_api_token"); + + b.HasIndex("LastDeployedReleaseId") + .HasDatabaseName("ix_stacks_last_deployed_release_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_stacks_name"); + + b.HasIndex("PinnedReleaseId") + .HasDatabaseName("ix_stacks_pinned_release_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_stacks_product_id"); + + b.HasIndex("TemplateId", "TenantSlug") + .IsUnique() + .HasDatabaseName("ix_stacks_template_id_tenant_slug"); + + b.ToTable("stacks", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackBackupServiceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Dump") + .HasColumnType("text") + .HasColumnName("dump"); + + b.Property("Exclude") + .HasColumnType("boolean") + .HasColumnName("exclude"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Stop") + .HasColumnType("text") + .HasColumnName("stop"); + + b.HasKey("Id") + .HasName("pk_stack_backup_service_overrides"); + + b.HasIndex("StackId", "Service") + .IsUnique() + .HasDatabaseName("ix_stack_backup_service_overrides_stack_id_service"); + + b.ToTable("stack_backup_service_overrides", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_path"); + + b.Property("HostPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host_path"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.HasKey("Id") + .HasName("pk_stack_device_mappings"); + + b.HasIndex("StackId", "Service", "HostPath") + .IsUnique() + .HasDatabaseName("ix_stack_device_mappings_stack_id_service_host_path"); + + b.ToTable("stack_device_mappings", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_stack_env_vars"); + + b.HasIndex("StackId", "Key") + .IsUnique() + .HasDatabaseName("ix_stack_env_vars_stack_id_key"); + + b.ToTable("stack_env_vars", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BackupCron") + .HasColumnType("text") + .HasColumnName("backup_cron"); + + b.Property("BackupEnabled") + .HasColumnType("boolean") + .HasColumnName("backup_enabled"); + + b.Property("BackupQuiesceMode") + .HasColumnType("text") + .HasColumnName("backup_quiesce_mode"); + + b.Property("BackupStopContainers") + .HasColumnType("boolean") + .HasColumnName("backup_stop_containers"); + + b.Property("BranchOverride") + .HasColumnType("text") + .HasColumnName("branch_override"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPinnedReleaseId") + .HasColumnType("integer") + .HasColumnName("default_pinned_release_id"); + + b.Property("DomainPattern") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain_pattern"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("TargetServiceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_service_name"); + + b.HasKey("Id") + .HasName("pk_stack_templates"); + + b.HasIndex("DefaultPinnedReleaseId") + .HasDatabaseName("ix_stack_templates_default_pinned_release_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_stack_templates_name"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_stack_templates_product_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_stack_templates_realm_id"); + + b.ToTable("stack_templates", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplateEnvVar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_stack_template_env_vars"); + + b.HasIndex("TemplateId", "Key") + .IsUnique() + .HasDatabaseName("ix_stack_template_env_vars_template_id_key"); + + b.ToTable("stack_template_env_vars", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackUpdateCheck", b => + { + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("AvailableReleaseId") + .HasColumnType("integer") + .HasColumnName("available_release_id"); + + b.Property("AvailableReleaseVersion") + .HasColumnType("text") + .HasColumnName("available_release_version"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("DriftedContainers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("drifted_containers"); + + b.Property("HasUpdates") + .HasColumnType("boolean") + .HasColumnName("has_updates"); + + b.Property("NewCommitSha") + .HasColumnType("text") + .HasColumnName("new_commit_sha"); + + b.Property("OutdatedImageDigests") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outdated_image_digests"); + + b.Property("OutdatedImages") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outdated_images"); + + b.HasKey("StackId") + .HasName("pk_stack_update_checks"); + + b.ToTable("stack_update_checks", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateBackupServiceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Dump") + .HasColumnType("text") + .HasColumnName("dump"); + + b.Property("Exclude") + .HasColumnType("boolean") + .HasColumnName("exclude"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("Stop") + .HasColumnType("text") + .HasColumnName("stop"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_template_backup_service_overrides"); + + b.HasIndex("TemplateId", "Service") + .IsUnique() + .HasDatabaseName("ix_template_backup_service_overrides_template_id_service"); + + b.ToTable("template_backup_service_overrides", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateManagementGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_template_management_grants"); + + b.HasIndex("TemplateId") + .HasDatabaseName("ix_template_management_grants_template_id"); + + b.HasIndex("StackId", "TemplateId") + .IsUnique() + .HasDatabaseName("ix_template_management_grants_stack_id_template_id"); + + b.ToTable("template_management_grants", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("AuthenticatorKey") + .HasColumnType("text") + .HasColumnName("authenticator_key"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("RealmId", "NormalizedUserName") + .IsUnique() + .HasDatabaseName("ix_users_realm_id_normalized_user_name"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.UserRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_recovery_codes"); + + b.HasIndex("UserId", "CodeHash") + .IsUnique() + .HasDatabaseName("ix_user_recovery_codes_user_id_code_hash"); + + b.ToTable("user_recovery_codes", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuthSession", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_sessions_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_users_user_id"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupEvent", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_backup_events_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.CiRepo", b => + { + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_ci_repos_credentials_credential_id"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.DeployEvent", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "Release") + .WithMany() + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_deploy_events_releases_release_id"); + + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany("DeployEvents") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deploy_events_stacks_stack_id"); + + b.Navigation("Release"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Group", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_groups_realms_realm_id"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.GroupMember", b => + { + b.HasOne("Watchtower.Application.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_members_groups_group_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_members_users_user_id"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.LoginCode", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_login_codes_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_login_codes_users_user_id"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.HasOne("Watchtower.Application.Entities.CiRepo", "CiRepo") + .WithMany() + .HasForeignKey("CiRepoId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_products_ci_repos_ci_repo_id"); + + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_products_credentials_credential_id"); + + b.Navigation("CiRepo"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Realm", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "LoginRoute") + .WithMany() + .HasForeignKey("LoginRouteId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_realms_routes_login_route_id"); + + b.Navigation("LoginRoute"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Registry", b => + { + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_registries_credentials_credential_id"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Releases") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_releases_products_product_id"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ReleaseImage", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "Release") + .WithMany("Images") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_release_images_releases_release_id"); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Route", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_routes_realms_realm_id"); + + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_routes_stacks_stack_id"); + + b.Navigation("Realm"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.RouteAccessGrant", b => + { + b.HasOne("Watchtower.Application.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_route_access_grants_groups_group_id"); + + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_access_grants_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_route_access_grants_users_user_id"); + + b.Navigation("Group"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "LastDeployedRelease") + .WithMany() + .HasForeignKey("LastDeployedReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stacks_releases_last_deployed_release_id"); + + b.HasOne("Watchtower.Application.Entities.Release", "PinnedRelease") + .WithMany() + .HasForeignKey("PinnedReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_stacks_releases_pinned_release_id"); + + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Stacks") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stacks_products_product_id"); + + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("Instances") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stacks_stack_templates_template_id"); + + b.Navigation("LastDeployedRelease"); + + b.Navigation("PinnedRelease"); + + b.Navigation("Product"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackBackupServiceOverride", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_backup_service_overrides_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_device_mappings_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany("EnvVars") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_env_vars_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "DefaultPinnedRelease") + .WithMany() + .HasForeignKey("DefaultPinnedReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stack_templates_releases_default_pinned_release_id"); + + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Templates") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stack_templates_products_product_id"); + + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stack_templates_realms_realm_id"); + + b.Navigation("DefaultPinnedRelease"); + + b.Navigation("Product"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplateEnvVar", b => + { + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("BaseEnvVars") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_template_env_vars_stack_templates_template_id"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackUpdateCheck", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithOne("UpdateCheck") + .HasForeignKey("Watchtower.Application.Entities.StackUpdateCheck", "StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_update_checks_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateBackupServiceOverride", b => + { + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("BackupServiceOverrides") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_backup_service_overrides_stack_templates_template_"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateManagementGrant", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_management_grants_stacks_stack_id"); + + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_management_grants_stack_templates_template_id"); + + b.Navigation("Stack"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.User", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_users_realms_realm_id"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.UserRecoveryCode", b => + { + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_recovery_codes_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.Navigation("Releases"); + + b.Navigation("Stacks"); + + b.Navigation("Templates"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.Navigation("DeployEvents"); + + b.Navigation("EnvVars"); + + b.Navigation("UpdateCheck"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.Navigation("BackupServiceOverrides"); + + b.Navigation("BaseEnvVars"); + + b.Navigation("Instances"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.cs b/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.cs new file mode 100644 index 0000000..514696f --- /dev/null +++ b/src/Watchtower.Application/Persistence/Migrations/20260828102057_AddStackDeviceMappings.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Watchtower.Application.Persistence.Migrations +{ + /// + public partial class AddStackDeviceMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "stack_device_mappings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + stack_id = table.Column(type: "integer", nullable: false), + service = table.Column(type: "text", nullable: false), + host_path = table.Column(type: "text", nullable: false), + container_path = table.Column(type: "text", nullable: false), + permissions = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_stack_device_mappings", x => x.id); + table.ForeignKey( + name: "fk_stack_device_mappings_stacks_stack_id", + column: x => x.stack_id, + principalTable: "stacks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_stack_device_mappings_stack_id_service_host_path", + table: "stack_device_mappings", + columns: new[] { "stack_id", "service", "host_path" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stack_device_mappings"); + } + } +} diff --git a/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.Designer.cs b/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.Designer.cs new file mode 100644 index 0000000..2738d40 --- /dev/null +++ b/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.Designer.cs @@ -0,0 +1,2505 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Watchtower.Application.Persistence; + +#nullable disable + +namespace Watchtower.Application.Persistence.Migrations +{ + [DbContext(typeof(WatchtowerDbContext))] + [Migration("20260828113059_AddStackGpuMappings")] + partial class AddStackGpuMappings + { + /// + 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("Elarion.Coordination.PostgreSql.RoleLeaseEntity", b => + { + b.Property("Role") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("role"); + + b.Property("Address") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("address"); + + b.Property("ExpiresOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_on_utc"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner"); + + b.HasKey("Role") + .HasName("pk_elarion_role_leases"); + + b.ToTable("elarion_role_leases", (string)null); + }); + + modelBuilder.Entity("Elarion.Scheduling.EntityFrameworkCore.SchedulerClaimEntity", b => + { + b.Property("JobName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("job_name"); + + b.Property("OccurrenceUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurrence_utc"); + + b.Property("ClaimedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_at_utc"); + + b.HasKey("JobName", "OccurrenceUtc") + .HasName("pk_elarion_scheduler_claims"); + + b.HasIndex("OccurrenceUtc") + .HasDatabaseName("ix_elarion_scheduler_claims_purge"); + + b.ToTable("elarion_scheduler_claims", (string)null); + }); + + modelBuilder.Entity("Elarion.Settings.EntityFrameworkCore.Setting", b => + { + b.Property("Kind") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("kind"); + + b.Property("Owner") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("owner"); + + b.Property("Key") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("key"); + + b.Property("UpdatedOnUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on_utc"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Kind", "Owner", "Key") + .HasName("pk_elarion_settings"); + + b.ToTable("elarion_settings", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AcmeAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountUrl") + .HasColumnType("text") + .HasColumnName("account_url"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DirectoryUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("directory_url"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.HasKey("Id") + .HasName("pk_acme_accounts"); + + b.HasIndex("DirectoryUrl") + .IsUnique() + .HasDatabaseName("ix_acme_accounts_directory_url"); + + b.ToTable("acme_accounts", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AcmeHttpChallenge", b => + { + b.Property("Token") + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Host") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("KeyAuthorization") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_authorization"); + + b.HasKey("Token") + .HasName("pk_acme_http_challenges"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_acme_http_challenges_expires_at"); + + b.ToTable("acme_http_challenges", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Detail") + .HasColumnType("text") + .HasColumnName("detail"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target"); + + b.HasKey("Id") + .HasName("pk_audit_events"); + + b.HasIndex("Category") + .HasDatabaseName("ix_audit_events_category"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_audit_events_created_at"); + + b.ToTable("audit_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuthSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_auth_sessions_expires_at"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_auth_sessions_route_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_sessions_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_sessions_user_id"); + + b.ToTable("auth_sessions", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("RemotePath") + .HasColumnType("text") + .HasColumnName("remote_path"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TriggeredBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_backup_events"); + + b.HasIndex("Status") + .HasDatabaseName("ix_backup_events_status"); + + b.HasIndex("StackId", "StartedAt") + .HasDatabaseName("ix_backup_events_stack_id_started_at"); + + b.ToTable("backup_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupPausedContainer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_id"); + + b.Property("ContainerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_name"); + + b.Property("PausedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paused_at"); + + b.Property("StackName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("stack_name"); + + b.HasKey("Id") + .HasName("pk_backup_paused_containers"); + + b.HasIndex("ContainerId") + .HasDatabaseName("ix_backup_paused_containers_container_id"); + + b.ToTable("backup_paused_containers", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.CiRepo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowDockerSocket") + .HasColumnType("boolean") + .HasColumnName("allow_docker_socket"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExtraLabels") + .HasColumnType("text") + .HasColumnName("extra_labels"); + + b.Property("LastRegistrySyncError") + .HasColumnType("text") + .HasColumnName("last_registry_sync_error"); + + b.Property("LastWarmError") + .HasColumnType("text") + .HasColumnName("last_warm_error"); + + b.Property("LastWarmedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_warmed_at"); + + b.Property("MaxConcurrentRunners") + .HasColumnType("integer") + .HasColumnName("max_concurrent_runners"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RegistrySyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("registry_synced_at"); + + b.Property("RegistrySyncedHash") + .HasColumnType("text") + .HasColumnName("registry_synced_hash"); + + b.Property("RunnerImage") + .HasColumnType("text") + .HasColumnName("runner_image"); + + b.Property("SyncRegistryUrl") + .HasColumnType("text") + .HasColumnName("sync_registry_url"); + + b.Property("ToolchainDetectedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("toolchain_detected_at"); + + b.Property("ToolchainProfileJson") + .HasColumnType("text") + .HasColumnName("toolchain_profile_json"); + + b.Property("WarmedProfileHash") + .HasColumnType("text") + .HasColumnName("warmed_profile_hash"); + + b.HasKey("Id") + .HasName("pk_ci_repos"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_ci_repos_credential_id"); + + b.HasIndex("Owner", "Name") + .IsUnique() + .HasDatabaseName("ix_ci_repos_owner_name"); + + b.ToTable("ci_repos", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Credential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_credentials"); + + b.HasIndex("Name") + .HasDatabaseName("ix_credentials_name"); + + b.ToTable("credentials", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.DeployEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("ReleaseId") + .HasColumnType("integer") + .HasColumnName("release_id"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("TriggeredBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_deploy_events"); + + b.HasIndex("ReleaseId") + .HasDatabaseName("ix_deploy_events_release_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_deploy_events_status"); + + b.HasIndex("StackId", "StartedAt") + .HasDatabaseName("ix_deploy_events_stack_id_started_at"); + + b.ToTable("deploy_events", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_name"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_groups"); + + b.HasIndex("RealmId", "NormalizedName") + .IsUnique() + .HasDatabaseName("ix_groups_realm_id_normalized_name"); + + b.ToTable("groups", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("integer") + .HasColumnName("group_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_group_members"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_group_members_user_id"); + + b.HasIndex("GroupId", "UserId") + .IsUnique() + .HasDatabaseName("ix_group_members_group_id_user_id"); + + b.ToTable("group_members", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.LoginCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RedirectUri") + .IsRequired() + .HasColumnType("text") + .HasColumnName("redirect_uri"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_login_codes"); + + b.HasIndex("CodeHash") + .IsUnique() + .HasDatabaseName("ix_login_codes_code_hash"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_login_codes_expires_at"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_login_codes_route_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_login_codes_user_id"); + + b.ToTable("login_codes", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.MetricContainerSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_name"); + + b.Property("CpuPercent") + .HasColumnType("double precision") + .HasColumnName("cpu_percent"); + + b.Property("MemLimitBytes") + .HasColumnType("bigint") + .HasColumnName("mem_limit_bytes"); + + b.Property("MemUsedBytes") + .HasColumnType("bigint") + .HasColumnName("mem_used_bytes"); + + b.Property("StackName") + .HasColumnType("text") + .HasColumnName("stack_name"); + + b.Property("TUnixSeconds") + .HasColumnType("bigint") + .HasColumnName("t_unix_seconds"); + + b.Property("TierSeconds") + .HasColumnType("integer") + .HasColumnName("tier_seconds"); + + b.HasKey("Id") + .HasName("pk_metric_container_samples"); + + b.HasIndex("TierSeconds", "TUnixSeconds", "ContainerName") + .IsUnique() + .HasDatabaseName("ix_metric_container_samples_tier_seconds_t_unix_seconds_contai"); + + b.ToTable("metric_container_samples", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.MetricHostSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CpuPercent") + .HasColumnType("double precision") + .HasColumnName("cpu_percent"); + + b.Property("LoadAvg1") + .HasColumnType("double precision") + .HasColumnName("load_avg1"); + + b.Property("LoadAvg5") + .HasColumnType("double precision") + .HasColumnName("load_avg5"); + + b.Property("MemPercent") + .HasColumnType("double precision") + .HasColumnName("mem_percent"); + + b.Property("MemUsedBytes") + .HasColumnType("bigint") + .HasColumnName("mem_used_bytes"); + + b.Property("TUnixSeconds") + .HasColumnType("bigint") + .HasColumnName("t_unix_seconds"); + + b.Property("TierSeconds") + .HasColumnType("integer") + .HasColumnName("tier_seconds"); + + b.HasKey("Id") + .HasName("pk_metric_host_samples"); + + b.HasIndex("TierSeconds", "TUnixSeconds") + .IsUnique() + .HasDatabaseName("ix_metric_host_samples_tier_seconds_t_unix_seconds"); + + b.ToTable("metric_host_samples", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionsSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("actions_synced_at"); + + b.Property("ActionsSyncedHash") + .HasColumnType("text") + .HasColumnName("actions_synced_hash"); + + b.Property("CiRepoId") + .HasColumnType("integer") + .HasColumnName("ci_repo_id"); + + b.Property("ComposeFilePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("compose_file_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("DefaultBranch") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_branch"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("LastActionsSyncError") + .HasColumnType("text") + .HasColumnName("last_actions_sync_error"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ReleaseMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Git") + .HasColumnName("release_mode"); + + b.Property("ReleaseWebhookEnabled") + .HasColumnType("boolean") + .HasColumnName("release_webhook_enabled"); + + b.Property("ReleaseWebhookToken") + .HasColumnType("text") + .HasColumnName("release_webhook_token"); + + b.Property("RepositoryUrl") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_url"); + + b.Property("RetainReleases") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(50) + .HasColumnName("retain_releases"); + + b.Property("SyncReleaseSecrets") + .HasColumnType("boolean") + .HasColumnName("sync_release_secrets"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("CiRepoId") + .HasDatabaseName("ix_products_ci_repo_id"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_products_credential_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_products_name"); + + b.HasIndex("ReleaseWebhookToken") + .IsUnique() + .HasDatabaseName("ix_products_release_webhook_token"); + + b.HasIndex(new[] { "CiRepoId" }, "ix_products_ci_repo_id_sync_release_secrets") + .IsUnique() + .HasDatabaseName("ix_products_ci_repo_id_sync_release_secrets") + .HasFilter("\"sync_release_secrets\""); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ProxyCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CertificatePem") + .IsRequired() + .HasColumnType("text") + .HasColumnName("certificate_pem"); + + b.Property("Host") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("installed_at"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("text") + .HasColumnName("issuer"); + + b.Property("NotAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("not_after"); + + b.Property("NotBefore") + .HasColumnType("timestamp with time zone") + .HasColumnName("not_before"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Thumbprint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("thumbprint"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_proxy_certificates"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ix_proxy_certificates_host"); + + b.ToTable("proxy_certificates", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Realm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("LoginRouteId") + .HasColumnType("integer") + .HasColumnName("login_route_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_realms"); + + b.HasIndex("LoginRouteId") + .IsUnique() + .HasDatabaseName("ix_realms_login_route_id") + .HasFilter("\"login_route_id\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_realms_slug"); + + b.ToTable("realms", (string)null); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTimeOffset(new DateTime(2026, 8, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsSystem = true, + Name = "Operator", + Slug = "operator", + Xmin = 0u + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Registry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialId") + .HasColumnType("integer") + .HasColumnName("credential_id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_registries"); + + b.HasIndex("CredentialId") + .HasDatabaseName("ix_registries_credential_id"); + + b.HasIndex("Name") + .HasDatabaseName("ix_registries_name"); + + b.ToTable("registries", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Branch") + .IsRequired() + .HasColumnType("text") + .HasColumnName("branch"); + + b.Property("CommitSha") + .HasColumnType("text") + .HasColumnName("commit_sha"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedVia") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_via"); + + b.Property("Fingerprint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("fingerprint"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("SourceRunUrl") + .HasColumnType("text") + .HasColumnName("source_run_url"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_releases"); + + b.HasIndex("ProductId", "Fingerprint") + .IsUnique() + .HasDatabaseName("ix_releases_product_id_fingerprint"); + + b.HasIndex("ProductId", "Id") + .HasDatabaseName("ix_releases_product_id_id"); + + b.HasIndex("ProductId", "Version") + .IsUnique() + .HasDatabaseName("ix_releases_product_id_version"); + + b.ToTable("releases", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ReleaseImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Digest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("digest"); + + b.Property("ReleaseId") + .HasColumnType("integer") + .HasColumnName("release_id"); + + b.Property("Repository") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.HasKey("Id") + .HasName("pk_release_images"); + + b.HasIndex("ReleaseId", "Repository") + .IsUnique() + .HasDatabaseName("ix_release_images_release_id_repository"); + + b.ToTable("release_images", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public") + .HasColumnName("access_mode"); + + b.Property("BypassPaths") + .HasColumnType("text") + .HasColumnName("bypass_paths"); + + b.Property("CertNotAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("cert_not_after"); + + b.Property("ContainerPort") + .HasColumnType("integer") + .HasColumnName("container_port"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("IdentityHeaderMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("None") + .HasColumnName("identity_header_mode"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service_name"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StatusDetail") + .HasColumnType("text") + .HasColumnName("status_detail"); + + b.Property("Target") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Service") + .HasColumnName("target"); + + b.Property("TlsEnabled") + .HasColumnType("boolean") + .HasColumnName("tls_enabled"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_routes"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_routes_domain"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_routes_realm_id"); + + b.HasIndex("StackId") + .HasDatabaseName("ix_routes_stack_id"); + + b.ToTable("routes", null, t => + { + t.HasCheckConstraint("ck_routes_target", "(\"target\" = 'Watchtower' AND \"stack_id\" IS NULL AND \"realm_id\" IS NOT NULL AND \"access_mode\" = 'Public')\nOR (\"target\" = 'Service' AND \"stack_id\" IS NOT NULL AND \"realm_id\" IS NULL)"); + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.RouteAccessGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("integer") + .HasColumnName("group_id"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_route_access_grants"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_route_access_grants_group_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_route_access_grants_user_id"); + + b.HasIndex("RouteId", "GroupId") + .IsUnique() + .HasDatabaseName("ix_route_access_grants_route_id_group_id") + .HasFilter("\"group_id\" IS NOT NULL"); + + b.HasIndex("RouteId", "UserId") + .IsUnique() + .HasDatabaseName("ix_route_access_grants_route_id_user_id") + .HasFilter("\"user_id\" IS NOT NULL"); + + b.ToTable("route_access_grants", null, t => + { + t.HasCheckConstraint("ck_route_access_grants_subject", "(\"user_id\" IS NOT NULL) <> (\"group_id\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.SigningKey", b => + { + b.Property("Purpose") + .HasColumnType("text") + .HasColumnName("purpose"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("KeyId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key_id"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("private_key"); + + b.Property("Protection") + .IsRequired() + .HasColumnType("text") + .HasColumnName("protection"); + + b.HasKey("Purpose") + .HasName("pk_signing_keys"); + + b.ToTable("signing_keys", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppApiEnabled") + .HasColumnType("boolean") + .HasColumnName("app_api_enabled"); + + b.Property("AppApiToken") + .HasColumnType("text") + .HasColumnName("app_api_token"); + + b.Property("AutoDeployMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auto_deploy_mode"); + + b.Property("AutoDeployTime") + .HasColumnType("text") + .HasColumnName("auto_deploy_time"); + + b.Property("BackupCron") + .HasColumnType("text") + .HasColumnName("backup_cron"); + + b.Property("BackupDirectory") + .HasColumnType("text") + .HasColumnName("backup_directory"); + + b.Property("BackupEnabled") + .HasColumnType("boolean") + .HasColumnName("backup_enabled"); + + b.Property("BackupQuiesceMode") + .HasColumnType("text") + .HasColumnName("backup_quiesce_mode"); + + b.Property("BackupStopContainers") + .HasColumnType("boolean") + .HasColumnName("backup_stop_containers"); + + b.Property("BranchOverride") + .HasColumnType("text") + .HasColumnName("branch_override"); + + b.Property("ComposeProjectName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("compose_project_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DesiredState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Running") + .HasColumnName("desired_state"); + + b.Property("LastDeployStatus") + .HasColumnType("text") + .HasColumnName("last_deploy_status"); + + b.Property("LastDeployedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_deployed_at"); + + b.Property("LastDeployedCommit") + .HasColumnType("text") + .HasColumnName("last_deployed_commit"); + + b.Property("LastDeployedReleaseId") + .HasColumnType("integer") + .HasColumnName("last_deployed_release_id"); + + b.Property("LastScheduledBackupAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_scheduled_backup_at"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("PinnedReleaseId") + .HasColumnType("integer") + .HasColumnName("pinned_release_id"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.Property("TenantSlug") + .HasColumnType("text") + .HasColumnName("tenant_slug"); + + b.Property("WebhookEnabled") + .HasColumnType("boolean") + .HasColumnName("webhook_enabled"); + + b.Property("WebhookToken") + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.Property("Xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_stacks"); + + b.HasIndex("AppApiToken") + .IsUnique() + .HasDatabaseName("ix_stacks_app_api_token"); + + b.HasIndex("LastDeployedReleaseId") + .HasDatabaseName("ix_stacks_last_deployed_release_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_stacks_name"); + + b.HasIndex("PinnedReleaseId") + .HasDatabaseName("ix_stacks_pinned_release_id"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_stacks_product_id"); + + b.HasIndex("TemplateId", "TenantSlug") + .IsUnique() + .HasDatabaseName("ix_stacks_template_id_tenant_slug"); + + b.ToTable("stacks", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackBackupServiceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Dump") + .HasColumnType("text") + .HasColumnName("dump"); + + b.Property("Exclude") + .HasColumnType("boolean") + .HasColumnName("exclude"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Stop") + .HasColumnType("text") + .HasColumnName("stop"); + + b.HasKey("Id") + .HasName("pk_stack_backup_service_overrides"); + + b.HasIndex("StackId", "Service") + .IsUnique() + .HasDatabaseName("ix_stack_backup_service_overrides_stack_id_service"); + + b.ToTable("stack_backup_service_overrides", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_path"); + + b.Property("HostPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host_path"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.HasKey("Id") + .HasName("pk_stack_device_mappings"); + + b.HasIndex("StackId", "Service", "HostPath") + .IsUnique() + .HasDatabaseName("ix_stack_device_mappings_stack_id_service_host_path"); + + b.ToTable("stack_device_mappings", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_stack_env_vars"); + + b.HasIndex("StackId", "Key") + .IsUnique() + .HasDatabaseName("ix_stack_env_vars_stack_id_key"); + + b.ToTable("stack_env_vars", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackGpuMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.HasKey("Id") + .HasName("pk_stack_gpu_mappings"); + + b.HasIndex("StackId", "Service") + .IsUnique() + .HasDatabaseName("ix_stack_gpu_mappings_stack_id_service"); + + b.ToTable("stack_gpu_mappings", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BackupCron") + .HasColumnType("text") + .HasColumnName("backup_cron"); + + b.Property("BackupEnabled") + .HasColumnType("boolean") + .HasColumnName("backup_enabled"); + + b.Property("BackupQuiesceMode") + .HasColumnType("text") + .HasColumnName("backup_quiesce_mode"); + + b.Property("BackupStopContainers") + .HasColumnType("boolean") + .HasColumnName("backup_stop_containers"); + + b.Property("BranchOverride") + .HasColumnType("text") + .HasColumnName("branch_override"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultPinnedReleaseId") + .HasColumnType("integer") + .HasColumnName("default_pinned_release_id"); + + b.Property("DomainPattern") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain_pattern"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ProductId") + .HasColumnType("integer") + .HasColumnName("product_id"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("TargetServiceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_service_name"); + + b.HasKey("Id") + .HasName("pk_stack_templates"); + + b.HasIndex("DefaultPinnedReleaseId") + .HasDatabaseName("ix_stack_templates_default_pinned_release_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_stack_templates_name"); + + b.HasIndex("ProductId") + .HasDatabaseName("ix_stack_templates_product_id"); + + b.HasIndex("RealmId") + .HasDatabaseName("ix_stack_templates_realm_id"); + + b.ToTable("stack_templates", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplateEnvVar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_stack_template_env_vars"); + + b.HasIndex("TemplateId", "Key") + .IsUnique() + .HasDatabaseName("ix_stack_template_env_vars_template_id_key"); + + b.ToTable("stack_template_env_vars", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackUpdateCheck", b => + { + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("AvailableReleaseId") + .HasColumnType("integer") + .HasColumnName("available_release_id"); + + b.Property("AvailableReleaseVersion") + .HasColumnType("text") + .HasColumnName("available_release_version"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("DriftedContainers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("drifted_containers"); + + b.Property("HasUpdates") + .HasColumnType("boolean") + .HasColumnName("has_updates"); + + b.Property("NewCommitSha") + .HasColumnType("text") + .HasColumnName("new_commit_sha"); + + b.Property("OutdatedImageDigests") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outdated_image_digests"); + + b.Property("OutdatedImages") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outdated_images"); + + b.HasKey("StackId") + .HasName("pk_stack_update_checks"); + + b.ToTable("stack_update_checks", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateBackupServiceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Dump") + .HasColumnType("text") + .HasColumnName("dump"); + + b.Property("Exclude") + .HasColumnType("boolean") + .HasColumnName("exclude"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("Stop") + .HasColumnType("text") + .HasColumnName("stop"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_template_backup_service_overrides"); + + b.HasIndex("TemplateId", "Service") + .IsUnique() + .HasDatabaseName("ix_template_backup_service_overrides_template_id_service"); + + b.ToTable("template_backup_service_overrides", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateManagementGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowDelete") + .HasColumnType("boolean") + .HasColumnName("allow_delete"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.Property("TemplateId") + .HasColumnType("integer") + .HasColumnName("template_id"); + + b.HasKey("Id") + .HasName("pk_template_management_grants"); + + b.HasIndex("TemplateId") + .HasDatabaseName("ix_template_management_grants_template_id"); + + b.HasIndex("StackId", "TemplateId") + .IsUnique() + .HasDatabaseName("ix_template_management_grants_stack_id_template_id"); + + b.ToTable("template_management_grants", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("AuthenticatorKey") + .HasColumnType("text") + .HasColumnName("authenticator_key"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Disabled") + .HasColumnType("boolean") + .HasColumnName("disabled"); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("RealmId") + .HasColumnType("integer") + .HasColumnName("realm_id"); + + b.Property("SecurityStamp") + .IsRequired() + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("RealmId", "NormalizedUserName") + .IsUnique() + .HasDatabaseName("ix_users_realm_id_normalized_user_name"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.UserRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("code_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_recovery_codes"); + + b.HasIndex("UserId", "CodeHash") + .IsUnique() + .HasDatabaseName("ix_user_recovery_codes_user_id_code_hash"); + + b.ToTable("user_recovery_codes", (string)null); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.AuthSession", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_sessions_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_users_user_id"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.BackupEvent", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_backup_events_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.CiRepo", b => + { + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_ci_repos_credentials_credential_id"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.DeployEvent", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "Release") + .WithMany() + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_deploy_events_releases_release_id"); + + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany("DeployEvents") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deploy_events_stacks_stack_id"); + + b.Navigation("Release"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Group", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_groups_realms_realm_id"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.GroupMember", b => + { + b.HasOne("Watchtower.Application.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_members_groups_group_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_group_members_users_user_id"); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.LoginCode", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_login_codes_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_login_codes_users_user_id"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.HasOne("Watchtower.Application.Entities.CiRepo", "CiRepo") + .WithMany() + .HasForeignKey("CiRepoId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_products_ci_repos_ci_repo_id"); + + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_products_credentials_credential_id"); + + b.Navigation("CiRepo"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Realm", b => + { + b.HasOne("Watchtower.Application.Entities.Route", "LoginRoute") + .WithMany() + .HasForeignKey("LoginRouteId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_realms_routes_login_route_id"); + + b.Navigation("LoginRoute"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Registry", b => + { + b.HasOne("Watchtower.Application.Entities.Credential", "Credential") + .WithMany() + .HasForeignKey("CredentialId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_registries_credentials_credential_id"); + + b.Navigation("Credential"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Releases") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_releases_products_product_id"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.ReleaseImage", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "Release") + .WithMany("Images") + .HasForeignKey("ReleaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_release_images_releases_release_id"); + + b.Navigation("Release"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Route", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_routes_realms_realm_id"); + + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_routes_stacks_stack_id"); + + b.Navigation("Realm"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.RouteAccessGrant", b => + { + b.HasOne("Watchtower.Application.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_route_access_grants_groups_group_id"); + + b.HasOne("Watchtower.Application.Entities.Route", "Route") + .WithMany() + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_access_grants_routes_route_id"); + + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_route_access_grants_users_user_id"); + + b.Navigation("Group"); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "LastDeployedRelease") + .WithMany() + .HasForeignKey("LastDeployedReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stacks_releases_last_deployed_release_id"); + + b.HasOne("Watchtower.Application.Entities.Release", "PinnedRelease") + .WithMany() + .HasForeignKey("PinnedReleaseId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_stacks_releases_pinned_release_id"); + + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Stacks") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stacks_products_product_id"); + + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("Instances") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stacks_stack_templates_template_id"); + + b.Navigation("LastDeployedRelease"); + + b.Navigation("PinnedRelease"); + + b.Navigation("Product"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackBackupServiceOverride", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_backup_service_overrides_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_device_mappings_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany("EnvVars") + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_env_vars_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackGpuMapping", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_gpu_mappings_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.HasOne("Watchtower.Application.Entities.Release", "DefaultPinnedRelease") + .WithMany() + .HasForeignKey("DefaultPinnedReleaseId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_stack_templates_releases_default_pinned_release_id"); + + b.HasOne("Watchtower.Application.Entities.Product", "Product") + .WithMany("Templates") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stack_templates_products_product_id"); + + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_stack_templates_realms_realm_id"); + + b.Navigation("DefaultPinnedRelease"); + + b.Navigation("Product"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplateEnvVar", b => + { + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("BaseEnvVars") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_template_env_vars_stack_templates_template_id"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackUpdateCheck", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithOne("UpdateCheck") + .HasForeignKey("Watchtower.Application.Entities.StackUpdateCheck", "StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_update_checks_stacks_stack_id"); + + b.Navigation("Stack"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateBackupServiceOverride", b => + { + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany("BackupServiceOverrides") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_backup_service_overrides_stack_templates_template_"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.TemplateManagementGrant", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_management_grants_stacks_stack_id"); + + b.HasOne("Watchtower.Application.Entities.StackTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_management_grants_stack_templates_template_id"); + + b.Navigation("Stack"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.User", b => + { + b.HasOne("Watchtower.Application.Entities.Realm", "Realm") + .WithMany() + .HasForeignKey("RealmId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_users_realms_realm_id"); + + b.Navigation("Realm"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.UserRecoveryCode", b => + { + b.HasOne("Watchtower.Application.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_recovery_codes_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Product", b => + { + b.Navigation("Releases"); + + b.Navigation("Stacks"); + + b.Navigation("Templates"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Release", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.Stack", b => + { + b.Navigation("DeployEvents"); + + b.Navigation("EnvVars"); + + b.Navigation("UpdateCheck"); + }); + + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => + { + b.Navigation("BackupServiceOverrides"); + + b.Navigation("BaseEnvVars"); + + b.Navigation("Instances"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.cs b/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.cs new file mode 100644 index 0000000..aa3f6c6 --- /dev/null +++ b/src/Watchtower.Application/Persistence/Migrations/20260828113059_AddStackGpuMappings.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Watchtower.Application.Persistence.Migrations +{ + /// + public partial class AddStackGpuMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "stack_gpu_mappings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + stack_id = table.Column(type: "integer", nullable: false), + service = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_stack_gpu_mappings", x => x.id); + table.ForeignKey( + name: "fk_stack_gpu_mappings_stacks_stack_id", + column: x => x.stack_id, + principalTable: "stacks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_stack_gpu_mappings_stack_id_service", + table: "stack_gpu_mappings", + columns: new[] { "stack_id", "service" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stack_gpu_mappings"); + } + } +} diff --git a/src/Watchtower.Application/Persistence/Migrations/WatchtowerDbContextModelSnapshot.cs b/src/Watchtower.Application/Persistence/Migrations/WatchtowerDbContextModelSnapshot.cs index cc80f27..21e7618 100644 --- a/src/Watchtower.Application/Persistence/Migrations/WatchtowerDbContextModelSnapshot.cs +++ b/src/Watchtower.Application/Persistence/Migrations/WatchtowerDbContextModelSnapshot.cs @@ -1316,7 +1316,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("routes", null, t => { - t.HasCheckConstraint("ck_routes_target", "(\"target\" = 'Watchtower' AND \"stack_id\" IS NULL AND \"realm_id\" IS NOT NULL AND \"access_mode\" = 'Public')\r\nOR (\"target\" = 'Service' AND \"stack_id\" IS NOT NULL AND \"realm_id\" IS NULL)"); + t.HasCheckConstraint("ck_routes_target", "(\"target\" = 'Watchtower' AND \"stack_id\" IS NULL AND \"realm_id\" IS NOT NULL AND \"access_mode\" = 'Public')\nOR (\"target\" = 'Service' AND \"stack_id\" IS NOT NULL AND \"realm_id\" IS NULL)"); }); }); @@ -1585,6 +1585,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("stack_backup_service_overrides", (string)null); }); + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContainerPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("container_path"); + + b.Property("HostPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("host_path"); + + b.Property("Permissions") + .HasColumnType("text") + .HasColumnName("permissions"); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.HasKey("Id") + .HasName("pk_stack_device_mappings"); + + b.HasIndex("StackId", "Service", "HostPath") + .IsUnique() + .HasDatabaseName("ix_stack_device_mappings_stack_id_service_host_path"); + + b.ToTable("stack_device_mappings", (string)null); + }); + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => { b.Property("Id") @@ -1618,6 +1660,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("stack_env_vars", (string)null); }); + modelBuilder.Entity("Watchtower.Application.Entities.StackGpuMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Service") + .IsRequired() + .HasColumnType("text") + .HasColumnName("service"); + + b.Property("StackId") + .HasColumnType("integer") + .HasColumnName("stack_id"); + + b.HasKey("Id") + .HasName("pk_stack_gpu_mappings"); + + b.HasIndex("StackId", "Service") + .IsUnique() + .HasDatabaseName("ix_stack_gpu_mappings_stack_id_service"); + + b.ToTable("stack_gpu_mappings", (string)null); + }); + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => { b.Property("Id") @@ -2249,6 +2319,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Stack"); }); + modelBuilder.Entity("Watchtower.Application.Entities.StackDeviceMapping", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_device_mappings_stacks_stack_id"); + + b.Navigation("Stack"); + }); + modelBuilder.Entity("Watchtower.Application.Entities.StackEnvVar", b => { b.HasOne("Watchtower.Application.Entities.Stack", "Stack") @@ -2261,6 +2343,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Stack"); }); + modelBuilder.Entity("Watchtower.Application.Entities.StackGpuMapping", b => + { + b.HasOne("Watchtower.Application.Entities.Stack", "Stack") + .WithMany() + .HasForeignKey("StackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stack_gpu_mappings_stacks_stack_id"); + + b.Navigation("Stack"); + }); + modelBuilder.Entity("Watchtower.Application.Entities.StackTemplate", b => { b.HasOne("Watchtower.Application.Entities.Release", "DefaultPinnedRelease") diff --git a/src/Watchtower.Application/Services/ComposeOverrideFile.cs b/src/Watchtower.Application/Services/ComposeOverrideFile.cs index f5fdbe4..76ab090 100644 --- a/src/Watchtower.Application/Services/ComposeOverrideFile.cs +++ b/src/Watchtower.Application/Services/ComposeOverrideFile.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; @@ -85,15 +86,26 @@ public static IReadOnlyList ParseServices(string configJson /// The image-pinning plan, or null in Git mode — where this method renders precisely what it /// rendered before ADR-0026's release stage, byte for byte. /// + /// + /// The device-mapping plan (ADR-0030), or null for a stack with none — where this method again + /// renders exactly its pre-device output. Each device becomes a devices: list entry in + /// Compose's host:container[:permissions] string form. Compose merges devices: by + /// container path, so entries here append to whatever the repository declares and replace only an + /// entry with the same container path — the per-host-wins rule ADR-0030 decides on. + /// /// The file body, or null when there is nothing to write. - public static string? Render(EnvInjectionPlan plan, ImagePinPlan? imagePlan = null) { + public static string? Render( + EnvInjectionPlan plan, ImagePinPlan? imagePlan = null, DeviceMappingPlan? devicePlan = null) { var pins = imagePlan?.Services ?? []; - if (plan.Services.Count == 0 && pins.Count == 0) return null; + var deviceServices = devicePlan?.Services ?? []; + if (plan.Services.Count == 0 && pins.Count == 0 && deviceServices.Count == 0) return null; var variablesByService = plan.Services.ToDictionary(s => s.ServiceName, StringComparer.Ordinal); var pinsByService = pins.ToDictionary(p => p.ServiceName, StringComparer.Ordinal); + var devicesByService = deviceServices.ToDictionary(d => d.ServiceName, StringComparer.Ordinal); var names = variablesByService.Keys .Union(pinsByService.Keys, StringComparer.Ordinal) + .Union(devicesByService.Keys, StringComparer.Ordinal) .OrderBy(n => n, StringComparer.Ordinal); // '\n' rather than AppendLine: the file is handed to a Compose CLI that may well be running in a @@ -105,15 +117,41 @@ public static IReadOnlyList ParseServices(string configJson body.Append(" ").Append(QuoteKey(name)).Append(":\n"); if (pinsByService.TryGetValue(name, out var pin)) body.Append(" image: ").Append(QuoteValue(pin.Image)).Append('\n'); - if (!variablesByService.TryGetValue(name, out var service)) continue; - body.Append(" environment:\n"); - foreach (var variable in service.Variables) - body.Append(" ").Append(QuoteKey(variable.Key)).Append(": ") - .Append(QuoteValue(variable.Value)).Append('\n'); + if (variablesByService.TryGetValue(name, out var service)) { + body.Append(" environment:\n"); + foreach (var variable in service.Variables) + body.Append(" ").Append(QuoteKey(variable.Key)).Append(": ") + .Append(QuoteValue(variable.Value)).Append('\n'); + } + if (devicesByService.TryGetValue(name, out var devices)) { + if (devices.Devices.Count > 0) { + body.Append(" devices:\n"); + foreach (var device in devices.Devices) + body.Append(" - ").Append(QuoteValue(DeviceText(device))).Append('\n'); + } + // Compose appends group_add across files, so the repository's own groups survive. + // GIDs render as quoted strings: an unquoted number is looked up as a group *name* + // inside the container by some runtimes, and the probed GID rarely has one there. + if (devices.GroupIds.Count > 0) { + body.Append(" group_add:\n"); + foreach (var groupId in devices.GroupIds) + body.Append(" - ").Append(QuoteValue(groupId.ToString(CultureInfo.InvariantCulture))) + .Append('\n'); + } + } } return body.ToString(); } + /// + /// A device as Compose's string form spells it: host:container, with :permissions + /// only when the operator chose some — so the runtime default stays the runtime's to define. + /// + private static string DeviceText(ServiceDevice device) => + device.Permissions is { } permissions + ? $"{device.HostPath}:{device.ContainerPath}:{permissions}" + : $"{device.HostPath}:{device.ContainerPath}"; + /// Reads one label out of the map (or the KEY=VALUE list) Compose emitted. /// /// The normalized document uses a map, but the list form is accepted too so that a Compose version diff --git a/src/Watchtower.Application/Services/DeployQueueService.cs b/src/Watchtower.Application/Services/DeployQueueService.cs index 36e9fad..e7c8016 100644 --- a/src/Watchtower.Application/Services/DeployQueueService.cs +++ b/src/Watchtower.Application/Services/DeployQueueService.cs @@ -57,6 +57,7 @@ public class DeployQueueService : IHostedService, IDisposable { private readonly DockerEngineClient _docker; private readonly DeployOutputBroadcaster _broadcaster; private readonly IProxyProvider _proxy; + private readonly HostGpuProbe _gpuProbe; private readonly IOptionsMonitor _options; private readonly ILogger _logger; @@ -79,6 +80,7 @@ public DeployQueueService( DockerEngineClient docker, DeployOutputBroadcaster broadcaster, IProxyProvider proxy, + HostGpuProbe gpuProbe, IOptionsMonitor options, ILogger logger) { _scopeFactory = scopeFactory; @@ -87,6 +89,7 @@ public DeployQueueService( _docker = docker; _broadcaster = broadcaster; _proxy = proxy; + _gpuProbe = gpuProbe; _options = options; _logger = logger; _maxConcurrentDeploys = options.CurrentValue.ResolveMaxConcurrentDeploys(); @@ -531,7 +534,28 @@ void WriteHeader(string line) { WriteHeader($"[Watchtower] {warning}"); } - if (ComposeOverrideFile.Render(plan, imagePlan) is { } overrideContent) { + // 4d. Device mappings (ADR-0030): host devices configured per stack in the UI, rendered + // into the same generated override — host-specific values the repository's compose + // file must not carry. Empty for a stack with no rows, which keeps the override + // byte-identical to its pre-device form. + // 4e. GPU intents (ADR-0031) resolve against a live probe of the host's render nodes — + // only when the stack has any, so a probe hiccup can never slow a GPU-less fleet, + // and a failed probe degrades to "no GPUs found" rather than a failed deploy. + var gpuMappings = GetGpuMappings(stackId); + var gpuCatalog = HostGpuCatalog.Empty; + if (gpuMappings.Count > 0) { + gpuCatalog = await _gpuProbe.GetAsync(ct); + if (gpuCatalog.Error is { } probeError) + WriteHeader($"[Watchtower] Warning: {probeError} GPU passthrough maps nothing this deploy."); + } + var devicePlan = DeviceMappingPlan.Create( + services, GetDeviceMappings(stackId), gpuMappings, gpuCatalog.Gpus); + foreach (var warning in devicePlan.Warnings) + WriteHeader($"[Watchtower] {warning}"); + foreach (var note in devicePlan.Notes) + WriteHeader($"[Watchtower] {note}"); + + if (ComposeOverrideFile.Render(plan, imagePlan, devicePlan) is { } overrideContent) { overrideFilePath = Path.Combine( Path.GetTempPath(), $"watchtower-override-{Guid.NewGuid():N}.yml"); await File.WriteAllTextAsync(overrideFilePath, overrideContent, ct); @@ -546,6 +570,19 @@ void WriteHeader(string line) { WriteHeader( $"[Watchtower] Injecting {string.Join(", ", service.Variables.Select(v => v.Key))} " + $"into service '{service.ServiceName}'"); + // One line per device: mapping a host device into a container is an operator-level + // grant, so "why does this container see the GPU" must be answerable from the log. + foreach (var mapped in devicePlan.Services) { + foreach (var device in mapped.Devices) + WriteHeader( + $"[Watchtower] Mapping device {device.HostPath} into service " + + $"'{mapped.ServiceName}' at {device.ContainerPath}" + + (device.Permissions is { } permissions ? $" ({permissions})" : "")); + if (mapped.GroupIds.Count > 0) + WriteHeader( + $"[Watchtower] Adding supplementary group(s) {string.Join(", ", mapped.GroupIds)} " + + $"to service '{mapped.ServiceName}' for device access"); + } } // 5. Pull updated images. @@ -929,6 +966,22 @@ private async Task EnsureAppApiTokenAsync(int stackId, CancellationToken return await appApi.EnsureTokenAsync(stackId, ct); } + private List GetGpuMappings(int stackId) { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.StackGpuMappings.AsNoTracking() + .Where(m => m.StackId == stackId) + .ToList(); + } + + private List GetDeviceMappings(int stackId) { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.StackDeviceMappings.AsNoTracking() + .Where(m => m.StackId == stackId) + .ToList(); + } + private List<(string Key, string Value)> GetEnvVars(int stackId) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Watchtower.Application/Services/DeviceMappingPlan.cs b/src/Watchtower.Application/Services/DeviceMappingPlan.cs new file mode 100644 index 0000000..fa0d74d --- /dev/null +++ b/src/Watchtower.Application/Services/DeviceMappingPlan.cs @@ -0,0 +1,159 @@ +using Watchtower.Application.Entities; + +namespace Watchtower.Application.Services; + +/// One host device a service is to receive. +/// Absolute device path on the host. +/// Absolute device path inside the container. +/// Cgroup permissions (subset of rwm), or null for the runtime default. +public sealed record ServiceDevice(string HostPath, string ContainerPath, string? Permissions); + +/// Every device one service receives, ordered deterministically. +/// The service the devices go to. +/// The devices, ordered by container path then host path. +public sealed record ServiceDeviceMappings(string ServiceName, IReadOnlyList Devices) { + /// + /// Supplementary group ids the service's container user needs to open the mapped devices — + /// the owning groups of the resolved GPU nodes (ADR-0031), ascending. Empty for path-only + /// mappings, where Watchtower does not know the node's group. + /// + public IReadOnlyList GroupIds { get; init; } = []; +} + +/// +/// Which of a stack's compose services receive which host devices, and the warnings the decision +/// produced (ADR-0030; GPU intents ADR-0031). +/// +/// +/// The runtime-neutral half of device mapping, exactly as and +/// are for their features (ADR-0010's seam rule): it names no Docker or +/// Compose concept — device paths, supplementary groups and a GPU catalog all have Kubernetes +/// equivalents — so turning a plan into a Compose override stays ComposeOverrideFile's +/// business. +/// +/// Pure and total, like and for the same reason: a mapping this plan +/// cannot place — its service is not in the resolved project — becomes a warning rather than a +/// failed deploy, because services come and go with the repository and a leftover row must never +/// take a fleet down. +/// +/// +/// +/// The services receiving devices, ordered by name; services receiving none are absent. +/// Deterministic so a rendered override is diffable between deploys. +/// +/// Operator-facing lines for the deploy output, in deterministic order. +public sealed record DeviceMappingPlan( + IReadOnlyList Services, + IReadOnlyList Warnings) { + /// + /// Operator-facing lines that are expected outcomes rather than problems — above all "this host + /// has no GPU", which is ADR-0031's feature working, not a misconfiguration. Kept apart from + /// so a deliberately GPU-less host does not warn on every deploy. + /// + public IReadOnlyList Notes { get; init; } = []; + + /// A plan that maps nothing and has nothing to say — what a stack with no rows uses. + public static readonly DeviceMappingPlan Empty = new([], []); + + /// + /// Places the stack's stored device mappings — literal paths and GPU intents — onto the services + /// the engine actually resolved. + /// + /// + /// The match is by service name, ordinal — the same identity keys + /// on. A GPU intent resolves to every node of + /// plus the nodes' owning groups; NVIDIA nodes are skipped with a + /// note (ADR-0031 decision 3). Exact duplicate devices collapse silently, and on a container-path + /// collision the explicit path mapping wins over a GPU-resolved node — the operator's literal row + /// is the more deliberate statement. Everything else the set handler already validated at write + /// time, and re-refusing it here would fail a deploy over a row the operator cannot currently see. + /// + /// The stack's services as the engine resolved them, in any order. + /// The stack's stored literal device mappings, in any order. + /// The stack's stored GPU intents, in any order; null means none. + /// The probed host GPU catalog; null/empty on GPU-less hosts or when the probe failed. + /// The plan, ordered deterministically. + public static DeviceMappingPlan Create( + IReadOnlyList services, + IReadOnlyList mappings, + IReadOnlyList? gpuMappings = null, + IReadOnlyList? hostGpus = null) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(mappings); + var gpuServices = (gpuMappings ?? []) + .Select(g => g.Service) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + if (mappings.Count == 0 && gpuServices.Count == 0) return Empty; + + var known = new HashSet(services.Select(s => s.Name), StringComparer.Ordinal); + var warnings = new List(); + var notes = new List(); + + var pathsByService = new Dictionary>(StringComparer.Ordinal); + foreach (var group in mappings + .GroupBy(m => m.Service, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal)) { + if (!known.Contains(group.Key)) { + warnings.Add( + $"Warning: device mapping(s) configured for service '{group.Key}', which is not one " + + "of this stack's services — they were not applied."); + continue; + } + pathsByService[group.Key] = [.. group + .Select(m => new ServiceDevice(m.HostPath, m.ContainerPath, m.Permissions)) + .Distinct()]; + } + + var mappable = (hostGpus ?? []).Where(g => g.IsMappable).OrderBy(g => g.Name, StringComparer.Ordinal).ToList(); + var gpusByService = new Dictionary>(StringComparer.Ordinal); + var gpulessServices = new List(); + foreach (var service in gpuServices) { + if (!known.Contains(service)) { + warnings.Add( + $"Warning: GPU passthrough configured for service '{service}', which is not one " + + "of this stack's services — nothing was mapped."); + continue; + } + if (mappable.Count == 0) gpulessServices.Add(service); + else gpusByService[service] = mappable; + } + if (gpulessServices.Count > 0) + notes.Add( + "No mappable host GPU was detected — " + + string.Join(", ", gpulessServices.Select(s => $"'{s}'")) + + " get(s) no GPU devices on this host."); + // Only worth a line when someone actually asked for a GPU on this host. + if (gpuServices.Any(known.Contains)) + foreach (var skipped in (hostGpus ?? []).Where(g => !g.IsMappable).OrderBy(g => g.Name, StringComparer.Ordinal)) + notes.Add( + $"NVIDIA GPU '{skipped.Name}' needs the NVIDIA container toolkit and is not mapped " + + "by device path (ADR-0031)."); + + var placed = new List(); + foreach (var name in pathsByService.Keys.Union(gpusByService.Keys, StringComparer.Ordinal) + .OrderBy(n => n, StringComparer.Ordinal)) { + var devices = new List(pathsByService.GetValueOrDefault(name) ?? []); + var groupIds = new List(); + foreach (var gpu in gpusByService.GetValueOrDefault(name) ?? []) { + // Explicit-path wins on a shared container path: skip the GPU node, keep its group — + // the operator plainly wants the device reachable either way. + if (!devices.Any(d => string.Equals(d.ContainerPath, gpu.Path, StringComparison.Ordinal))) + devices.Add(new ServiceDevice(gpu.Path, gpu.Path, null)); + groupIds.Add(gpu.GroupId); + } + placed.Add(new ServiceDeviceMappings( + name, + [.. devices + .OrderBy(d => d.ContainerPath, StringComparer.Ordinal) + .ThenBy(d => d.HostPath, StringComparer.Ordinal)]) { + GroupIds = [.. groupIds.Distinct().Order()], + }); + } + + return placed.Count == 0 && warnings.Count == 0 && notes.Count == 0 + ? Empty + : new DeviceMappingPlan(placed, warnings) { Notes = notes }; + } +} diff --git a/src/Watchtower.Application/Services/HostGpuProbe.cs b/src/Watchtower.Application/Services/HostGpuProbe.cs new file mode 100644 index 0000000..9e272d1 --- /dev/null +++ b/src/Watchtower.Application/Services/HostGpuProbe.cs @@ -0,0 +1,174 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Watchtower.Application.Config; + +namespace Watchtower.Application.Services; + +/// One GPU render node the host exposes, as the probe read it (ADR-0031). +/// The node's name, e.g. renderD128. +/// The host device path, e.g. /dev/dri/renderD128. +/// PCI vendor id as sysfs prints it (0x8086 Intel, 0x1002 AMD, 0x10de NVIDIA); empty when unreadable. +/// The bound kernel driver (i915, xe, amdgpu, nvidia, …); empty when unreadable. +/// The PCI address, e.g. 0000:00:02.0; empty when unreadable. +/// The GID owning the device node — what the container user must carry to open it. +public sealed record HostGpu( + string Name, string Path, string VendorId, string Driver, string PciAddress, int GroupId) { + /// PCI vendor ids, lower-cased as sysfs prints them. + public const string IntelVendorId = "0x8086"; + public const string AmdVendorId = "0x1002"; + public const string NvidiaVendorId = "0x10de"; + + /// + /// Whether a plain device mapping gives a container working access. NVIDIA is the deliberate + /// exception (ADR-0031 decision 3): the node without the toolkit-injected user-space driver + /// fails inconsistently, which is worse than not mapping it. + /// + public bool IsMappable => + !string.Equals(VendorId, NvidiaVendorId, StringComparison.OrdinalIgnoreCase) + && Driver is not ("nvidia" or "nouveau"); +} + +/// What one probe run produced: the nodes found, or why nothing could be said. +/// The render nodes, in node-name order; empty on a GPU-less host. +/// +/// Why the probe could not run (helper image unpullable, daemon error), or null when it ran — an +/// empty with a null error genuinely means "this host has no render nodes". +/// +public sealed record HostGpuCatalog(IReadOnlyList Gpus, string? Error) { + public static readonly HostGpuCatalog Empty = new([], null); +} + +/// +/// Discovers the Docker host's GPU render nodes (ADR-0031). Watchtower's own container does not see +/// the host's /dev, so the probe borrows the backup feature's trick (ADR-0016): a short-lived +/// helper container with the host's /dev and /sys bind-mounted read-only. The default +/// device cgroup denies opening the nodes, so the probe can list and stat but never touch a +/// device — it needs no privileges beyond the two mounts. +/// +/// +/// The result is cached briefly: the UI asks on every Settings visit and a deploy asks once more, +/// while the answer changes about as often as someone reseats a PCI card. Failure is part of the +/// contract, not an exception — a deploy must proceed (GPU-less) past a broken probe, so +/// reports problems inside the catalog. +/// +public sealed class HostGpuProbe( + DockerEngineClient docker, + IOptionsMonitor options, + ILogger logger, + TimeProvider time) { + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + + /// + /// Walks the render nodes via the host's /sys (mounted at /hostsys) and prints one + /// parseable line per node. Field order matches . BusyBox-only + /// tools, matching the default helper image; every sub-read tolerates absence so one odd node + /// cannot take down the whole listing. + /// + private const string ProbeScript = """ + for n in /hostsys/class/drm/renderD*; do + [ -e "$n" ] || continue + name="${n##*/}" + node="/hostdev/dri/$name" + [ -e "$node" ] || continue + vendor="$(cat "$n/device/vendor" 2>/dev/null)" + driver="$(sed -n 's/^DRIVER=//p' "$n/device/uevent" 2>/dev/null)" + pci="$(readlink -f "$n/device" 2>/dev/null)" + gid="$(stat -c %g "$node" 2>/dev/null)" + echo "gpu|$name|$vendor|$driver|${pci##*/}|$gid" + done + """; + + private readonly SemaphoreSlim _gate = new(1, 1); + private HostGpuCatalog? _cached; + private DateTimeOffset _cachedAt; + + /// Returns the host's GPU catalog, probing at most once per . + public async Task GetAsync(CancellationToken ct) { + await _gate.WaitAsync(ct); + try { + if (_cached is not null && time.GetUtcNow() - _cachedAt < CacheTtl) return _cached; + _cached = await ProbeAsync(ct); + _cachedAt = time.GetUtcNow(); + return _cached; + } finally { + _gate.Release(); + } + } + + private async Task ProbeAsync(CancellationToken ct) { + var image = options.CurrentValue.Backup.HelperImage; + string? containerId = null; + try { + if (!await docker.ImageExistsAsync(image, ct)) { + logger.LogInformation("Pulling GPU probe helper image {Image}", image); + await docker.PullImageAsync(image, ct: ct); + } + + containerId = await docker.CreateContainerAsync(new DockerCreateContainerBody { + Image = image, + Cmd = ["sh", "-c", ProbeScript], + HostConfig = new DockerCreateHostConfig { + // The whole of /dev and /sys rather than /dev/dri and /sys/class/drm: both roots + // exist on every Linux host, where binding a *missing* source path would make + // the daemon create it as a directory on the host — a GPU-less machine would + // grow an empty /dev/dri because Watchtower looked at it. + Binds = ["/dev:/hostdev:ro", "/sys:/hostsys:ro"], + NetworkMode = "none", + AutoRemove = false, + }, + }, name: $"watchtower-gpuprobe-{Guid.NewGuid():N}"[..32], ct); + + await docker.StartContainerAsync(containerId, ct); + var exitCode = await docker.WaitContainerAsync(containerId, ct); + + var lines = new List(); + await foreach (var line in docker.StreamLogsAsync(containerId, tail: 200, follow: false, ct)) + lines.Add(line); + + if (exitCode != 0) { + logger.LogWarning("GPU probe helper exited with code {ExitCode}", exitCode); + return new HostGpuCatalog([], $"The GPU probe helper exited with code {exitCode}."); + } + return new HostGpuCatalog(ParseProbeOutput(lines), null); + } catch (OperationCanceledException) { + throw; + } catch (Exception ex) { + // The message reaches the Settings UI and the deploy log; the stack trace stays here. + logger.LogWarning(ex, "GPU probe failed"); + return new HostGpuCatalog([], $"Probing the host for GPUs failed: {ex.Message}"); + } finally { + if (containerId is not null) { + try { + await docker.RemoveContainerAsync(containerId, CancellationToken.None); + } catch (Exception ex) { + logger.LogWarning(ex, "Failed to remove GPU probe container {ContainerId}", containerId); + } + } + } + } + + /// + /// Parses the probe's gpu|name|vendor|driver|pci|gid lines. Anything else in the log — + /// a shell diagnostic, a truncated line — is skipped rather than failed on: a probe that found + /// two GPUs and one oddity should report two GPUs. Pure, for the tests' sake. + /// + public static IReadOnlyList ParseProbeOutput(IReadOnlyList lines) { + var gpus = new List(); + foreach (var line in lines) { + var parts = line.Split('|'); + if (parts.Length != 6 || parts[0] != "gpu") continue; + var name = parts[1].Trim(); + // The GID gates group_add, so a node whose stat failed is dropped rather than mapped + // half-working: a device the container cannot open is the trap this feature removes. + if (name.Length == 0 || !int.TryParse(parts[5].Trim(), out var gid)) continue; + gpus.Add(new HostGpu( + name, + $"/dev/dri/{name}", + parts[2].Trim().ToLowerInvariant(), + parts[3].Trim(), + parts[4].Trim(), + gid)); + } + return [.. gpus.OrderBy(g => g.Name, StringComparer.Ordinal)]; + } +} diff --git a/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs b/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs index 91a131c..e5891dd 100644 --- a/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs +++ b/src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs @@ -61,6 +61,8 @@ public static IServiceCollection AddWatchtowerServices(this IServiceCollection s // Stateless infrastructure (no DB) — singletons. services.AddSingleton(); + // Host GPU discovery for device passthrough (ADR-0031); caches, so a singleton. + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/watchtower-web/src/components/device-mapping-editor.tsx b/src/watchtower-web/src/components/device-mapping-editor.tsx new file mode 100644 index 0000000..694fc0b --- /dev/null +++ b/src/watchtower-web/src/components/device-mapping-editor.tsx @@ -0,0 +1,220 @@ +import { Plus, Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +/** One draft row; all fields plain strings so the inputs stay controlled. */ +export interface DeviceMappingRow { + service: string + hostPath: string + containerPath: string + permissions: string +} + +export const blankDeviceRow: DeviceMappingRow = { + service: '', + hostPath: '', + containerPath: '', + permissions: '', +} + +function isBlank(row: DeviceMappingRow): boolean { + return ( + row.service.trim() === '' && + row.hostPath.trim() === '' && + row.containerPath.trim() === '' && + row.permissions.trim() === '' + ) +} + +export interface DeviceMappingEditorProps { + /** + * The DRAFT rows, INCLUDING the trailing blank row — the EnvVarEditor contract: the parent holds + * the array and passes it straight back. Start with `[blankDeviceRow]`. + */ + value: DeviceMappingRow[] + onChange: (rows: DeviceMappingRow[]) => void + className?: string +} + +const cellClass = + 'w-full rounded bg-surface-2 px-3 py-2 font-mono text-[13px] text-text outline-none placeholder:text-text-3 focus-visible:shadow-[var(--sh-focus)] md:rounded-none md:border-r md:border-border md:bg-transparent md:focus-visible:shadow-none md:focus-visible:bg-surface-2' + +/** + * Controlled editor for a stack's host device mappings (ADR-0030). Rows are + * [service | host path | container path | permissions | remove]; the blank trailing row + * auto-appends so there's always an empty row to type into. Container path and permissions are + * optional — blank means "same path in the container" and "Docker's default (rwm)". + * + * To persist, drop fully blank rows: `value.filter(r => !isRowBlank(r))` via the parent. + */ +export function DeviceMappingEditor({ value, onChange, className }: DeviceMappingEditorProps) { + function updateRow(i: number, field: keyof DeviceMappingRow, val: string) { + const next = value.map((r, idx) => (idx === i ? { ...r, [field]: val } : r)) + const last = next.at(-1) + if (!last || !isBlank(last)) next.push(blankDeviceRow) + onChange(next) + } + + function removeRow(i: number) { + const next = value.filter((_, idx) => idx !== i) + const tail = next.at(-1) + if (!tail || !isBlank(tail)) next.push(blankDeviceRow) + onChange(next) + } + + const grid = 'md:grid-cols-[1fr_1.4fr_1.4fr_5rem_2.5rem]' + + return ( +
+ {/* Header (desktop only) */} +
+ Service + Host device + In container + Access + +
+ +
+ {value.map((row, i) => { + const isBlankTrailer = i === value.length - 1 + return ( +
+ updateRow(i, 'service', e.target.value)} + placeholder="service" + spellCheck={false} + autoComplete="off" + aria-label={`Service for device ${i + 1}`} + className={cellClass} + /> + updateRow(i, 'hostPath', e.target.value)} + placeholder="/dev/dri/renderD128" + spellCheck={false} + autoComplete="off" + aria-label={`Host device path for device ${i + 1}`} + className={cellClass} + /> + updateRow(i, 'containerPath', e.target.value)} + placeholder="same as host" + spellCheck={false} + autoComplete="off" + aria-label={`Container path for device ${i + 1}`} + className={cellClass} + /> + updateRow(i, 'permissions', e.target.value)} + placeholder="rwm" + spellCheck={false} + autoComplete="off" + maxLength={3} + aria-label={`Permissions for device ${i + 1}`} + className={cellClass} + /> +
+ {!isBlankTrailer ? ( + + ) : ( + + )} +
+
+ ) + })} +
+
+ ) +} + +export function isDeviceRowBlank(row: DeviceMappingRow): boolean { + return isBlank(row) +} + +export interface GpuServiceEditorProps { + /** DRAFT rows including the trailing blank row — same contract as DeviceMappingEditor. */ + value: string[] + onChange: (rows: string[]) => void + className?: string +} + +/** + * Controlled editor for the services that receive the host's GPUs (ADR-0031). One column of + * compose service names; the actual devices are resolved by probing the host on every deploy, so + * there is nothing else to configure. To persist, drop blank rows. + */ +export function GpuServiceEditor({ value, onChange, className }: GpuServiceEditorProps) { + function updateRow(i: number, val: string) { + const next = value.map((r, idx) => (idx === i ? val : r)) + if (next.at(-1)?.trim() !== '') next.push('') + onChange(next) + } + + function removeRow(i: number) { + const next = value.filter((_, idx) => idx !== i) + if (next.length === 0 || next.at(-1)?.trim() !== '') next.push('') + onChange(next) + } + + return ( +
+ {value.map((row, i) => { + const isBlankTrailer = i === value.length - 1 + return ( +
+ updateRow(i, e.target.value)} + placeholder="service receiving host GPUs" + spellCheck={false} + autoComplete="off" + aria-label={`GPU service ${i + 1}`} + className={cn(cellClass, 'md:border-r-0')} + /> +
+ {!isBlankTrailer ? ( + + ) : ( + + )} +
+
+ ) + })} +
+ ) +} diff --git a/src/watchtower-web/src/lib/api.ts b/src/watchtower-web/src/lib/api.ts index f4ea72b..4f65eb4 100644 --- a/src/watchtower-web/src/lib/api.ts +++ b/src/watchtower-web/src/lib/api.ts @@ -83,6 +83,9 @@ import type { TemplateEnvVar, TemplateGrant, UpdateTemplateRequest, + HostGpus, + StackDeviceMappingInput, + StackDevices, StackEnvVar, StackEnvVarInput, StackMetricsResult, @@ -325,6 +328,20 @@ export const api = { getEnv: async (id: number) => (await rpc('stacks.getEnv', { stackId: id })).envVars as StackEnvVar[], setEnv: async (id: number, vars: StackEnvVarInput[]) => (await rpc('stacks.setEnv', { stackId: id, vars })).envVars as StackEnvVar[], + getDevices: async (id: number) => + (await rpc('stacks.getDevices', { stackId: id })) as StackDevices, + setDevices: async (id: number, devices: StackDeviceMappingInput[], gpuServices: string[]) => + (await rpc('stacks.setDevices', { + stackId: id, + devices: devices.map((d) => ({ + service: d.service, + hostPath: d.hostPath, + containerPath: d.containerPath ?? null, + permissions: d.permissions ?? null, + })), + gpuServices, + })) as StackDevices, + hostGpus: async () => (await rpc('stacks.hostGpus', {})) as HostGpus, checkUpdates: async (id: number) => (await rpc('stacks.checkUpdates', { id })).stack as Stack, /** diff --git a/src/watchtower-web/src/lib/types.ts b/src/watchtower-web/src/lib/types.ts index f6b729d..e49f760 100644 --- a/src/watchtower-web/src/lib/types.ts +++ b/src/watchtower-web/src/lib/types.ts @@ -496,6 +496,53 @@ export interface StackEnvVarInput { value: string } +/** One host device mapped into a compose service of a stack (ADR-0030). */ +export interface StackDeviceMapping { + id: number + service: string + hostPath: string + containerPath: string + /** Cgroup permissions (subset of "rwm"); absent for Docker's default. */ + permissions?: string | null +} + +/** One entry in a batch-replace request for stack device mappings. */ +export interface StackDeviceMappingInput { + service: string + hostPath: string + /** Defaults to hostPath when omitted/blank. */ + containerPath?: string | null + permissions?: string | null +} + +/** A stack's device configuration: literal mappings plus GPU-passthrough intents (ADR-0031). */ +export interface StackDevices { + devices: StackDeviceMapping[] + /** Services with the "map host GPU(s)" intent. */ + gpuServices: string[] +} + +/** One GPU render node the Docker host exposes (ADR-0031). */ +export interface HostGpu { + /** Node name, e.g. "renderD128". */ + name: string + /** Host device path, e.g. "/dev/dri/renderD128". */ + path: string + /** "intel" | "amd" | "nvidia" | "unknown". */ + vendor: string + /** Bound kernel driver, e.g. "i915". */ + driver: string + pciAddress: string + /** False for NVIDIA, which needs the container toolkit rather than a device mapping. */ + mappable: boolean +} + +export interface HostGpus { + gpus: HostGpu[] + /** Why the probe could not run, or absent when it did. */ + error?: string | null +} + /** One env var a container is actually running with (from Docker inspect). */ export interface ContainerEnvVar { key: string diff --git a/src/watchtower-web/src/modules/stacks/SettingsTab.tsx b/src/watchtower-web/src/modules/stacks/SettingsTab.tsx index 1d83fd5..c01e04b 100644 --- a/src/watchtower-web/src/modules/stacks/SettingsTab.tsx +++ b/src/watchtower-web/src/modules/stacks/SettingsTab.tsx @@ -6,6 +6,13 @@ import { apiBase } from '@/lib/config' import { usesReleases } from '@/lib/release' import type { AutoDeployMode, Stack, StackEnvVarInput, UpdateStackRequest } from '@/lib/types' import { EnvVarEditor } from '@/components/env-var-editor' +import { + DeviceMappingEditor, + GpuServiceEditor, + blankDeviceRow, + isDeviceRowBlank, + type DeviceMappingRow, +} from '@/components/device-mapping-editor' import { Banner } from '@/components/ui/banner' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -40,6 +47,18 @@ export function SettingsTab({ stack }: { stack: Stack }) { queryFn: () => api.stacks.getEnv(stackId), }) + const devicesQuery = useQuery({ + queryKey: ['stacks', stackId, 'devices'], + queryFn: () => api.stacks.getDevices(stackId), + }) + + // Host-wide, not per stack — what "map host GPU(s)" would resolve to on this Docker host. + const hostGpusQuery = useQuery({ + queryKey: ['host', 'gpus'], + queryFn: () => api.stacks.hostGpus(), + staleTime: 60_000, + }) + // Only to decide whether the product is linkable; the branch hint below is derived from the stack // DTO alone, because it is the only source that cannot disagree with what the backend compares. const { caps } = useRouteContext({ from: '__root__' }) @@ -69,16 +88,55 @@ export function SettingsTab({ stack }: { stack: Stack }) { { key: '', value: '' }, ] + // Same draft-or-fallback shape as the env editor, for the same cold-cache reason. + const [deviceDraft, setDeviceDraft] = useState(null) + const deviceRows: DeviceMappingRow[] = deviceDraft ?? [ + ...(devicesQuery.data?.devices ?? []).map((d) => ({ + service: d.service, + hostPath: d.hostPath, + // Stored resolved (the backend defaults it to the host path); shown blank when they agree so + // the common case reads as "same as host" rather than as a deliberate second path. + containerPath: d.containerPath === d.hostPath ? '' : d.containerPath, + permissions: d.permissions ?? '', + })), + blankDeviceRow, + ] + + const [gpuDraft, setGpuDraft] = useState(null) + const gpuRows: string[] = gpuDraft ?? [...(devicesQuery.data?.gpuServices ?? []), ''] + const [confirmDelete, setConfirmDelete] = useState(false) const set = (key: K, value: (typeof form)[K]) => setForm((prev) => ({ ...prev, [key]: value })) const update = useMutation({ - mutationFn: (data: UpdateStackRequest) => api.stacks.update(stackId, data), + mutationFn: async (data: UpdateStackRequest) => { + const updated = await api.stacks.update(stackId, data) + // Devices ride the same Save, but only when the user actually edited them — sending the + // fallback rows while the query is unresolved would silently wipe the stored mappings. One + // RPC replaces both lists, so the unedited one is re-sent from the loaded data (present + // whenever a draft exists: the editors only render after the query resolved). + if ((deviceDraft || gpuDraft) && devicesQuery.data) { + const devices = deviceDraft + ? deviceDraft.filter((r) => !isDeviceRowBlank(r)).map((r) => ({ + service: r.service.trim(), + hostPath: r.hostPath.trim(), + containerPath: r.containerPath.trim() || null, + permissions: r.permissions.trim() || null, + })) + : devicesQuery.data.devices + const gpuServices = gpuDraft + ? gpuDraft.map((s) => s.trim()).filter((s) => s !== '') + : devicesQuery.data.gpuServices + await api.stacks.setDevices(stackId, devices, gpuServices) + } + return updated + }, onSuccess: (updated) => { qc.setQueryData(['stacks', stackId], updated) qc.invalidateQueries({ queryKey: ['stacks', stackId, 'env'] }) + qc.invalidateQueries({ queryKey: ['stacks', stackId, 'devices'] }) qc.invalidateQueries({ queryKey: ['stacks'] }) toast.success('Settings saved.') }, @@ -379,6 +437,70 @@ export function SettingsTab({ stack }: { stack: Stack }) { {envQuery.isSuccess && } + {/* Device mappings (ADR-0030) */} +
+ + {devicesQuery.isPending && ( +

+ Loading device mappings… +

+ )} + {devicesQuery.isError && ( + // No editor on error, for the env editor's reason: editing on top of unseen mappings + // would replace them on save. + + {devicesQuery.error.message} — saving will leave the stored mappings unchanged. + + )} + {devicesQuery.isSuccess && ( + <> + {/* GPU passthrough (ADR-0031): a host-neutral intent — the deploy probes the host and + maps whatever mappable render nodes exist, plus their owning groups. */} +

GPU passthrough

+ +

+ {hostGpusQuery.data?.error != null ? ( + <>Couldn’t inspect this host’s GPUs: {hostGpusQuery.data.error} + ) : hostGpusQuery.data ? ( + hostGpusQuery.data.gpus.length === 0 ? ( + <> + No GPU render node detected on this Docker host — listed services deploy fine + and simply get no GPU here. + + ) : ( + <> + Detected:{' '} + {hostGpusQuery.data.gpus.map((g, i) => ( + + {i > 0 && ', '} + {g.name} — {g.vendor} ({g.driver},{' '} + {g.pciAddress}){g.mappable ? '' : ' — needs the NVIDIA toolkit, not mapped'} + + ))} + . Each listed service gets every mappable GPU, and the required group is added + automatically. + + ) + ) : ( + <>Checking this host for GPUs… + )} +

+ +

Specific devices

+ +

+ Access is some combination of read,{' '} + write and mknod; + blank means all three. A mapping for a service the compose file doesn’t define is + skipped with a warning in the deploy log. +

+ + )} +
+ {/* Save */}