Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Release Packages

# Triggered by publishing a GitHub release (full or prerelease). Builds the
# three Debian packages and uploads them to the release that triggered the
# four Debian packages and uploads them to the release that triggered the
# workflow, together with flat APT repository metadata (Packages, Packages.gz)
# so the release can be used directly as an apt source:
#
Expand Down Expand Up @@ -50,7 +50,7 @@ jobs:
gzip -k9f Packages
# Stable-name aliases for one-off downloads (the _latest URLs resolve
# via releases/latest/download for the newest non-prerelease release).
for pkg in opensource-server opensource-docs opensource-agent; do
for pkg in opensource-server opensource-docs opensource-agent opensource-mcp; do
f=$(ls ${pkg}_*.deb | head -1)
[ -n "$f" ] && cp -f "$f" "${pkg}_latest.deb"
done
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

COMPONENTS := agent mie-opensource-landing create-a-container
COMPONENTS := agent mie-opensource-landing manager-control-program create-a-container
PACKAGER ?= deb

# Forwarded to every component Makefile.
Expand Down
1 change: 1 addition & 0 deletions agent/contrib/systemd/opensource-agent.service
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ After=network-online.target environment.service
[Service]
Type=oneshot
# Runtime config (SITE_ID, MANAGER_URL, API_KEY) lives in /etc/environment.
# Optional LOG_LEVEL (error|warn|info|debug; default info) also read from there.
EnvironmentFile=-/etc/environment
# systemd creates /var/lib/opensource-agent and passes it as $STATE_DIRECTORY.
StateDirectory=opensource-agent
Expand Down
34 changes: 27 additions & 7 deletions agent/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { AgentConfig } from './config';
import type { CheckinRequest, SiteConfig } from './types';
import { log } from './log';

export type CheckinResult =
| { notModified: true }
Expand All @@ -20,15 +21,34 @@ export async function checkin(
if (etag) headers['If-None-Match'] = etag;
if (cfg.apiKey) headers['Authorization'] = `Bearer ${cfg.apiKey}`;

const res = await fetch(`${cfg.managerUrl}/api/v1/agents`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(CHECKIN_TIMEOUT_MS),
});
const url = `${cfg.managerUrl}/api/v1/agents`;
log.debug(`check-in POST ${url}${etag ? ` (If-None-Match: ${etag})` : ''}${cfg.apiKey ? ' with API key' : ''}`);

let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(CHECKIN_TIMEOUT_MS),
});
} catch (err) {
// fetch rejects on network errors and on the abort timeout; distinguish
// the timeout so a stalled manager is obvious in the logs.
const reason =
(err as Error)?.name === 'TimeoutError'
? `no response within ${CHECKIN_TIMEOUT_MS} ms`
: (err as Error)?.message ?? String(err);
log.error(`check-in request to ${url} failed: ${reason}`);
throw err;
}

log.debug(`check-in response: HTTP ${res.status}`);
if (res.status === 304) return { notModified: true };
if (!res.ok) throw new Error(`Check-in failed: HTTP ${res.status}`);
if (!res.ok) {
log.error(`check-in rejected by manager: HTTP ${res.status}`);
throw new Error(`Check-in failed: HTTP ${res.status}`);
}

const { data } = (await res.json()) as { data: SiteConfig };
return {
Expand Down
48 changes: 39 additions & 9 deletions agent/src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import path from 'path';
import { execFileSync } from 'child_process';
import ejs from 'ejs';
import { reloadOrRestartService, restartService, sighupService } from './system';
import { log, commandOutput } from './log';
import type { ApplyResult, SiteConfig } from './types';

const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
Expand All @@ -36,8 +37,12 @@ function renderTemplate(template: string, data: object): Promise<string> {
return ejs.renderFile(path.join(TEMPLATES_DIR, template), data);
}

function run(cmd: string[]): void {
execFileSync(cmd[0], cmd.slice(1), { stdio: 'pipe' });
// Run a command, capturing its output. execFileSync with stdio 'pipe' attaches
// the captured stdout/stderr to the thrown error on failure (see commandOutput
// in log.ts), so callers can surface e.g. exactly why `nginx -t` rejected a
// config. Returns the combined stdout for successful runs.
function run(cmd: string[]): string {
return execFileSync(cmd[0], cmd.slice(1), { stdio: 'pipe', encoding: 'utf8' });
}

export const services: ManagedService[] = [
Expand Down Expand Up @@ -102,48 +107,73 @@ function writeFileAtomic(dest: string, content: string): void {
}

export async function applyService(svc: ManagedService, config: SiteConfig): Promise<ApplyResult> {
log.debug(`${svc.unit}: rendering config`);
const files = await svc.render(config);
if (!files) return 'success';
if (!files) {
log.debug(`${svc.unit}: nothing to manage yet, skipping`);
return 'success';
}

const current = new Map(files.map((f) => [f.dest, readIfExists(f.dest)]));
const changed = files.filter((f) => current.get(f.dest) !== f.content).map((f) => f.dest);
if (changed.length === 0) return 'success';
if (changed.length === 0) {
log.debug(`${svc.unit}: config unchanged, nothing to do`);
return 'success';
}

log.info(`${svc.unit}: ${changed.length} file(s) changed, applying: ${changed.join(', ')}`);

// Stage the new files (previous contents kept in memory for rollback).
for (const f of files) {
fs.mkdirSync(path.dirname(f.dest), { recursive: true });
writeFileAtomic(f.dest, f.content);
log.debug(`${svc.unit}: wrote ${f.dest}`);
}

const rollback = () => {
for (const [dest, prev] of current) {
if (prev === null) fs.rmSync(dest, { force: true });
else writeFileAtomic(dest, prev);
}
log.debug(`${svc.unit}: rolled back to previous config`);
};

if (svc.test) {
const testCmd = svc.test.join(' ');
log.debug(`${svc.unit}: testing config with \`${testCmd}\``);
try {
run(svc.test);
const out = run(svc.test);
const trimmed = out.trim();
if (trimmed) log.debug(`${svc.unit}: config test output:\n${trimmed}`);
log.info(`${svc.unit}: config test passed`);
} catch (err) {
rollback();
console.error(`${svc.unit}: config test failed, rolled back: ${(err as Error).message}`);
// Surface the actual validator output (e.g. the nginx line/column that
// was rejected) — that's the whole point of running the test, and it's
// otherwise lost since the command's stdio is piped.
const output = commandOutput(err);
log.error(`${svc.unit}: config test failed (\`${testCmd}\`), rolled back: ${(err as Error).message}`);
if (output) log.error(`${svc.unit}: config test output:\n${output}`);
return 'failure';
}
}

try {
log.debug(`${svc.unit}: reloading service`);
await svc.reload(changed);
log.info(`${svc.unit}: reloaded`);
} catch (err) {
// The new config passed its test but the service couldn't pick it up:
// restore the previous files and reload again (best effort) so the
// service keeps running the last known-good config.
rollback();
await svc.reload(changed).catch(() => { /* reported via service state at next check-in */ });
console.error(`${svc.unit}: reload failed, rolled back: ${(err as Error).message}`);
await svc.reload(changed).catch((reErr) => {
log.error(`${svc.unit}: reload after rollback also failed: ${(reErr as Error).message}`);
});
log.error(`${svc.unit}: reload failed, rolled back: ${(err as Error).message}`);
return 'failure';
}

console.log(`${svc.unit}: applied ${changed.length} file(s)`);
log.info(`${svc.unit}: applied ${changed.length} file(s)`);
return 'success';
}
6 changes: 6 additions & 0 deletions agent/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/** Agent configuration, read from the environment (systemd passes
* /etc/environment through EnvironmentFile=). */

import { setLogLevel, type LogLevel } from './log';

export interface AgentConfig {
siteId: number;
managerUrl: string;
apiKey?: string;
stateDir: string;
logLevel: LogLevel;
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig {
// Set the log level first so any warnings below (and from callers) honor it.
const logLevel = setLogLevel(env.LOG_LEVEL);
const siteId = parseInt(env.SITE_ID ?? '', 10);
const managerUrl = env.MANAGER_URL;
if (!Number.isInteger(siteId) || !managerUrl) {
Expand All @@ -20,5 +25,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AgentConfig {
apiKey: env.API_KEY || undefined,
// Set by systemd from StateDirectory=; fallback for manual runs.
stateDir: env.STATE_DIRECTORY || '/var/lib/opensource-agent',
logLevel,
};
}
15 changes: 13 additions & 2 deletions agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { State } from './state';
import { getPrimaryIpv4, getServiceState, disconnectSystemBus } from './system';
import { checkin } from './api';
import { services, applyService } from './apply';
import { log } from './log';
import type { CheckinRequest, ServiceStatus } from './types';

// Safety cap: a flapping server-side config can't keep a single run alive
Expand All @@ -42,11 +43,18 @@ async function buildCheckinBody(cfg: AgentConfig, state: State): Promise<Checkin
async function main(): Promise<void> {
const cfg = loadConfig();
const state = State.load(cfg.stateDir);
log.info(`agent starting: siteId=${cfg.siteId}, manager=${cfg.managerUrl}`);
log.debug(`state dir=${cfg.stateDir}, saved etag=${state.etag ?? '(none)'}`);

for (let pass = 0; pass < MAX_PASSES; pass++) {
log.debug(`check-in pass ${pass + 1}/${MAX_PASSES}`);
const result = await checkin(cfg, await buildCheckinBody(cfg, state), state.etag);
if (result.notModified) return;
if (result.notModified) {
log.info('check-in: config unchanged (304), nothing to apply');
return;
}

log.info(`check-in: new config received (etag=${result.etag ?? '(none)'}), applying`);
for (const svc of services) {
state.lastApply[svc.unit] = await applyService(svc, result.config);
}
Expand All @@ -61,12 +69,15 @@ async function main(): Promise<void> {
// MAX_PASSES exhausted (flapping server-side config): check in once more so
// the final pass' apply results reach the manager instead of going stale
// until the next timer run.
log.warn(`reached MAX_PASSES (${MAX_PASSES}) without a stable config; reporting final results`);
await checkin(cfg, await buildCheckinBody(cfg, state), state.etag);
}

main()
.then(() => disconnectSystemBus())
.catch((err) => {
console.error(err instanceof Error ? err.message : err);
// Log the full error (stack included when present) so a failed run is
// diagnosable from the journal, not just a one-line message.
log.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
process.exit(1);
});
74 changes: 74 additions & 0 deletions agent/src/log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Minimal leveled logger for the agent.
*
* The agent runs as a systemd oneshot, so everything written to stdout/stderr
* lands in the journal (view with `journalctl -u opensource-agent`). We keep a
* tiny hand-rolled logger rather than pull in a dependency: levels gate what
* gets emitted, error/warn go to stderr and info/debug to stdout, and each
* line is prefixed with its level so journal output is greppable.
*
* The active level comes from LOG_LEVEL (case-insensitive; default "info").
* An unknown value falls back to "info" with a warning so a typo can't
* silently mute the agent.
*/

export type LogLevel = 'error' | 'warn' | 'info' | 'debug';

const LEVELS: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};

let activeLevel: LogLevel = 'info';

/**
* Set the active level from a raw env string. Returns the level actually
* applied. Call once at startup from loadConfig(); defaults to "info".
*/
export function setLogLevel(raw: string | undefined): LogLevel {
if (!raw) {
activeLevel = 'info';
return activeLevel;
}
const normalized = raw.trim().toLowerCase();
if (normalized in LEVELS) {
activeLevel = normalized as LogLevel;
} else {
activeLevel = 'info';
log.warn(`Unknown LOG_LEVEL "${raw}", defaulting to "info"`);
}
return activeLevel;
}

function emit(level: LogLevel, message: string): void {
if (LEVELS[level] > LEVELS[activeLevel]) return;
const line = `[${level}] ${message}`;
if (level === 'error' || level === 'warn') {
console.error(line);
} else {
console.log(line);
}
}

export const log = {
error: (message: string) => emit('error', message),
warn: (message: string) => emit('warn', message),
info: (message: string) => emit('info', message),
debug: (message: string) => emit('debug', message),
};

/**
* Format an error's command output (execFileSync attaches captured stdout /
* stderr to the thrown error when stdio is 'pipe'). Returns a trimmed,
* human-readable blob or null when there is nothing captured — used to surface
* the actual reason a command like `nginx -t` rejected a config.
*/
export function commandOutput(err: unknown): string | null {
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
const parts = [e?.stdout, e?.stderr]
.map((part) => (part == null ? '' : part.toString()).trim())
.filter((part) => part.length > 0);
return parts.length > 0 ? parts.join('\n') : null;
}
3 changes: 2 additions & 1 deletion agent/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import fs from 'fs';
import path from 'path';
import { log } from './log';
import type { ApplyResult } from './types';

export class State {
Expand All @@ -27,7 +28,7 @@ export class State {
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
// A corrupt state file just means a full re-apply on this run.
console.error(`Ignoring unparsable state file ${state.file}: ${err.message}`);
log.warn(`Ignoring unparsable state file ${state.file}: ${err.message}`);
}
return state;
}
Expand Down
21 changes: 21 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ services:
retries: 60
start_period: 5s

# Assembles the MCP server's runtime tree (the app package plus all vendored
# Python dependencies) into manager-control-program/build via `make build`.
# The opensource-mcp.service inside Proxmox runs `python3 -m
# manager_control_program.server` with PYTHONPATH=/opt/opensource-server/
# manager-control-program/build, but the repo is mounted read-only there and
# the vendored deps aren't checked in — so, like the `node`/`client`
# services, this host-side step produces the build output first (repo mounted
# read-write); the read-only bind mount inside Proxmox then sees it at the
# same path the package installs to. Runs once and exits.
mcp:
image: astral/uv:0.11.14-trixie
volumes:
- ./:/opt/opensource-server
working_dir: /opt/opensource-server
entrypoint: ["/bin/sh", "-c"]
command:
- |
make -C manager-control-program build

# This service will watch the documentation for any changes and rebuild when
# needed. Technically it's listening on port 8000, but we only care about the
# HTML being placed in ./mie-opensource-landing/site which is shared with the
Expand Down Expand Up @@ -136,6 +155,8 @@ services:
condition: service_completed_successfully
node:
condition: service_completed_successfully
mcp:
condition: service_completed_successfully
client:
condition: service_healthy

Expand Down
1 change: 1 addition & 0 deletions create-a-container/.fpm
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
--description "MIE opensource-server cluster manager (create-a-container): web UI and REST API for self-service LXC container hosting on Proxmox VE, with automated DNS and reverse-proxy configuration, LDAP authentication and ACME TLS orchestration."
--depends opensource-agent
--depends opensource-docs
--depends opensource-mcp
--depends nodejs
--depends sudo
--depends libc6
Expand Down
Loading
Loading