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