feat(git-sources): node-scoped polling settings endpoint

This commit is contained in:
Anso
2026-09-09 14:47:15 -04:00
parent 466f7d5ca4
commit 583b577bd9
4 changed files with 298 additions and 2 deletions
@@ -2526,3 +2526,162 @@ describe('git-source policy compatibility', () => {
}
});
});
describe('git-source polling settings', () => {
// A real viewer row, not just a signed token: the auth middleware resolves
// users against the database, so a token for a username with no row is
// rejected 401 before the node:manage gate ever runs.
let pollingViewerToken: string;
beforeAll(async () => {
DatabaseService.getInstance().addUser({
username: 'polling-viewer',
password_hash: 'test',
role: 'viewer',
});
pollingViewerToken = jwt.sign(
{ username: 'polling-viewer', role: 'viewer' },
TEST_JWT_SECRET,
{ expiresIn: '1m' },
);
});
function seedPollApp(stackName: string, policy: 'manual' | 'review' | 'automatic'): void {
const row = directApplicationFixture(`poll-app-${stackName}`, stackName);
row.source_policy = policy;
GitOpsStore.getInstance().insertApplication(row);
}
function deletePollRows(stackName: string): void {
DatabaseService.getInstance().getDb()
.prepare('DELETE FROM gitops_applications WHERE id = ?')
.run(`poll-app-${stackName}`);
}
it('GET requires node:manage', async () => {
const res = await request(app)
.get('/api/git-sources/polling')
.set('Authorization', `Bearer ${pollingViewerToken}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('GET returns the global interval and per-source rows', async () => {
const stackName = 'polling-get-row';
seedPollApp(stackName, 'review');
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '7');
try {
const res = await request(app)
.get('/api/git-sources/polling')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(res.body.poll_interval_mins).toBe(7);
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
expect(row).toBeDefined();
expect(row.source_policy).toBe('review');
expect(row.poll_interval_secs).toBeNull();
expect(row.next_poll_at).toBeNull();
} finally {
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
deletePollRows(stackName);
}
});
it('PATCH rejects non-integer, negative, and oversized values with 400', async () => {
for (const value of [-1, 0.5, 10081, 'five', true, null]) {
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ poll_interval_mins: value });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/poll_interval_mins/i);
}
});
it('PATCH requires node:manage', async () => {
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${pollingViewerToken}`)
.send({ poll_interval_mins: 5 });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
});
it('PATCH stores the value and recomputes next_poll_at', async () => {
const stackName = 'polling-patch-auto';
seedPollApp(stackName, 'automatic');
const before = Date.now();
try {
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ poll_interval_mins: 5 });
expect(res.status).toBe(200);
expect(res.body.poll_interval_mins).toBe(5);
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
// Automatic sources get a cursor at now + 300s; review sources
// are re-armed on the same cadence, and only manual is left off
// the unattended schedule.
expect(row.next_poll_at).toBeGreaterThanOrEqual(before + 300_000);
expect(row.next_poll_at).toBeLessThanOrEqual(Date.now() + 300_000);
const stored = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`);
expect(stored?.next_poll_at).toBe(row.next_poll_at);
} finally {
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
deletePollRows(stackName);
}
});
it('PATCH re-arms review sources on the new cadence and skips manual', async () => {
const reviewStack = 'polling-patch-review';
const manualStack = 'polling-patch-manual';
seedPollApp(reviewStack, 'review');
seedPollApp(manualStack, 'manual');
const staleCursor = 123456;
for (const stackName of [reviewStack, manualStack]) {
DatabaseService.getInstance().getDb()
.prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?')
.run(staleCursor, `poll-app-${stackName}`);
}
try {
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ poll_interval_mins: 5 });
expect(res.status).toBe(200);
const reviewRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === reviewStack);
const manualRow = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === manualStack);
// Review rides the unattended cadence like automatic; manual keeps
// its stale cursor because rescheduleAll never arms it.
expect(reviewRow.next_poll_at).toBeGreaterThan(staleCursor);
expect(manualRow.next_poll_at).toBe(staleCursor);
} finally {
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
deletePollRows(reviewStack);
deletePollRows(manualStack);
}
});
it('PATCH 0 leaves existing next_poll_at values alone (off means off)', async () => {
const stackName = 'polling-patch-off';
seedPollApp(stackName, 'automatic');
const existing = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`)!;
existing.next_poll_at = 123456;
DatabaseService.getInstance().getDb()
.prepare('UPDATE gitops_applications SET next_poll_at = ? WHERE id = ?')
.run(123456, existing.id);
try {
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ poll_interval_mins: 0 });
expect(res.status).toBe(200);
expect(res.body.poll_interval_mins).toBe(0);
const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName);
expect(row.next_poll_at).toBe(123456);
} finally {
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0');
deletePollRows(stackName);
}
});
});
@@ -341,3 +341,51 @@ describe('remote proxy scoped node-admin settings writes', () => {
db.deleteRoleAssignmentsByUser(viewerId);
});
});
describe('remote proxy scoped node-admin git-source polling writes', () => {
it('forwards polling PATCH from scoped node-admin on granted node with elevated role header', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(grantedNodeId),
});
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(grantedNodeId))
.send({ poll_interval_mins: 15 });
expect(res.status).toBe(200);
const hop = grantedHops.find((h) => h.url?.includes('/git-sources/polling'));
expect(hop).toBeDefined();
expect(hop!.method).toBe('PATCH');
expect(hop!.roleHeader).toBe('node-admin');
db.deleteRoleAssignmentsByUser(viewerId);
});
it('denies polling PATCH from scoped node-admin on ungranted remote node', async () => {
const db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
db.addRoleAssignment({
user_id: viewerId,
role: 'node-admin',
resource_type: 'node',
resource_id: String(grantedNodeId),
});
const res = await request(app)
.patch('/api/git-sources/polling')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(ungrantedNodeId))
.send({ poll_interval_mins: 15 });
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
expect(ungrantedHops).toHaveLength(0);
db.deleteRoleAssignmentsByUser(viewerId);
});
});
+9 -2
View File
@@ -832,10 +832,17 @@ export function createRemoteProxyMiddleware(): RequestHandler {
/** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */
const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024;
/** True when the request is a settings write destined for a remote node (path is post-/api strip). */
/**
* True when the request configures a remote node's own settings and must run
* the node-admin elevation check on the hop (path is post-/api strip).
*/
function isSettingsWrite(req: Request): boolean {
if (req.method !== 'POST' && req.method !== 'PATCH') return false;
return /^\/settings\/?$/.test(req.path);
if (/^\/settings\/?$/.test(req.path)) return true;
// Polling cadence is configured per instance, so the hub PATCH must reach
// the target node's own route; classify it like a settings write so the
// same node-admin elevation check applies before the hop.
return /^\/git-sources\/polling\/?$/.test(req.path);
}
/**
+82
View File
@@ -1,5 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { GitSourceService, type PublicGitSource, type SourcePolicy } from '../services/GitSourceService';
import { GitOpsStore } from '../services/gitops/store';
import { SourceController } from '../services/gitops/SourceController';
import type { GitOpsRevisionProjection } from '../services/gitops/types';
import { GitProjectManifestService } from '../services/GitProjectManifestService';
import { FileSystemService } from '../services/FileSystemService';
@@ -244,6 +246,86 @@ gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise<vo
await handleBrowse(req, res, null, null, null, null);
});
/**
* Strict parser for the global poll interval (minutes, 0..10080). Accepts
* integer numbers or digit strings only; rejects null, booleans, decimals,
* whitespace, and out-of-range values without coercion, so a malformed value
* cannot silently disable polling the operator meant to arm.
*/
export function parsePollIntervalMins(raw: unknown): number | null {
if (typeof raw === 'number') {
if (!Number.isInteger(raw) || raw < 0 || raw > 10080) return null;
return raw;
}
if (typeof raw === 'string') {
if (!/^\d{1,5}$/.test(raw)) return null;
const value = Number(raw);
return value <= 10080 ? value : null;
}
return null;
}
function requireNodeManage(req: Request, res: Response): boolean {
if (typeof req.nodeId === 'number') {
return requirePermission(req, res, 'node:manage', 'node', String(req.nodeId));
}
return requirePermission(req, res, 'node:manage');
}
function pollingSettingsPayload(): {
poll_interval_mins: number;
per_source: Array<{
stack_name: string;
poll_interval_secs: number | null;
next_poll_at: number | null;
source_policy: string;
}>;
} {
return {
poll_interval_mins: DatabaseService.getInstance().getGitOpsPollIntervalMins(),
per_source: GitOpsStore.getInstance().listActiveDirectApplications().map((app) => ({
stack_name: app.stack_name ?? '',
poll_interval_secs: app.poll_interval_secs,
next_poll_at: app.next_poll_at,
source_policy: app.source_policy,
})),
};
}
/**
* Node-scoped polling configuration. The GET projects the global interval and
* each live source's own cadence; the PATCH stores the interval and
* reschedules every non-manual source, both gated by node:manage because the
* cadence governs unattended fetches against the node.
*/
gitSourcesRouter.get('/polling', async (req: Request, res: Response): Promise<void> => {
if (!requireNodeManage(req, res)) return;
try {
res.json(pollingSettingsPayload());
} catch (error) {
console.error('[GitSources] polling settings read failed:', error instanceof Error ? error.message : String(error));
res.status(500).json({ error: 'Could not read polling settings.' });
}
});
gitSourcesRouter.patch('/polling', async (req: Request, res: Response): Promise<void> => {
if (!requireNodeManage(req, res)) return;
const value = parsePollIntervalMins((req.body ?? {}).poll_interval_mins);
if (value === null) {
res.status(400).json({ error: 'poll_interval_mins must be an integer between 0 and 10080' });
return;
}
try {
DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', String(value));
SourceController.getInstance().rescheduleAll('system:git-source');
SourceController.getInstance().restartPolling();
res.json(pollingSettingsPayload());
} catch (error) {
console.error('[GitSources] polling settings write failed:', error instanceof Error ? error.message : String(error));
res.status(500).json({ error: 'Could not save polling settings.' });
}
});
/**
* Router for per-stack git-source endpoints. Mount at `/api/stacks` so the
* `/:stackName/git-source*` paths work alongside other stack-scoped routes