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
5 changes: 5 additions & 0 deletions app/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,10 @@ export const config = {
sshTarget: process.env.SSH_TARGET ?? 'ubuntu@localhost',
secretsKey: process.env.SECRETS_KEY || undefined,
graphiteHome: process.env.GRAPHITE_HOME ?? '/srv/graphite',
platformRepo: process.env.PLATFORM_REPO ?? 'project-graphite/platform',
platformApp:
process.env.PLATFORM_APP_CLIENT_ID && process.env.PLATFORM_APP_PRIVATE_KEY
? { clientId: process.env.PLATFORM_APP_CLIENT_ID, privateKey: process.env.PLATFORM_APP_PRIVATE_KEY.replaceAll('\\n', '\n') }
: undefined,
webRoot: new URL('../web/', import.meta.url),
};
35 changes: 31 additions & 4 deletions app/src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Refusal } from './refusal.ts';
import { appliedAt, appliedCompose, appliedValueNames, redeployStatus, startRedeploy } from './rollout.ts';
import { composeReferences, parseKeys, projectPattern, SecretStore } from './secrets.ts';
import { LogStore } from './logstore.ts';
import { release, setEnabled, setVersion } from './platform.ts';
import { hostShell, type ShellMode } from './shell.ts';
import { createVolume, downloadFile, listFiles, removeVolume } from './volumes.ts';
import { downloadLogs, logs, terminal } from './terminal.ts';
Expand Down Expand Up @@ -150,10 +151,18 @@ function secretStore() {
return store;
}

async function needsRedeploy(project: string, views: ContainerView[]) {
async function redeployReasons(project: string, views: ContainerView[]) {
const changed = store?.changedAt(project) ?? null;
const created = views.filter((view) => view.project === project).map((view) => view.created);
return changed !== null && created.length > 0 && changed > Math.max(...created, (await appliedAt(project)) ?? 0);
if (changed === null || !created.length) {
return [];
}
const rollout = Math.max(...created, (await appliedAt(project)) ?? 0);
if (changed <= rollout) {
return [];
}
const changes = store!.changesSince(project, rollout);
return changes.length ? changes : [{ name: `${project}.env`, action: 'edited on the host', login: 'host', at: changed }];
}

async function secretProject(project: string) {
Expand Down Expand Up @@ -316,7 +325,7 @@ async function api(user: User, request: http.IncomingMessage, url: URL) {
buildCache: usage.buildCache,
volumes: usage.volumes.reduce((sum, volume) => sum + Math.max(0, volume.size), 0),
},
projects: await Promise.all(list.map(async (project) => ({ ...project, needsRedeploy: await needsRedeploy(project.slug, project.containers) }))),
projects: await Promise.all(list.map(async (project) => ({ ...project, redeploy: await redeployReasons(project.slug, project.containers) }))),
};
}
const project = /^GET \/api\/projects\/([a-z0-9-]+)$/.exec(route);
Expand All @@ -328,7 +337,12 @@ async function api(user: User, request: http.IncomingMessage, url: URL) {
image.usedBy.push(container.service);
images.set(container.imageId, image);
}
return { ...found, images: [...images.values()], volumes: usage.volumes.filter((volume) => volume.project === found.slug) };
return {
...found,
images: [...images.values()],
volumes: usage.volumes.filter((volume) => volume.project === found.slug),
redeploy: await redeployReasons(found.slug, found.containers),
};
}
if (route === 'GET /api/history') {
const range = rangeOf(url);
Expand Down Expand Up @@ -416,6 +430,19 @@ async function api(user: User, request: http.IncomingMessage, url: URL) {
await refresh();
return { ok: true };
}
const releasing = /^(GET|POST) \/api\/projects\/([a-z0-9-]+)\/(release|enabled)$/.exec(route);
if (releasing) {
await requireOwnerNow(request);
if (releasing[1] === 'GET' && releasing[3] === 'release') {
return release(releasing[2]);
}
if (releasing[1] === 'POST') {
const input = (await body(request)) as { tag?: unknown; enabled?: unknown };
const url = releasing[3] === 'release' ? await setVersion(releasing[2], input.tag, user.login) : await setEnabled(releasing[2], input.enabled, user.login);
store?.audit(user.login, releasing[3] === 'release' ? `release ${String(input.tag)}` : input.enabled ? 'enable' : 'disable', releasing[2], null);
return { commit: url };
}
}
const redeploy = /^(GET|POST) \/api\/projects\/([a-z0-9-]+)\/redeploy$/.exec(route);
if (redeploy) {
if (redeploy[1] === 'GET') {
Expand Down
128 changes: 128 additions & 0 deletions app/src/server/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { createSign } from 'node:crypto';
import { config } from './config.ts';
import { Refusal } from './refusal.ts';

const tagPattern = /^sha-[0-9a-f]{7,40}$/;
const slugPattern = /^[a-z0-9-]+$/;
let cached: { token: string; until: number } | undefined;

function credentials() {
if (!config.platformApp) {
throw new Refusal(503, 'Releases need PLATFORM_APP_CLIENT_ID and PLATFORM_APP_PRIVATE_KEY in the console env file.');
}
return config.platformApp;
}

function appToken() {
const { clientId, privateKey } = credentials();
const now = Math.floor(Date.now() / 1000);
const part = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url');
const unsigned = `${part({ alg: 'RS256', typ: 'JWT' })}.${part({ iat: now - 60, exp: now + 540, iss: clientId })}`;
return `${unsigned}.${createSign('RSA-SHA256').update(unsigned).sign(privateKey, 'base64url')}`;
}

async function github<T>(path: string, token: string, method = 'GET', body?: unknown) {
const response = await fetch(`https://api.github.com${path}`, {
method,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'User-Agent': 'project-graphite-console',
'X-GitHub-Api-Version': '2022-11-28',
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new Refusal(502, `GitHub answered ${response.status} for ${path.split('?')[0]}: ${(await response.text()).slice(0, 200)}`);
}
return (await response.json()) as T;
}

async function token() {
if (cached && cached.until > Date.now() + 60_000) {
return cached.token;
}
const jwt = appToken();
const { id } = await github<{ id: number }>(`/repos/${config.platformRepo}/installation`, jwt);
const created = await github<{ token: string; expires_at: string }>(`/app/installations/${id}/access_tokens`, jwt, 'POST', {
repositories: [config.platformRepo.split('/')[1]],
permissions: { contents: 'write' },
});
cached = { token: created.token, until: Date.parse(created.expires_at) };
return created.token;
}

export function parseValues(text: string) {
return new Map(
text.split('\n').flatMap((line) => {
const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
return match ? [[match[1], match[2]] as [string, string]] : [];
}),
);
}

export function withValue(text: string, name: string, value: string | null) {
const lines = text.split('\n').filter((line) => line && !line.startsWith(`${name}=`));
return `${[...(value === null ? [] : [`${name}=${value}`]), ...lines].join('\n')}\n`;
}

export function deployedTags(commits: { commit: { message: string; author: { name: string; date: string } } }[]) {
const seen = new Set<string>();
return commits.flatMap(({ commit }) => {
const tag = /\bdeploy (sha-[0-9a-f]{7,40})\b/.exec(commit.message)?.[1];
if (!tag || seen.has(tag)) {
return [];
}
seen.add(tag);
return [{ tag, at: commit.author.date, by: /from the console by ([\w-]+)/.exec(commit.message)?.[1] ?? commit.author.name }];
});
}

async function valuesFile(slug: string, auth: string) {
if (!slugPattern.test(slug)) {
throw new Refusal(400, `${slug} is not a project.`);
}
const file = await github<{ content: string; sha: string }>(`/repos/${config.platformRepo}/contents/deploy/${slug}/values?ref=main`, auth);
return { text: Buffer.from(file.content, 'base64').toString('utf8'), sha: file.sha };
}

export async function release(slug: string) {
const auth = await token();
const [{ text }, commits] = await Promise.all([
valuesFile(slug, auth),
github<{ commit: { message: string; author: { name: string; date: string } } }[]>(`/repos/${config.platformRepo}/commits?path=deploy/${slug}/values&per_page=40`, auth),
]);
const values = parseValues(text);
return { current: values.get('IMAGE_TAG') ?? null, enabled: values.get('ENABLED') !== 'false', history: deployedTags(commits) };
}

async function commit(slug: string, name: string, value: string | null, message: string) {
const auth = await token();
const { text, sha } = await valuesFile(slug, auth);
const next = withValue(text, name, value);
if (next === text) {
return null;
}
const result = await github<{ commit: { html_url: string } }>(`/repos/${config.platformRepo}/contents/deploy/${slug}/values`, auth, 'PUT', {
message,
content: Buffer.from(next).toString('base64'),
sha,
branch: 'main',
});
return result.commit.html_url;
}

export function setVersion(slug: string, tag: unknown, login: string) {
if (typeof tag !== 'string' || !tagPattern.test(tag)) {
throw new Refusal(400, 'A version is a sha- tag.');
}
return commit(slug, 'IMAGE_TAG', tag, `chore(${slug}): deploy ${tag} from the console by ${login}`);
}

export function setEnabled(slug: string, enabled: unknown, login: string) {
if (typeof enabled !== 'boolean') {
throw new Refusal(400, 'Say whether the project is enabled.');
}
return commit(slug, 'ENABLED', enabled ? null : 'false', `chore(${slug}): ${enabled ? 'enable' : 'disable'} from the console by ${login}`);
}
12 changes: 10 additions & 2 deletions app/src/server/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ export class SecretStore {
};
}

changesSince(project: string, since: number) {
const rows = this.db
.prepare("SELECT name, action, login, at FROM secret_audit WHERE project = ? AND at > ? AND name IS NOT NULL AND action IN ('create', 'update', 'restore', 'delete', 'reconcile') ORDER BY id")
.all(project, since) as { name: string; action: string; login: string; at: number }[];
return [...new Map(rows.map((row) => [row.name, row])).values()];
}

changedAt(project: string) {
return statSync(path.join(this.hostDirectory, `${project}.env`), { throwIfNoEntry: false })?.mtimeMs ?? null;
}
Expand Down Expand Up @@ -402,10 +409,11 @@ export class SecretStore {
this.db
.prepare('UPDATE secrets SET description = ?, type = ?, updated_by = ?, updated_at = ? WHERE project = ? AND name = ?')
.run(description, type, login, at, project, name);
if (text !== undefined && text !== this.value(project, name)) {
const changed = text !== undefined && text !== this.value(project, name);
if (changed) {
this.addVersion(project, name, text, login, at);
}
this.audit(login, 'update', project, name, at);
this.audit(login, changed ? 'update' : 'describe', project, name, at);
});
}

Expand Down
21 changes: 15 additions & 6 deletions app/src/server/volumes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs';
import { lstat, readdir, realpath, stat } from 'node:fs/promises';
import { lstat, readdir, stat } from 'node:fs/promises';
import type { ServerResponse } from 'node:http';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
Expand All @@ -17,15 +17,24 @@ function check(name: string) {

export async function inside(name: string, relative: string, volumes = path.join(config.hostRoot, 'var/lib/docker/volumes')) {
check(name);
const root = await realpath(path.join(volumes, name, '_data')).catch(() => {
const root = path.join(volumes, name, '_data');
if (!(await lstat(root).catch(() => undefined))?.isDirectory()) {
throw new Refusal(404, `There is no volume ${name}.`);
});
const target = await realpath(path.join(root, relative)).catch(() => {
throw new Refusal(404, `There is no ${relative} in ${name}.`);
});
}
const target = path.resolve(root, relative);
if (target !== root && !target.startsWith(root + path.sep)) {
throw new Refusal(403, 'That path leads out of the volume.');
}
let current = root;
for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
const info = await lstat(current).catch(() => {
throw new Refusal(404, `There is no ${relative} in ${name}.`);
});
if (info.isSymbolicLink()) {
throw new Refusal(403, 'That path leads out of the volume.');
}
}
return target;
}

Expand Down
24 changes: 23 additions & 1 deletion app/src/web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface Volume {
export interface ProjectDetail extends Project {
images: { id: string; name: string; size: number | null; usedBy: string[] }[];
volumes: Volume[];
redeploy: RedeployReason[];
}

export interface Usage {
Expand All @@ -72,10 +73,31 @@ export interface Host {
swap: Usage;
}

export interface RedeployReason {
name: string;
action: string;
login: string;
at: number;
}

const changeWords: Record<string, string> = { create: 'added', update: 'changed', restore: 'restored', delete: 'removed', reconcile: 'edited on the host' };

export function describeChange(reason: RedeployReason) {
const minutes = Math.max(1, Math.round((Date.now() - reason.at) / 60_000));
const ago = minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / 1440)} days ago`;
return `${reason.name} ${changeWords[reason.action] ?? reason.action}${reason.login === 'host' ? '' : ` by ${reason.login}`}, ${ago}`;
}

export interface Release {
current: string | null;
enabled: boolean;
history: { tag: string; at: string; by: string }[];
}

export interface Overview {
host: Host | null;
docker: { images: number; containers: number; buildCache: number; volumes: number } | null;
projects: (Project & { needsRedeploy: boolean })[];
projects: (Project & { redeploy: RedeployReason[] })[];
backups: { projects: number; verified: number; latest: string | null };
}

Expand Down
18 changes: 18 additions & 0 deletions app/src/web/components/Collapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import ButtonBase from '@mui/material/ButtonBase';
import { Icon } from '@project-graphite/ui';
import type { ReactNode } from 'react';

export function Collapsible({ title, open, onToggle, adornment, children }: { title: ReactNode; open: boolean; onToggle: (open: boolean) => void; adornment?: ReactNode; children: ReactNode }) {
return (
<div className="rounded-xl border border-line bg-surface">
<ButtonBase aria-expanded={open} className="flex w-full items-center justify-start gap-2 px-3.5 py-2.5 text-left hover:bg-line-soft" onClick={() => onToggle(!open)}>
<span className={`transition-transform ${open ? 'rotate-90' : ''}`}>
<Icon name="chevron-right" size={14} />
</span>
<span className="mono-sm truncate">{title}</span>
{adornment}
</ButtonBase>
{open && <div className="border-t border-line">{children}</div>}
</div>
);
}
20 changes: 9 additions & 11 deletions app/src/web/components/Logs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import ButtonBase from '@mui/material/ButtonBase';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import ListSubheader from '@mui/material/ListSubheader';
Expand All @@ -9,6 +8,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import { Icon, Popover, TagChip } from '@project-graphite/ui';
import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { socketUrl } from '../api';
import { Collapsible } from './Collapsible';
import { entryDetail, formatTime, levelOf, levels, segments, type Level } from '../logs';
import { usePreference } from '../stored';

Expand Down Expand Up @@ -513,16 +513,14 @@ export function Logs({ collapsible = false, container, name, open = true, runnin
return (
<div data-logs={fullscreen ? 'fullscreen' : expanded ? 'expanded' : 'collapsed'} ref={root}>
{collapsible ? (
<div className="rounded-xl border border-line bg-surface">
<ButtonBase aria-expanded={expanded} className="flex w-full items-center justify-start gap-2 px-3.5 py-2.5 text-left hover:bg-line-soft" onClick={() => setExpanded(!expanded)}>
<span className={`transition-transform ${expanded ? 'rotate-90' : ''}`}>
<Icon name="chevron-right" size={14} />
</span>
<span className="mono-sm truncate">{name}</span>
{expanded && status === 'live' && <span className="size-1.5 animate-pulse rounded-full bg-live" />}
</ButtonBase>
{expanded && <div className="border-t border-line">{panel}</div>}
</div>
<Collapsible
adornment={expanded && status === 'live' && <span className="size-1.5 animate-pulse rounded-full bg-live" />}
onToggle={setExpanded}
open={expanded}
title={name}
>
{panel}
</Collapsible>
) : (
panel
)}
Expand Down
4 changes: 2 additions & 2 deletions app/src/web/components/Metrics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { bytes, percent, rate, ranges, type HistoryData, type Metric, type Series } from '../api';
import { containerPoint, hostPoint, liveSeconds, useLive } from '../live';
import { containerPoint, hostPoint, liveSeconds, projectSeries, useLive } from '../live';
import { load, save } from '../stored';
import { usePoll } from '../usePoll';
import { Chart } from './Chart';
Expand All @@ -26,7 +26,7 @@ export function Metrics({ query, live, cpuMax, memoryMax }: { query: string; liv
: live.map(({ name, label }) => ({ name: label, points: frames.flatMap((frame) => containerPoint(frame, name) ?? []) }));
const chart =
range === 'live'
? { series: liveSeries, span: liveSeconds * 1000, step: 1000, now: frames.at(-1)?.at ?? Date.now() }
? { series: liveSeries, breakdown: live === 'host' ? projectSeries(frames) : undefined, span: liveSeconds * 1000, step: 1000, now: frames.at(-1)?.at ?? Date.now() }
: data && { series: data.series, breakdown: data.breakdown, span: data.span, step: data.step, now: Date.now() };
const peak = (metric: Metric): Metric => (peaks && range !== 'live' ? (metric === 'cpu' ? 'cpuPeak' : 'memoryPeak') : metric);

Expand Down
Loading
Loading