1{
2 config,
3 pkgs,
4 lib,
5 ...
6}:
7
8let
9 cfg = config.services.windmill;
10in
11{
12 options.services.windmill = {
13 enable = lib.mkEnableOption "windmill service";
14
15 serverPort = lib.mkOption {
16 type = lib.types.port;
17 default = 8001;
18 description = "Port the windmill server listens on.";
19 };
20
21 lspPort = lib.mkOption {
22 type = lib.types.port;
23 default = 3001;
24 description = "Port the windmill lsp listens on.";
25 };
26
27 database = {
28 name = lib.mkOption {
29 type = lib.types.str;
30 # the simplest database setup is to have the database named like the user.
31 default = "windmill";
32 description = "Database name.";
33 };
34
35 user = lib.mkOption {
36 type = lib.types.str;
37 # the simplest database setup is to have the database user like the name.
38 default = "windmill";
39 description = "Database user.";
40 };
41
42 url = lib.mkOption {
43 type = lib.types.str;
44 default = "postgres://${config.services.windmill.database.name}?host=/var/run/postgresql";
45 defaultText = lib.literalExpression ''
46 "postgres://\$\{config.services.windmill.database.name}?host=/var/run/postgresql";
47 '';
48 description = "Database url. Note that any secret here would be world-readable. Use `services.windmill.database.urlPath` unstead to include secrets in the url.";
49 };
50
51 urlPath = lib.mkOption {
52 type = lib.types.nullOr lib.types.path;
53 description = ''
54 Path to the file containing the database url windmill should connect to. This is not deducted from database user and name as it might contain a secret
55 '';
56 default = null;
57 example = "config.age.secrets.DATABASE_URL_FILE.path";
58 };
59
60 createLocally = lib.mkOption {
61 type = lib.types.bool;
62 default = true;
63 description = "Whether to create a local database automatically.";
64 };
65 };
66
67 baseUrl = lib.mkOption {
68 type = lib.types.str;
69 default = "https://localhost:${toString config.services.windmill.serverPort}";
70 defaultText = lib.literalExpression ''
71 "https://localhost:\$\{toString config.services.windmill.serverPort}";
72 '';
73 description = ''
74 The base url that windmill will be served on.
75 '';
76 example = "https://windmill.example.com";
77 };
78
79 logLevel = lib.mkOption {
80 type = lib.types.enum [
81 "error"
82 "warn"
83 "info"
84 "debug"
85 "trace"
86 ];
87 default = "info";
88 description = "Log level";
89 };
90 };
91
92 config = lib.mkIf cfg.enable {
93
94 assertions = [
95 {
96 assertion = cfg.database.createLocally -> cfg.database.name == cfg.database.user;
97 message = ''
98 Automatically provisioning the windmill database requires both database name and database user to be equal. '${cfg.database.name}' != '${cfg.database.user}'
99 To fix this problem, assign the same value to both options services.windmill.database.{name,user}.
100 '';
101 }
102 ];
103
104 services.postgresql = lib.optionalAttrs (cfg.database.createLocally) {
105 enable = lib.mkDefault true;
106
107 ensureDatabases = [ cfg.database.name ];
108 ensureUsers = [
109 {
110 name = cfg.database.user;
111 ensureDBOwnership = true;
112 }
113 ];
114 };
115
116 systemd.targets.windmill = {
117 description = "Windmill";
118 wantedBy = [ "multi-user.target" ];
119 requires =
120 [ ]
121 ++ (lib.optionals config.systemd.services.windmill-server.enable [ "windmill-server.service" ])
122 ++ (lib.optionals config.systemd.services.windmill-worker.enable [ "windmill-worker.service" ])
123 ++ (lib.optionals config.systemd.services.windmill-worker-native.enable [
124 "windmill-worker-native.service"
125 ]);
126 };
127
128 systemd.services =
129 let
130 useUrlPath = (cfg.database.urlPath != null);
131 serviceConfig = {
132 DynamicUser = true;
133 # using the same user to simplify db connection
134 User = cfg.database.user;
135 ExecStart = "${pkgs.windmill}/bin/windmill";
136 Restart = "always";
137 }
138 // lib.optionalAttrs useUrlPath {
139 LoadCredential = [
140 "DATABASE_URL_FILE:${cfg.database.urlPath}"
141 ];
142 };
143 db_url_envs =
144 lib.optionalAttrs useUrlPath {
145 DATABASE_URL_FILE = "%d/DATABASE_URL_FILE";
146 }
147 // lib.optionalAttrs (!useUrlPath) {
148 DATABASE_URL = cfg.database.url;
149 };
150 in
151 {
152 windmill-initdb = lib.mkIf cfg.database.createLocally {
153 description = "Windmill database setup";
154 requires = [ "postgresql.target" ];
155 after = [ "postgresql.target" ];
156 requiredBy =
157 [ ]
158 ++ (lib.optionals config.systemd.services.windmill-server.enable [ "windmill-server.service" ])
159 ++ (lib.optionals config.systemd.services.windmill-worker.enable [ "windmill-worker.service" ])
160 ++ (lib.optionals config.systemd.services.windmill-worker-native.enable [
161 "windmill-worker-native.service"
162 ]);
163 before =
164 [ ]
165 ++ (lib.optionals config.systemd.services.windmill-server.enable [ "windmill-server.service" ])
166 ++ (lib.optionals config.systemd.services.windmill-worker.enable [ "windmill-worker.service" ])
167 ++ (lib.optionals config.systemd.services.windmill-worker-native.enable [
168 "windmill-worker-native.service"
169 ]);
170
171 path = [ config.services.postgresql.package ];
172 # coming from https://github.com/windmill-labs/windmill/blob/main/init-db-as-superuser.sql
173 # modified to not grant privileges on all tables
174 # create role windmill_user and windmill_admin only if they don't exist
175 script = ''
176 psql -tA <<"EOF"
177 DO $$
178 BEGIN
179 IF NOT EXISTS (
180 SELECT FROM pg_catalog.pg_roles
181 WHERE rolname = 'windmill_user'
182 ) THEN
183 CREATE ROLE windmill_user;
184 GRANT ALL PRIVILEGES ON DATABASE ${cfg.database.name} TO windmill_user;
185 ELSE
186 RAISE NOTICE 'Role "windmill_user" already exists. Skipping.';
187 END IF;
188 IF NOT EXISTS (
189 SELECT FROM pg_catalog.pg_roles
190 WHERE rolname = 'windmill_admin'
191 ) THEN
192 CREATE ROLE windmill_admin WITH BYPASSRLS;
193 GRANT windmill_user TO windmill_admin;
194 ELSE
195 RAISE NOTICE 'Role "windmill_admin" already exists. Skipping.';
196 END IF;
197 GRANT windmill_admin TO ${cfg.database.user};
198 END
199 $$;
200 EOF
201 '';
202
203 serviceConfig = {
204 Type = "oneshot";
205 RemainAfterExit = true;
206 # Superuser because of required permission CREATE ROLE
207 User = "postgres";
208
209 ProtectSystem = "strict";
210 ProtectHome = "read-only";
211 };
212 };
213
214 windmill-server = {
215 description = "Windmill server";
216 after = [ "network.target" ];
217 partOf = [ "windmill.target" ];
218
219 serviceConfig = serviceConfig // {
220 StateDirectory = "windmill";
221 };
222
223 environment = {
224 PORT = builtins.toString cfg.serverPort;
225 WM_BASE_URL = cfg.baseUrl;
226 RUST_LOG = cfg.logLevel;
227 MODE = "server";
228 }
229 // db_url_envs;
230 };
231
232 windmill-worker = {
233 description = "Windmill worker";
234 after = [ "network.target" ];
235 partOf = [ "windmill.target" ];
236
237 serviceConfig = serviceConfig // {
238 StateDirectory = "windmill-worker";
239 };
240
241 environment = {
242 WM_BASE_URL = cfg.baseUrl;
243 RUST_LOG = cfg.logLevel;
244 MODE = "worker";
245 WORKER_GROUP = "default";
246 KEEP_JOB_DIR = "false";
247 }
248 // db_url_envs;
249 };
250
251 windmill-worker-native = {
252 description = "Windmill worker native";
253 after = [ "network.target" ];
254 partOf = [ "windmill.target" ];
255
256 serviceConfig = serviceConfig // {
257 StateDirectory = "windmill-worker-native";
258 };
259
260 environment = {
261 WM_BASE_URL = cfg.baseUrl;
262 RUST_LOG = cfg.logLevel;
263 MODE = "worker";
264 WORKER_GROUP = "native";
265 }
266 // db_url_envs;
267 };
268 };
269 };
270}