| 1 | open module Service |
| 2 | |
| 3 | import "site.pkl" as site |
| 4 | |
| 5 | const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else }}{{ .Address }}{{ end }}:{{ .Port }}" |
| 6 | |
| 7 | /// Name shown in the management UI and service listings. |
| 8 | class Metadata { |
| 9 | name: String |
| 10 | tagline: String = "" |
| 11 | launcher: Boolean = true |
| 12 | /// Discovery group; absent uses the public route's auth role. |
| 13 | access: String? |
| 14 | } |
| 15 | |
| 16 | /// One HTTP listener and its public route. |
| 17 | class Http { |
| 18 | containerPort: UInt16 |
| 19 | hostPort: UInt16? |
| 20 | loopback: Boolean = false |
| 21 | subdomain: String? |
| 22 | authRole: String? |
| 23 | /// Backend header set from the authenticated OIDC preferred username. |
| 24 | userHeader: String? |
| 25 | /// Identity headers copied from a valid SSO session; anonymous requests continue. |
| 26 | identityHeaders: Mapping<String, String> = new {} |
| 27 | /// Set X-Real-Ip from Caddy's observed client address before proxying. |
| 28 | forwardRealIp: Boolean = false |
| 29 | /// Generated secret whose value becomes the private proxy header name. |
| 30 | identityProofSecret: String? |
| 31 | /// Additional hosts routed to this container without SSO headers. |
| 32 | plainHostnames: Listing<String> = new {} |
| 33 | hostname: String? = read?("prop:hostname") ?? if (subdomain != null) "\(subdomain).\(site.domain)" else null |
| 34 | tlsInternal: Boolean = site.tlsInternal |
| 35 | checkPath: String = "/" |
| 36 | /// Internal Prometheus endpoint collected by the home server dashboard. |
| 37 | metricsPath: String? |
| 38 | checkHeaders: Mapping<String, String> = new {} |
| 39 | /// Request path to a file or directory in this service's folder. |
| 40 | overrideFiles: Mapping<String, String> = new {} |
| 41 | /// HTML inserted before </head> for the listed page paths. |
| 42 | headHtml: Mapping<String, String> = new {} |
| 43 | } |
| 44 | |
| 45 | /// One TCP listener published through Nomad. |
| 46 | class Tcp { |
| 47 | name: String |
| 48 | containerPort: UInt16 |
| 49 | hostPort: UInt16? |
| 50 | loopback: Boolean = true |
| 51 | } |
| 52 | |
| 53 | /// A container mount, keyed by its absolute path inside the container. |
| 54 | class Volume { |
| 55 | /// Host source; absent means the same path beneath this service's data root. |
| 56 | src: String? |
| 57 | /// File or directory relative to this service's assets folder; copied into a read-only mount. |
| 58 | config: String? |
| 59 | readOnly: Boolean = config != null |
| 60 | } |
| 61 | |
| 62 | /// A persistent value generated when the service is first provisioned. |
| 63 | class GeneratedSecret { |
| 64 | bytes: Int(isBetween(16, 128)) = 32 |
| 65 | } |
| 66 | |
| 67 | /// Provider-owned resource request; the alias names its allocated secret. |
| 68 | open class Requirement { |
| 69 | alias: String |
| 70 | fixed provider: String |
| 71 | fixed kind: String |
| 72 | } |
| 73 | |
| 74 | /// One Podman task. A service can contain one or several of these. |
| 75 | class Container { |
| 76 | /// Use either an image or a build directory in this service's release folder. |
| 77 | image: String? |
| 78 | build: String? |
| 79 | entrypoint: String? |
| 80 | http: Http? |
| 81 | tcp: Tcp? |
| 82 | volumes: Mapping<String, Volume> = new {} |
| 83 | /// Podman tmpfs mounts for data that must not persist in service storage. |
| 84 | tmpfs: Listing<String> = new {} |
| 85 | /// `${secret.own.key}` and `${secret.alias.key}` resolve from Nomad variables. |
| 86 | env: Mapping<String, String> = new {} |
| 87 | /// Nomad template for environment values resolved after allocation. |
| 88 | envTemplate: String? |
| 89 | args: Listing<String> = new {} |
| 90 | capAdd: Listing<String> = new {} |
| 91 | devices: Listing<String> = new {} |
| 92 | extraHosts: Listing<String> = new {} |
| 93 | hostNetwork: Boolean = false |
| 94 | imageUser: Boolean = false |
| 95 | /// Prestart tasks finish first; prestart sidecars stay up for the main tasks. |
| 96 | lifecycle: "main"|"prestart"|"prestartSidecar" = "main" |
| 97 | rootGroup: Boolean = false |
| 98 | cpu: Int = 200 |
| 99 | memory: Int = 512 |
| 100 | } |
| 101 | |
| 102 | /// Stable internal ID supplied from the service definition name by the caller. |
| 103 | id: String = read?("prop:serviceId") |
| 104 | /// Stable numeric owner assigned by the deployment tool. |
| 105 | uid: Int = read("prop:uid").toInt() |
| 106 | meta: Metadata |
| 107 | /// Allows site-specific services to stay out of deployments where they would cause side effects. |
| 108 | enabled: Boolean = true |
| 109 | /// Simple replaces one allocation; overlapped runs a canary alongside it. |
| 110 | rollout: String = "simple" |
| 111 | /// Fresh previews create empty storage and new secrets instead of forking production. |
| 112 | stageIsolation: "clone"|"fresh" = "clone" |
| 113 | /// Time allowed for a new allocation to pass its service checks. |
| 114 | healthyDeadline: String? |
| 115 | /// Delay before persistent health-check failures may restart a running allocation. |
| 116 | healthRestartGrace: String? |
| 117 | /// Repeatable service configuration run after a healthy deployment. |
| 118 | setup: String? |
| 119 | /// Service-owned data preparation before its container starts. |
| 120 | prepare: String? |
| 121 | /// Handler for input requests owned by this service. |
| 122 | provide: String? |
| 123 | |
| 124 | /// Use this for one container; it becomes the "app" task in the output. |
| 125 | container: Container? |
| 126 | |
| 127 | /// Use this instead of `container` when the service has named tasks. |
| 128 | containers: Mapping<String, Container>? |
| 129 | |
| 130 | secrets: Mapping<String, GeneratedSecret> = new {} |
| 131 | /// Secret names supplied from outside the release, in import-file line order. |
| 132 | requiredSecrets: Listing<String> = new {} |
| 133 | requirements: Listing<Requirement> = new {} |
| 134 | /// Service startup dependency with no resource to allocate. |
| 135 | dependsOn: Listing<String> = new {} |
| 136 | /// Resource service.name emitted by this app's trace exporter. |
| 137 | traceServiceName: String? |
| 138 | /// Metrics sent by the app over OTLP instead of a Prometheus HTTP endpoint. |
| 139 | metricsPushed: Boolean = false |
| 140 | |
| 141 | local deploymentContainers: Mapping<String, Container> = |
| 142 | if (!enabled) |
| 143 | new Mapping {} |
| 144 | else if (container != null && containers != null) |
| 145 | throw("Declare either container or containers, not both") |
| 146 | else if (container != null) |
| 147 | new Mapping { ["app"] = container!! } |
| 148 | else if (containers != null && !containers!!.isEmpty) |
| 149 | containers!! |
| 150 | else if (!requirements.isEmpty) |
| 151 | new Mapping {} |
| 152 | else |
| 153 | throw("Declare a container or an input") |
| 154 | |
| 155 | local vmStartupGrace: String? = if (read?("env:STUDIO_VM_ACCEL") == "qemu") "60m" else null |
| 156 | |
| 157 | output { |
| 158 | renderer = new JsonRenderer {} |
| 159 | value = new { |
| 160 | id = module.id |
| 161 | name = module.meta.name |
| 162 | tagline = module.meta.tagline |
| 163 | launcher = module.meta.launcher |
| 164 | access = module.meta.access |
| 165 | rollout = module.rollout |
| 166 | stageIsolation = module.stageIsolation |
| 167 | healthyDeadline = vmStartupGrace ?? module.healthyDeadline |
| 168 | healthRestartGrace = vmStartupGrace ?? module.healthRestartGrace |
| 169 | hostRoot = "\(site.root)/prod/\(module.id)" |
| 170 | stagingRoot = "\(site.root)/staging" |
| 171 | pool = site.pool |
| 172 | cloverRoot = site.cloverRoot |
| 173 | cloverGid = site.cloverGid |
| 174 | mediaRoot = site.mediaRoot |
| 175 | ownerEmail = site.ownerEmail |
| 176 | containers = deploymentContainers |
| 177 | setup = module.setup |
| 178 | prepare = module.prepare |
| 179 | provide = module.provide |
| 180 | // External mounts do not require this service's dataset. |
| 181 | hasManagedVolumes = deploymentContainers.toMap().values.any((task) -> |
| 182 | task.volumes.toMap().values.any((volume) -> volume.src == null && volume.config == null)) |
| 183 | secrets = module.secrets |
| 184 | requiredSecrets = module.requiredSecrets |
| 185 | requirements = module.requirements |
| 186 | dependsOn = module.dependsOn |
| 187 | traceServiceName = module.traceServiceName |
| 188 | metricsPushed = module.metricsPushed |
| 189 | } |
| 190 | } |