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:
2026-04-30 07:38:01 +02:00
parent f22a25ce46
commit 8770e60c93
6 changed files with 631 additions and 0 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

5
.gitignore vendored
View File

@@ -2,3 +2,8 @@ node_modules
dist dist
.env .env
*.local *.local
.worktrees/
.p*
result
docs/
.direnv/

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1777077449,
"narHash": "sha256-AIiMJiqvGrN4HyLEbKAoCSRRYn0rnlW5VbKNIMIYqm4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a4bf06618f0b5ee50f14ed8f0da77d34ecc19160",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

71
flake.nix Normal file
View File

@@ -0,0 +1,71 @@
{
description = "AZion Scheduler - React SPA for scheduling tasks";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
};
outputs = {
self,
nixpkgs,
}: let
systems = [
"aarch64-linux"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
nixosModules.default = import ./nixos;
devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.mkShell {
name = "azion-scheduler-dev";
buildInputs = with pkgs; [
nodejs_22
nodePackages.typescript
];
shellHook = ''
echo "AZion Scheduler Development Environment"
echo ""
echo "Available commands:"
echo " npm run dev - Start development server"
echo " npm run build - Build for production"
echo " npm run preview - Preview production build"
echo ""
echo "Node version: $(node --version)"
echo "NPM version: $(npm --version)"
echo "=========================================="
'';
};
});
packages = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.buildNpmPackage {
pname = "azion-scheduler";
version = "2.0.0";
src = self;
npmDepsHash = "sha256-/4YWme2q0MSQkhdAfDJwNP/qxCBtNC/FNTi41Uejdy0=";
installPhase = ''
runHook preInstall
cp -r dist $out
runHook postInstall
'';
meta = with pkgs.lib; {
description = "AZion Scheduler web UI";
platforms = platforms.linux;
};
};
});
};
}

5
nixos/default.nix Normal file
View File

@@ -0,0 +1,5 @@
{
imports = [
./module.nix
];
}

522
nixos/module.nix Normal file
View 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;
'';
};
};
};
};
}