feat: add NixOS configuration with direnv support
- Add flake.nix with dev shell - Add NixOS module for Azion runtime config - Add direnv .envrc for auto-activation - Update .gitignore with .p*, result, .worktrees/, docs/, .direnv/
This commit is contained in:
522
nixos/module.nix
Normal file
522
nixos/module.nix
Normal file
@@ -0,0 +1,522 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
cfg = config.services.azion-scheduler;
|
||||
|
||||
proxyScript = pkgs.writeText "azion-scheduler-proxy.js" ''
|
||||
'use strict';
|
||||
|
||||
const http = require('node:http');
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(statusCode, message) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error('Missing required environment variable: ' + name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBaseUrl(rawValue) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(rawValue);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error('BASEROW_URL must be a valid absolute URL.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('BASEROW_URL must start with http:// or https://.');
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(rawValue, variableName) {
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
|
||||
throw new Error(variableName + ' must be a positive integer.');
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
function createRowsUrl(baseUrl, tableId, rowId, isListRequest) {
|
||||
const rowPath = rowId === null
|
||||
? '/api/database/rows/table/' + tableId + '/'
|
||||
: '/api/database/rows/table/' + tableId + '/' + rowId + '/';
|
||||
|
||||
const rowsUrl = new URL(rowPath, baseUrl);
|
||||
rowsUrl.searchParams.set('user_field_names', 'true');
|
||||
|
||||
if (isListRequest) {
|
||||
rowsUrl.searchParams.set('size', '200');
|
||||
}
|
||||
|
||||
return rowsUrl.toString();
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendNoContent(response) {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
}
|
||||
|
||||
function sendMethodNotAllowed(response, allowedMethods) {
|
||||
response.writeHead(405, {
|
||||
Allow: allowedMethods.join(', '),
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(JSON.stringify({ error: 'Method not allowed.' }));
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload, fallbackMessage) {
|
||||
if (typeof payload === 'string' && payload.length > 0) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object') {
|
||||
if (typeof payload.error === 'string' && payload.error.length > 0) {
|
||||
return payload.error;
|
||||
}
|
||||
|
||||
if (typeof payload.detail === 'string' && payload.detail.length > 0) {
|
||||
return payload.detail;
|
||||
}
|
||||
|
||||
if (typeof payload.message === 'string' && payload.message.length > 0) {
|
||||
return payload.message;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
async function readJsonFromResponse(response) {
|
||||
const bodyText = await response.text();
|
||||
if (bodyText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new HttpError(502, 'Baserow returned invalid JSON.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readRequestBody(request) {
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
let hasSizeError = false;
|
||||
|
||||
request.setEncoding('utf8');
|
||||
|
||||
request.on('data', (chunk) => {
|
||||
if (hasSizeError) {
|
||||
return;
|
||||
}
|
||||
|
||||
body += chunk;
|
||||
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
|
||||
hasSizeError = true;
|
||||
reject(new HttpError(413, 'Request body exceeds 1 MB.'));
|
||||
request.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
request.on('end', () => {
|
||||
if (!hasSizeError) {
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
reject(new HttpError(400, 'Failed to read request body: ' + error.message));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const rawBody = await readRequestBody(request);
|
||||
|
||||
if (rawBody.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let parsedBody;
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new HttpError(400, 'Request body must be valid JSON.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!parsedBody || typeof parsedBody !== 'object' || Array.isArray(parsedBody)) {
|
||||
throw new HttpError(400, 'Request body must be a JSON object.');
|
||||
}
|
||||
|
||||
return parsedBody;
|
||||
}
|
||||
|
||||
const BASEROW_FETCH_TIMEOUT_MS = 10000;
|
||||
|
||||
async function fetchWithTimeout(url, options) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), BASEROW_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new HttpError(504, 'Baserow request timed out after 10000 ms.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function callBaserow(options) {
|
||||
const requestUrl = createRowsUrl(
|
||||
options.baseUrl,
|
||||
options.tableId,
|
||||
options.rowId === undefined ? null : options.rowId,
|
||||
options.isListRequest === true
|
||||
);
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Token ' + options.token,
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (options.payload !== undefined && options.payload !== null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
requestOptions.body = JSON.stringify(options.payload);
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(requestUrl, requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await readJsonFromResponse(response);
|
||||
const message = extractErrorMessage(errorPayload, response.statusText || 'Request failed');
|
||||
throw new HttpError(response.status, 'Baserow request failed: ' + message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await readJsonFromResponse(response);
|
||||
}
|
||||
|
||||
function parseRowId(pathname, pattern, entityName) {
|
||||
const match = pathname.match(pattern);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowId = Number.parseInt(match[1], 10);
|
||||
if (!Number.isInteger(rowId) || rowId <= 0) {
|
||||
throw new HttpError(400, entityName + ' ID must be a positive integer.');
|
||||
}
|
||||
|
||||
return rowId;
|
||||
}
|
||||
|
||||
function ensureListResult(value, entityName) {
|
||||
if (!value || typeof value !== 'object' || !Array.isArray(value.results)) {
|
||||
throw new HttpError(502, 'Baserow list response for ' + entityName + ' is invalid.');
|
||||
}
|
||||
|
||||
return value.results;
|
||||
}
|
||||
|
||||
const proxyPort = ${toString cfg.proxyPort};
|
||||
const baserowUrl = parseBaseUrl(readRequiredEnv('BASEROW_URL'));
|
||||
const baserowToken = readRequiredEnv('BASEROW_TOKEN');
|
||||
const serversTableId = parsePositiveInteger(readRequiredEnv('SERVERS_TABLE_ID'), 'SERVERS_TABLE_ID');
|
||||
const processesTableId = parsePositiveInteger(readRequiredEnv('PROCESSES_TABLE_ID'), 'PROCESSES_TABLE_ID');
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const method = request.method || 'GET';
|
||||
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
|
||||
const pathname = requestUrl.pathname;
|
||||
|
||||
if (pathname === '/health') {
|
||||
if (method !== 'GET') {
|
||||
sendMethodNotAllowed(response, ['GET']);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/servers') {
|
||||
if (method === 'GET') {
|
||||
const listResponse = await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: serversTableId,
|
||||
method: 'GET',
|
||||
isListRequest: true,
|
||||
});
|
||||
|
||||
sendJson(response, 200, ensureListResult(listResponse, 'servers'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
const payload = await readJsonBody(request);
|
||||
const createdRow = await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: serversTableId,
|
||||
method: 'POST',
|
||||
payload,
|
||||
});
|
||||
|
||||
sendJson(response, 201, createdRow);
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, ['GET', 'POST']);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverRowId = parseRowId(pathname, /^\/servers\/(\d+)$/, 'Server');
|
||||
if (serverRowId !== null) {
|
||||
if (method !== 'DELETE') {
|
||||
sendMethodNotAllowed(response, ['DELETE']);
|
||||
return;
|
||||
}
|
||||
|
||||
await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: serversTableId,
|
||||
method: 'DELETE',
|
||||
rowId: serverRowId,
|
||||
});
|
||||
|
||||
sendNoContent(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/processes') {
|
||||
if (method === 'GET') {
|
||||
const listResponse = await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: processesTableId,
|
||||
method: 'GET',
|
||||
isListRequest: true,
|
||||
});
|
||||
|
||||
sendJson(response, 200, ensureListResult(listResponse, 'processes'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
const payload = await readJsonBody(request);
|
||||
const createdRow = await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: processesTableId,
|
||||
method: 'POST',
|
||||
payload,
|
||||
});
|
||||
|
||||
sendJson(response, 201, createdRow);
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, ['GET', 'POST']);
|
||||
return;
|
||||
}
|
||||
|
||||
const processRowId = parseRowId(pathname, /^\/processes\/(\d+)$/, 'Process');
|
||||
if (processRowId !== null) {
|
||||
if (method === 'PATCH') {
|
||||
const payload = await readJsonBody(request);
|
||||
const updatedRow = await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: processesTableId,
|
||||
method: 'PATCH',
|
||||
rowId: processRowId,
|
||||
payload,
|
||||
});
|
||||
|
||||
sendJson(response, 200, updatedRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
await callBaserow({
|
||||
baseUrl: baserowUrl,
|
||||
token: baserowToken,
|
||||
tableId: processesTableId,
|
||||
method: 'DELETE',
|
||||
rowId: processRowId,
|
||||
});
|
||||
|
||||
sendNoContent(response);
|
||||
return;
|
||||
}
|
||||
|
||||
sendMethodNotAllowed(response, ['PATCH', 'DELETE']);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: 'Not found.' });
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
handleRequest(request, response).catch((error) => {
|
||||
if (error instanceof HttpError) {
|
||||
sendJson(response, error.statusCode, { error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error('[azion-scheduler-proxy] Request failed:', error.message);
|
||||
sendJson(response, 500, { error: 'Internal server error.' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[azion-scheduler-proxy] Request failed with unknown error type.');
|
||||
sendJson(response, 500, { error: 'Internal server error.' });
|
||||
});
|
||||
});
|
||||
|
||||
server.on('clientError', (error, socket) => {
|
||||
console.error('[azion-scheduler-proxy] Client error:', error.message);
|
||||
socket.end('HTTP/1.1 400 Bad Request\\r\\n\\r\\n');
|
||||
});
|
||||
|
||||
server.listen(proxyPort, '127.0.0.1', () => {
|
||||
console.log('[azion-scheduler-proxy] Listening on 127.0.0.1:' + proxyPort);
|
||||
});
|
||||
'';
|
||||
in {
|
||||
options.services.azion-scheduler = {
|
||||
enable = lib.mkEnableOption "azion-scheduler web UI";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "The azion-scheduler package to serve.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3039;
|
||||
description = "Port where nginx listens locally.";
|
||||
};
|
||||
|
||||
proxyPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3049;
|
||||
description = "Port for the internal Baserow proxy service.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "File containing BASEROW_* runtime credentials and table IDs.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.services.azion-scheduler-proxy = {
|
||||
description = "AZion Scheduler Baserow proxy";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "nginx.service" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
ExecStart = "${pkgs.nodejs}/bin/node ${proxyScript}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 3;
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.nginx = {
|
||||
after = [ "azion-scheduler-proxy.service" ];
|
||||
wants = [ "azion-scheduler-proxy.service" ];
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
|
||||
virtualHosts."azion-scheduler" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "127.0.0.1";
|
||||
port = cfg.port;
|
||||
}
|
||||
];
|
||||
|
||||
locations."/" = {
|
||||
root = "${cfg.package}";
|
||||
tryFiles = "$uri $uri/ /index.html";
|
||||
};
|
||||
|
||||
locations."/api/" = {
|
||||
proxyPass = "http://127.0.0.1:${toString cfg.proxyPort}/";
|
||||
extraConfig = ''
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user