feat(gitops): config-as-code via .tinyforge.yml for repo-backed workloads

A dockerfile or static workload can opt in to reading its deploy config from a
.tinyforge.yml in its own repo. Tinyforge fetches the file, shows field-level
drift vs the live config, and an admin applies it with an explicit Sync. The
repo becomes the source of truth for the declared fields. Manual-sync only;
no auto-apply on deploy, no multi-workload reconcile, no create/delete in v1.

Scope is deliberately source-aware and source_config-resident: dockerfile
declares port/healthcheck/deploy_strategy, static declares deploy_strategy.
The file never carries repo coords or secrets (those stay in the encrypted
DB), which keeps credentials out of the repo.

Backend:
- internal/gitops: Spec/ParseSpec (KnownFields rejects unknown keys), a
  source-aware ApplyPlan/BuildPlan, MergeAndValidate (omitted-field-preserving
  deep merge + validate-the-merged-result-then-commit — never a partial
  config), declared-only Drift with normalization, and Fetch with
  ok/no_file/fetch_failed/invalid statuses and token-redacted messages.
- staticsite: DownloadFile added to GitProvider + Gitea/GitHub/GitLab impls,
  reusing each provider's SSRF-safe client; 64 KiB cap; ErrFileNotFound.
- store: 4 additive gitops_* columns + setters (disjoint from UpdateWorkload
  so the edit-form save and a sync never clobber each other).
- api: GET /workloads/{id}/gitops (status + raw + live drift + managed_fields),
  PUT /gitops (admin, enable/path, traversal-safe), POST /gitops/sync (admin,
  per-workload locked read->merge->validate->write, audited to event_log).

Frontend:
- GitOpsPanel.svelte: status pill, a purpose-built field-level drift view,
  .tinyforge.yml preview, enable ToggleSwitch, Sync via ConfirmDialog; all five
  statuses handled, admin affordances gated on the real viewer role.
- GitOps-managed badge (list + detail hero) and a read-only edit-form banner.
- api.ts fetchers + types; i18n apps.detail.gitops.* (en + ru parity).

Built phase-by-phase with an adversarial plan review (caught 5 design flaws
pre-implementation) and an independent review per phase (go / security / ts /
final) — all APPROVE, 0 CRITICAL/HIGH. docs/gitops.md documents the schema and
what's intentionally out of v1. Plan: plans/gitops/.
This commit is contained in:
2026-06-21 23:32:02 +03:00
parent 5b51bbbd7f
commit 7733e64b08
38 changed files with 3013 additions and 106 deletions
+46
View File
@@ -976,6 +976,52 @@ export function rollbackWorkload(
return post(`/api/workloads/${id}/rollback`, { deploy_id: deployId });
}
// ── GitOps (config-as-code) ─────────────────────────────────────────
// One rich payload per workload folds the file preview, parsed status, and
// field-level drift into a single GET so the panel makes one call. The shape
// mirrors the Go `gitOpsStatusResponse` (snake_case is preserved end-to-end,
// matching the rest of this file). Drift entries list only the declared
// fields that DIFFER from live; `managed_fields` lists every key the file
// declares (the read-only gate keys on these).
export interface GitOpsDriftEntry {
field: string;
repo_value: string;
live_value: string;
}
export type GitOpsStatusKind = 'disabled' | 'ok' | 'no_file' | 'fetch_failed' | 'invalid';
export interface GitOpsStatus {
eligible: boolean;
enabled: boolean;
path: string;
status: GitOpsStatusKind;
raw: string;
message: string;
commit_sha: string;
last_sync_at: string;
drift: GitOpsDriftEntry[];
drift_count: number;
managed_fields: string[];
}
export function fetchWorkloadGitOps(id: string, signal?: AbortSignal): Promise<GitOpsStatus> {
return get<GitOpsStatus>(`/api/workloads/${id}/gitops`, signal);
}
export function setWorkloadGitOps(
id: string,
body: { enabled: boolean; path: string }
): Promise<{ enabled: boolean; path: string }> {
return put<{ enabled: boolean; path: string }>(`/api/workloads/${id}/gitops`, body);
}
export function syncWorkloadGitOps(
id: string
): Promise<{ status: string; commit_sha: string; applied_fields: string[]; triggered_by: string }> {
return post(`/api/workloads/${id}/gitops/sync`);
}
// ── Per-workload metrics history ────────────────────────────────────
// CPU% and memory (bytes) summed across the workload's containers, one
// point per sampled timestamp. Empty when stats collection is off / Docker
+818
View File
@@ -0,0 +1,818 @@
<script lang="ts">
/**
* GitOpsPanel
*
* Config-as-code for dockerfile/static workloads. The repo's
* `.tinyforge.yml` declares a small overlay (port / healthcheck /
* deploy_strategy); this panel shows whether the live source_config
* matches that file (drift), previews the file, and applies it on demand
* via "Sync now" (validate-then-commit on the server).
*
* Self-contained: it fetches its own GET /gitops on mount and on
* workloadId change, and owns the enable/disable toggle. On a successful
* sync it both re-fetches its own status AND calls `onSynced` so the parent
* page reloads the (now-changed) workload row.
*
* The `.panel` / `.reg` card chrome is declared locally — Svelte scopes the
* detail page's panel styles to that route, so a child component must carry
* its own copy to render the forge card frame.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { toasts } from '$lib/stores/toast';
import { fmt } from '$lib/format/datetime';
import ToggleSwitch from './ToggleSwitch.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconRefresh, IconCheck, IconCopy } from './icons';
interface Props {
workloadId: string;
sourceKind: string;
isAdmin?: boolean;
/** Called after a successful sync so the parent reloads the workload. */
onSynced?: () => void;
}
// Default false: the admin affordances (enable toggle, Sync) stay hidden
// unless the parent explicitly proves the viewer is an admin. The server
// also gates PUT/POST with AdminOnly, so this is defense-in-depth.
let { workloadId, sourceKind, isAdmin = false, onSynced }: Props = $props();
let gitops = $state<api.GitOpsStatus | null>(null);
let loading = $state(true);
let error = $state('');
let togglePending = $state(false);
let syncing = $state(false);
let confirmSync = $state(false);
let copied = $state(false);
// Eligibility is decided client-side from the source kind so the panel can
// render-nothing instantly. (The server also reports `eligible`; the two
// always agree.) dockerfile + static are the git-backed sources.
const ELIGIBLE_KINDS = ['dockerfile', 'static'];
const eligibleByKind = $derived(ELIGIBLE_KINDS.includes(sourceKind));
async function load(signal?: AbortSignal): Promise<void> {
try {
gitops = await api.fetchWorkloadGitOps(workloadId, signal);
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever workloadId changes — the parent reuses this instance
// across /apps/A → /apps/B navigation.
$effect(() => {
void workloadId;
loading = true;
const controller = new AbortController();
load(controller.signal);
return () => controller.abort();
});
async function onToggle(next: boolean): Promise<void> {
if (togglePending || !gitops) return;
togglePending = true;
const path = gitops.path || '.tinyforge.yml';
try {
await api.setWorkloadGitOps(workloadId, { enabled: next, path });
toasts.success(
next ? $t('apps.detail.gitops.enabledToast') : $t('apps.detail.gitops.disabledToast')
);
await load();
onSynced?.();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.toggleFailed'));
// Reload so the switch reflects the persisted (unchanged) value.
await load();
} finally {
togglePending = false;
}
}
async function doSync(): Promise<void> {
if (syncing) return;
syncing = true;
try {
const res = await api.syncWorkloadGitOps(workloadId);
toasts.success(
$t('apps.detail.gitops.syncedToast', {
count: String(res.applied_fields.length),
sha: shortSha(res.commit_sha)
})
);
await load();
onSynced?.();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.syncFailed'));
} finally {
syncing = false;
confirmSync = false;
}
}
async function copyRaw(): Promise<void> {
if (!gitops?.raw) return;
try {
await navigator.clipboard.writeText(gitops.raw);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {
// Clipboard unavailable (insecure context) — silently no-op; the
// preview is visible and selectable regardless.
}
}
function shortSha(sha: string): string {
if (!sha) return '—';
return /^[0-9a-f]{8,}$/i.test(sha) ? sha.slice(0, 10) : sha;
}
// Pill descriptor: status tone + label drive the header pill. "ok" splits
// into in-sync (0 drift) vs N-changes so the single most important signal —
// "does live match the repo?" — reads at a glance.
type PillTone = 'sync' | 'drift' | 'muted' | 'warn' | 'danger';
const pill = $derived.by((): { tone: PillTone; label: string } => {
if (!gitops || !gitops.enabled) {
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
}
switch (gitops.status) {
case 'ok':
return gitops.drift_count === 0
? { tone: 'sync', label: $t('apps.detail.gitops.pillSynced') }
: {
tone: 'drift',
label:
gitops.drift_count === 1
? $t('apps.detail.gitops.pillChangesOne')
: $t('apps.detail.gitops.pillChangesMany', {
count: String(gitops.drift_count)
})
};
case 'no_file':
return { tone: 'warn', label: $t('apps.detail.gitops.pillNoFile') };
case 'fetch_failed':
return { tone: 'danger', label: $t('apps.detail.gitops.pillFetchFailed') };
case 'invalid':
return { tone: 'danger', label: $t('apps.detail.gitops.pillInvalid') };
default:
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
}
});
const isOK = $derived(gitops?.status === 'ok');
const inSync = $derived(isOK && (gitops?.drift_count ?? 0) === 0);
const hasDrift = $derived(isOK && (gitops?.drift_count ?? 0) > 0);
const lastSync = $derived(gitops?.last_sync_at ?? '');
// Human label for a managed source_config key.
function fieldLabel(key: string): string {
const k = `apps.detail.gitops.field.${key}`;
const label = $t(k);
// $t returns the key verbatim when missing — fall back to the raw key.
return label === k ? key : label;
}
</script>
{#if eligibleByKind}
<section class="panel gp-panel" aria-labelledby="gp-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<div class="gp-titlewrap">
<h2 class="panel-title" id="gp-heading">
{$t('apps.detail.gitops.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.gitops.sub')}</span>
</div>
<span class="gp-pill gp-pill-{pill.tone}">
{#if pill.tone === 'sync'}
<IconCheck size={11} />
{:else}
<span class="gp-pill-dot" aria-hidden="true"></span>
{/if}
{pill.label}
</span>
{#if isAdmin}
<div class="gp-toggle" title={$t('apps.detail.gitops.toggleHint')}>
<span class="gp-toggle-lbl">{$t('apps.detail.gitops.toggleLabel')}</span>
<ToggleSwitch
checked={gitops?.enabled ?? false}
disabled={togglePending || loading}
ariaLabel={$t('apps.detail.gitops.toggleAria')}
onchange={onToggle}
/>
</div>
{/if}
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading && !gitops}
<p class="gp-hint">{$t('apps.detail.gitops.loading')}</p>
{:else if gitops}
{#if !gitops.enabled}
<!-- Disabled: calm one-liner + path the file is expected at. -->
<div class="gp-empty">
<p class="gp-empty-lead">{$t('apps.detail.gitops.disabledLead')}</p>
<p class="gp-empty-sub">
{$t('apps.detail.gitops.disabledSub')}
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</p>
</div>
{:else}
<!-- Meta row: path · short sha · last sync. -->
<div class="gp-meta">
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaPath')}</span>
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</span>
{#if gitops.commit_sha}
<span class="gp-meta-sep" aria-hidden="true">·</span>
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaCommit')}</span>
<code class="gp-sha" title={gitops.commit_sha}>{shortSha(gitops.commit_sha)}</code>
</span>
{/if}
<span class="gp-meta-sep" aria-hidden="true">·</span>
<span class="gp-meta-item">
<span class="gp-meta-k">{$t('apps.detail.gitops.metaLastSync')}</span>
{#if lastSync}
<span class="gp-meta-v" title={$fmt.dateTime(lastSync)}>{$fmt.relative(lastSync)}</span>
{:else}
<span class="gp-meta-v gp-muted">{$t('apps.detail.gitops.metaNeverSynced')}</span>
{/if}
</span>
</div>
<!-- ── THE DRIFT VIEW ─────────────────────────────────
Field-level repo-vs-live diff. Purpose-built: one row per
declared field, repo value on the left, live value on the
right, with a connective glyph that turns amber when the
two differ. In-sync collapses to a single confident gitops. -->
{#if isOK}
{#if inSync}
<div class="gp-insync" role="status">
<span class="gp-insync-icon" aria-hidden="true"><IconCheck size={16} /></span>
<div class="gp-insync-text">
<strong>{$t('apps.detail.gitops.inSyncTitle')}</strong>
<span>{$t('apps.detail.gitops.inSyncSub')}</span>
</div>
</div>
{:else}
<div class="gp-drift" aria-label={$t('apps.detail.gitops.driftAria')}>
<div class="gp-drift-head" aria-hidden="true">
<span class="gp-col-field">{$t('apps.detail.gitops.driftColField')}</span>
<span class="gp-col gp-col-repo">{$t('apps.detail.gitops.driftColRepo')}</span>
<span class="gp-col-arrow"></span>
<span class="gp-col gp-col-live">{$t('apps.detail.gitops.driftColLive')}</span>
</div>
{#each gitops.drift as d (d.field)}
<div class="gp-drift-row">
<span class="gp-field">{fieldLabel(d.field)}</span>
<span class="gp-val gp-val-repo" title={d.repo_value}>{d.repo_value || '—'}</span>
<span class="gp-arrow" aria-hidden="true"></span>
<span class="gp-val gp-val-live" title={d.live_value}>{d.live_value || '—'}</span>
</div>
{/each}
<p class="gp-drift-foot">{$t('apps.detail.gitops.driftFoot')}</p>
</div>
{/if}
{:else if gitops.status === 'no_file'}
<div class="gp-status-note gp-note-warn">
<p class="gp-note-lead">{$t('apps.detail.gitops.noFileLead')}</p>
<p class="gp-note-sub">
{$t('apps.detail.gitops.noFileSub')}
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
</p>
</div>
{:else if gitops.status === 'fetch_failed'}
<div class="gp-status-note gp-note-danger">
<p class="gp-note-lead">{$t('apps.detail.gitops.fetchFailedLead')}</p>
{#if gitops.message}<p class="gp-note-sub">{gitops.message}</p>{/if}
</div>
{:else if gitops.status === 'invalid'}
<div class="gp-status-note gp-note-danger">
<p class="gp-note-lead">{$t('apps.detail.gitops.invalidLead')}</p>
{#if gitops.message}<p class="gp-note-sub gp-mono">{gitops.message}</p>{/if}
</div>
{/if}
<!-- ── Rendered .tinyforge.yml preview (when present) ──── -->
{#if gitops.raw}
<div class="gp-editor">
<div class="gp-editor-head">
<span class="gp-dot"></span><span class="gp-dot"></span><span class="gp-dot"></span>
<span class="gp-editor-title">{gitops.path || '.tinyforge.yml'}</span>
<span class="gp-spacer"></span>
<button
type="button"
class="gp-editor-chip"
onclick={copyRaw}
title={$t('apps.detail.gitops.copyAria')}
>
{#if copied}
<IconCheck size={12} />{$t('apps.detail.gitops.copied')}
{:else}
<IconCopy size={12} />{$t('apps.detail.gitops.copy')}
{/if}
</button>
</div>
<pre class="gp-code" aria-label={gitops.path || '.tinyforge.yml'}>{gitops.raw}</pre>
</div>
{/if}
<!-- ── Sync action (admin) ─────────────────────────────── -->
{#if isAdmin && (isOK || gitops.status === 'no_file')}
<div class="gp-actions">
<p class="gp-actions-hint">
{hasDrift
? $t('apps.detail.gitops.syncHintDrift')
: $t('apps.detail.gitops.syncHintClean')}
</p>
<button
class="forge-btn"
onclick={() => (confirmSync = true)}
disabled={syncing || !isOK}
>
<IconRefresh size={13} />
<span>{syncing ? $t('apps.detail.gitops.syncing') : $t('apps.detail.gitops.syncNow')}</span>
</button>
</div>
{/if}
{/if}
{/if}
</section>
{/if}
{#if confirmSync}
<ConfirmDialog
open={true}
title={$t('apps.detail.gitops.confirmTitle')}
message={$t('apps.detail.gitops.confirmMessage')}
confirmLabel={$t('apps.detail.gitops.syncNow')}
confirmVariant="primary"
onconfirm={doSync}
oncancel={() => (confirmSync = false)}
/>
{/if}
<style>
/* ── Card chrome (page-scoped on the detail route; carried locally so
this child renders the forge frame on its own). ───────────────── */
.gp-panel {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.25rem 1.5rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.reg {
position: absolute;
width: 10px;
height: 10px;
border-color: var(--color-brand-500);
border-style: solid;
border-width: 0;
pointer-events: none;
}
.reg-tl {
top: -1px;
left: -1px;
border-top-width: 2px;
border-left-width: 2px;
border-top-left-radius: var(--radius-2xl);
}
.reg-tr {
top: -1px;
right: -1px;
border-top-width: 2px;
border-right-width: 2px;
border-top-right-radius: var(--radius-2xl);
}
.reg-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 2px;
border-left-width: 2px;
border-bottom-left-radius: var(--radius-2xl);
}
.reg-br {
bottom: -1px;
right: -1px;
border-bottom-width: 2px;
border-right-width: 2px;
border-bottom-right-radius: var(--radius-2xl);
}
.panel-head {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.gp-titlewrap {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.panel-title {
font-family: var(--font-family-sans);
font-size: 0.98rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--text-primary);
margin: 0;
}
.title-accent {
color: var(--accent);
font-weight: 700;
}
.panel-sub {
font-size: 0.78rem;
color: var(--text-tertiary);
}
/* ── Status pill ─────────────────────────────────────────────── */
.gp-pill {
display: inline-flex;
align-items: center;
gap: 0.32rem;
margin-left: auto;
padding: 0.22rem 0.6rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid transparent;
white-space: nowrap;
}
.gp-pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.gp-pill-sync {
color: var(--color-success-dark);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
.gp-pill-drift {
color: var(--color-brand-700);
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--color-brand-500) 38%, transparent);
}
.gp-pill-warn {
color: var(--color-warning-dark);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-color: color-mix(in srgb, var(--color-warning) 32%, transparent);
}
.gp-pill-danger {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: color-mix(in srgb, var(--color-danger) 32%, transparent);
}
.gp-pill-muted {
color: var(--text-tertiary);
background: var(--surface-card-hover);
border-color: var(--border-primary);
}
/* ── Enable toggle ───────────────────────────────────────────── */
.gp-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gp-toggle-lbl {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gp-hint {
font-size: 0.74rem;
color: var(--text-tertiary);
margin: 0.2rem 0;
}
/* ── Disabled / empty ────────────────────────────────────────── */
.gp-empty {
padding: 0.4rem 0 0.1rem;
}
.gp-empty-lead {
font-size: 0.82rem;
color: var(--text-secondary);
margin: 0 0 0.25rem;
}
.gp-empty-sub {
font-size: 0.74rem;
color: var(--text-tertiary);
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
/* ── Meta row ────────────────────────────────────────────────── */
.gp-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.73rem;
color: var(--text-secondary);
}
.gp-meta-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.gp-meta-k {
color: var(--text-tertiary);
font-size: 0.66rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: var(--forge-mono);
}
.gp-meta-sep {
color: var(--text-tertiary-soft);
}
.gp-muted,
.gp-meta-v.gp-muted {
color: var(--text-tertiary);
}
.gp-path,
.gp-sha {
font-family: var(--forge-mono);
font-size: 0.7rem;
padding: 0.08rem 0.38rem;
border-radius: var(--radius-sm);
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
}
/* ── In-sync confident gitops ─────────────────────────────────── */
.gp-insync {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-success) 7%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--color-success) 28%, transparent);
}
.gp-insync-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 50%;
color: #fff;
background: var(--color-success);
}
.gp-insync-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.gp-insync-text strong {
font-size: 0.82rem;
color: var(--text-primary);
}
.gp-insync-text span {
font-size: 0.72rem;
color: var(--text-secondary);
}
/* ── THE DRIFT VIEW ──────────────────────────────────────────── */
.gp-drift {
border: 1px solid color-mix(in srgb, var(--color-brand-500) 28%, var(--border-primary));
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface-card);
}
.gp-drift-head {
display: grid;
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
background: var(--forge-accent-soft);
border-bottom: 1px solid color-mix(in srgb, var(--color-brand-500) 22%, transparent);
font-family: var(--forge-mono);
font-size: 0.6rem;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gp-col-repo {
color: var(--color-brand-700);
}
.gp-col-live {
color: var(--text-secondary);
}
.gp-drift-row {
display: grid;
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border-secondary);
}
.gp-drift-row:last-of-type {
border-bottom: 0;
}
.gp-field {
font-family: var(--forge-mono);
font-size: 0.74rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gp-val {
font-family: var(--forge-mono);
font-size: 0.74rem;
padding: 0.22rem 0.5rem;
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Repo = the desired/incoming value: ember-tinted, the "source of truth". */
.gp-val-repo {
color: var(--color-brand-700);
background: var(--forge-accent-soft);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
font-weight: 600;
}
/* Live = the current value being replaced: muted, struck feel via dashed. */
.gp-val-live {
color: var(--text-tertiary);
background: var(--surface-card-hover);
border: 1px dashed var(--border-input);
}
.gp-arrow {
justify-self: center;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
.gp-drift-foot {
margin: 0;
padding: 0.5rem 0.85rem;
font-size: 0.68rem;
color: var(--text-tertiary);
background: var(--surface-card-hover);
border-top: 1px solid var(--border-secondary);
}
/* ── Status notes (no_file / fetch_failed / invalid) ─────────── */
.gp-status-note {
padding: 0.7rem 0.9rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.gp-note-warn {
background: color-mix(in srgb, var(--color-warning) 7%, var(--surface-card));
border-color: color-mix(in srgb, var(--color-warning) 26%, transparent);
}
.gp-note-danger {
background: color-mix(in srgb, var(--color-danger) 6%, var(--surface-card));
border-color: color-mix(in srgb, var(--color-danger) 26%, transparent);
}
.gp-note-lead {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
}
.gp-note-sub {
margin: 0.3rem 0 0;
font-size: 0.73rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.gp-mono {
font-family: var(--forge-mono);
font-size: 0.7rem;
word-break: break-word;
}
/* ── Rendered .tinyforge.yml preview ─────────────────────────── */
.gp-editor {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface-card);
}
.gp-editor-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.7rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-primary);
}
.gp-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-input);
}
.gp-editor-title {
margin-left: 0.3rem;
font-family: var(--forge-mono);
font-size: 0.68rem;
color: var(--text-secondary);
}
.gp-spacer {
flex: 1;
}
.gp-editor-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
background: var(--surface-card);
color: var(--text-secondary);
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease;
}
.gp-editor-chip:hover {
color: var(--accent);
border-color: var(--accent);
}
.gp-code {
margin: 0;
padding: 0.85rem 1rem;
font-family: var(--forge-mono);
font-size: 0.76rem;
line-height: 1.55;
color: var(--text-primary);
white-space: pre;
overflow-x: auto;
tab-size: 2;
max-height: 22rem;
}
/* ── Sync action ─────────────────────────────────────────────── */
.gp-actions {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.gp-actions-hint {
margin: 0;
font-size: 0.72rem;
color: var(--text-tertiary);
flex: 1;
min-width: 12rem;
}
@media (prefers-reduced-motion: reduce) {
.gp-editor-chip {
transition: none;
}
}
</style>
+59 -1
View File
@@ -1407,7 +1407,9 @@
"colTrigger": "Trigger",
"colCreated": "Created",
"colActions": "Actions",
"rowOpen": "Open"
"rowOpen": "Open",
"gitopsBadge": "GitOps",
"gitopsBadgeTitle": "Deploy config for this app is managed from its repo."
},
"new": {
"pageTitle": "New App · Tinyforge",
@@ -1731,6 +1733,62 @@
"cpuSeries": "CPU",
"memorySeries": "Memory"
},
"gitops": {
"title": "GitOps",
"sub": "Declare this app's deploy config in your repo and sync it on demand.",
"loading": "Loading GitOps status…",
"disabledLead": "GitOps is off for this app.",
"disabledSub": "Enable it to manage the deploy config from",
"badge": "GitOps",
"badgeTitle": "Deploy config for this app is managed from its repo.",
"toggleLabel": "Enabled",
"toggleAria": "Enable GitOps for this app",
"toggleHint": "Read the deploy config from a file in this app's repo.",
"enabledToast": "GitOps enabled.",
"disabledToast": "GitOps disabled.",
"toggleFailed": "Could not update GitOps settings.",
"pillSynced": "Synced",
"pillChangesOne": "1 change",
"pillChangesMany": "{count} changes",
"pillNoFile": "No file",
"pillFetchFailed": "Fetch failed",
"pillInvalid": "Invalid",
"pillDisabled": "Disabled",
"metaPath": "Path",
"metaCommit": "Commit",
"metaLastSync": "Last sync",
"metaNeverSynced": "Never synced",
"inSyncTitle": "Live config matches the repo",
"inSyncSub": "Every declared field is already applied. Nothing to sync.",
"driftAria": "Fields that differ between the repo file and the live config",
"driftColField": "Field",
"driftColRepo": "Repo",
"driftColLive": "Live",
"driftFoot": "Syncing applies the repo values, replacing the live ones above.",
"noFileLead": "No config file on the branch",
"noFileSub": "GitOps is on, but nothing was found at",
"fetchFailedLead": "Couldn't read the config from the repo",
"invalidLead": "The repo config couldn't be parsed",
"copy": "Copy",
"copied": "Copied",
"copyAria": "Copy the config file to the clipboard",
"syncHintDrift": "Sync to apply the repo config to this app's live config.",
"syncHintClean": "Live config already matches the repo.",
"syncNow": "Sync now",
"syncing": "Syncing…",
"syncedToast": "Synced {count} field(s) from {sha}.",
"syncFailed": "Sync failed.",
"confirmTitle": "Apply the repo config?",
"confirmMessage": "This applies the repo config to this app's live config. The current values for the declared fields are replaced.",
"gateTitle": "Managed by .tinyforge.yml",
"gateBody": "Edit the config in the repo and Sync — changes made here are overwritten on the next sync.",
"gateFieldsLabel": "Managed fields",
"field": {
"port": "Port",
"healthcheck": "Healthcheck",
"deploy_strategy": "Deploy strategy"
}
},
"toolbar": {
"stop": "Stop",
"start": "Start",
+59 -1
View File
@@ -1407,7 +1407,9 @@
"colTrigger": "Триггер",
"colCreated": "Создано",
"colActions": "Действия",
"rowOpen": "Открыть"
"rowOpen": "Открыть",
"gitopsBadge": "GitOps",
"gitopsBadgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория."
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
@@ -1731,6 +1733,62 @@
"cpuSeries": "CPU",
"memorySeries": "Память"
},
"gitops": {
"title": "GitOps",
"sub": "Опишите конфигурацию деплоя в репозитории и применяйте её по запросу.",
"loading": "Загрузка статуса GitOps…",
"disabledLead": "GitOps для этого приложения отключён.",
"disabledSub": "Включите его, чтобы управлять конфигурацией деплоя из",
"badge": "GitOps",
"badgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория.",
"toggleLabel": "Включено",
"toggleAria": "Включить GitOps для этого приложения",
"toggleHint": "Читать конфигурацию деплоя из файла в репозитории приложения.",
"enabledToast": "GitOps включён.",
"disabledToast": "GitOps отключён.",
"toggleFailed": "Не удалось обновить настройки GitOps.",
"pillSynced": "Синхронизировано",
"pillChangesOne": "1 изменение",
"pillChangesMany": "изменений: {count}",
"pillNoFile": "Нет файла",
"pillFetchFailed": "Ошибка загрузки",
"pillInvalid": "Некорректно",
"pillDisabled": "Отключено",
"metaPath": "Путь",
"metaCommit": "Коммит",
"metaLastSync": "Последняя синхр.",
"metaNeverSynced": "Не синхронизировано",
"inSyncTitle": "Текущая конфигурация совпадает с репозиторием",
"inSyncSub": "Все объявленные поля уже применены. Синхронизировать нечего.",
"driftAria": "Поля, отличающиеся между файлом в репозитории и текущей конфигурацией",
"driftColField": "Поле",
"driftColRepo": "Репозиторий",
"driftColLive": "Текущее",
"driftFoot": "Синхронизация применит значения из репозитория, заменив текущие выше.",
"noFileLead": "В ветке нет файла конфигурации",
"noFileSub": "GitOps включён, но ничего не найдено по пути",
"fetchFailedLead": "Не удалось прочитать конфигурацию из репозитория",
"invalidLead": "Конфигурацию из репозитория не удалось разобрать",
"copy": "Копировать",
"copied": "Скопировано",
"copyAria": "Скопировать файл конфигурации в буфер обмена",
"syncHintDrift": "Синхронизируйте, чтобы применить конфигурацию из репозитория к текущей.",
"syncHintClean": "Текущая конфигурация уже совпадает с репозиторием.",
"syncNow": "Синхронизировать",
"syncing": "Синхронизация…",
"syncedToast": "Применено полей: {count} из {sha}.",
"syncFailed": "Ошибка синхронизации.",
"confirmTitle": "Применить конфигурацию из репозитория?",
"confirmMessage": "Конфигурация из репозитория будет применена к текущей конфигурации приложения. Текущие значения объявленных полей будут заменены.",
"gateTitle": "Управляется через .tinyforge.yml",
"gateBody": "Редактируйте конфигурацию в репозитории и синхронизируйте — изменения здесь будут перезаписаны при следующей синхронизации.",
"gateFieldsLabel": "Управляемые поля",
"field": {
"port": "Порт",
"healthcheck": "Healthcheck",
"deploy_strategy": "Стратегия деплоя"
}
},
"toolbar": {
"stop": "Стоп",
"start": "Старт",
+7
View File
@@ -372,6 +372,13 @@ export interface Workload {
parent_workload_id: string;
notification_url: string;
webhook_require_signature: boolean;
// GitOps config-as-code (dockerfile/static sources only). Opt-in: when
// enabled, the workload's deploy config is declared in `gitops_path`
// (default ".tinyforge.yml") in its own repo and applied via Sync.
gitops_enabled: boolean;
gitops_path: string;
gitops_last_sync_at: string;
gitops_commit_sha: string;
created_at: string;
updated_at: string;
}
+15
View File
@@ -169,6 +169,11 @@
<span class="badge {sourceBadge(w.source_kind)}">
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
</span>
{#if w.gitops_enabled && (w.source_kind === 'dockerfile' || w.source_kind === 'static')}
<span class="badge badge-gitops" title={$t('apps.list.gitopsBadgeTitle')}>
<span class="badge-dot" aria-hidden="true"></span>{$t('apps.list.gitopsBadge')}
</span>
{/if}
</td>
<td>
<span class="badge badge-trigger">{w.trigger_kind}</span>
@@ -506,6 +511,16 @@
background: var(--surface-card-hover);
color: var(--text-secondary);
}
/* GitOps-managed chip — ember-tinted, sits beside the source badge. */
.badge-gitops {
margin-left: 0.35rem;
background: var(--forge-accent-soft);
color: var(--color-brand-700);
border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent);
}
:global([data-theme='dark']) .badge-gitops {
color: var(--color-brand-300);
}
.muted {
color: var(--text-tertiary);
+156 -1
View File
@@ -23,7 +23,8 @@
IconGlobe,
IconHardDrive,
IconClock,
IconLoader
IconLoader,
IconLock
} from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -35,6 +36,7 @@
import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
import DeployHistoryPanel from '$lib/components/DeployHistoryPanel.svelte';
import WorkloadMetricsPanel from '$lib/components/WorkloadMetricsPanel.svelte';
import GitOpsPanel from '$lib/components/GitOpsPanel.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
@@ -139,6 +141,19 @@
// plain text input if the request fails. Bound into ImageSourceForm.
let editRegistries = $state<{ name: string; url: string }[]>([]);
// Source-config keys the repo's .tinyforge.yml declares for a
// GitOps-managed workload. Fetched (fire-and-forget) when the edit form
// opens on a gitops_enabled workload; drives the read-only gate banner that
// warns these fields are overwritten on the next Sync. Empty = no gate.
let gitopsManagedFields = $state<string[]>([]);
// Viewer role drives whether the GitOps panel offers its admin affordances
// (enable toggle, Sync). Fetched once; the server also enforces AdminOnly.
let isAdmin = $state(false);
let roleFetched = false;
const editGitOpsGated = $derived(
(workload?.gitops_enabled ?? false) && gitopsManagedFields.length > 0
);
// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
// `null <= 0` is false — so a bare `port <= 0` check would pass validation
// on an empty field and POST `port: null`. The module's isDockerfileValid
@@ -1209,6 +1224,23 @@
.catch(() => {
editRegistries = [];
});
// Fire-and-forget: surface which fields the repo config manages so the
// edit form can warn they'll be overwritten on Sync. Only relevant when
// GitOps is on; failure leaves the gate off (no banner) — never blocks.
gitopsManagedFields = [];
if (workload.gitops_enabled) {
// Guard against a slow response landing on a different workload after
// an A→B nav (the component instance is reused).
const forId = workload.id;
void api
.fetchWorkloadGitOps(forId)
.then((g) => {
if (forId === workload?.id) gitopsManagedFields = g.managed_fields ?? [];
})
.catch(() => {
if (forId === workload?.id) gitopsManagedFields = [];
});
}
editing = true;
}
@@ -1492,6 +1524,18 @@
// fetch for the previous id cannot land on the new id's state.
$effect(() => {
const _ = id; // explicit dependency
// Resolve the viewer's role once (role is session-wide, not per-id).
if (!roleFetched) {
roleFetched = true;
void api
.getCurrentUser()
.then((u) => {
isAdmin = u.role === 'admin';
})
.catch(() => {
isAdmin = false;
});
}
runtimeAbort?.abort();
storageAbort?.abort();
triggersAbort?.abort();
@@ -1706,6 +1750,12 @@
<span class="badge-dot" aria-hidden="true"></span>
{workload!.source_kind}
</span>
{#if workload!.gitops_enabled}
<span class="badge badge-gitops" title={$t('apps.detail.gitops.badgeTitle')}>
<span class="badge-dot" aria-hidden="true"></span>
{$t('apps.detail.gitops.badge')}
</span>
{/if}
<span class="badge trigger">
{bindings.length === 0
? $t('apps.detail.chainTriggersZero')
@@ -1750,6 +1800,26 @@
</span>
</header>
{#if editGitOpsGated}
<!-- Read-only gate: this workload's config is declared in the repo.
Edits here are valid but get overwritten on the next Sync, so
the banner steers the user to the file + lists the managed
fields. Calm (accent, not danger) — it's guidance, not an error. -->
<div class="gitops-gate" role="note">
<span class="gitops-gate-icon" aria-hidden="true"><IconLock size={15} /></span>
<div class="gitops-gate-body">
<p class="gitops-gate-title">{$t('apps.detail.gitops.gateTitle')}</p>
<p class="gitops-gate-text">{$t('apps.detail.gitops.gateBody')}</p>
<div class="gitops-gate-fields">
<span class="gitops-gate-flabel">{$t('apps.detail.gitops.gateFieldsLabel')}</span>
{#each gitopsManagedFields as f (f)}
<code class="gitops-gate-field">{f}</code>
{/each}
</div>
</div>
</div>
{/if}
<div class="field">
<label for="edit-name" class="field-label">
<span class="num">01</span>
@@ -2820,6 +2890,16 @@
<DeployHistoryPanel workloadId={id} />
{/if}
<!-- ── GitOps config-as-code (dockerfile/static) ──── -->
{#if !editing && (workload.source_kind === 'dockerfile' || workload.source_kind === 'static')}
<GitOpsPanel
workloadId={id}
sourceKind={workload.source_kind}
{isAdmin}
onSynced={load}
/>
{/if}
<!-- ── Per-workload notification routes ───────────── -->
{#if !editing}
<WorkloadNotificationsPanel workloadId={id} />
@@ -3477,6 +3557,81 @@
background: var(--surface-card-hover);
color: var(--text-secondary);
}
/* GitOps-managed chip: ember-tinted so it reads as "this app's config
is driven from the repo", visually allied with the accent. */
.badge-gitops {
background: var(--forge-accent-soft);
color: var(--color-brand-700);
border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent);
}
:global([data-theme='dark']) .badge-gitops {
color: var(--color-brand-300);
}
/* ── GitOps read-only gate banner (edit form) ──── */
.gitops-gate {
display: flex;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-brand-500) 7%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
}
.gitops-gate-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: var(--radius-md);
color: #fff;
background: var(--color-brand-600);
}
.gitops-gate-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.gitops-gate-title {
margin: 0;
font-size: 0.82rem;
font-weight: 700;
color: var(--text-primary);
}
.gitops-gate-text {
margin: 0;
font-size: 0.74rem;
line-height: 1.5;
color: var(--text-secondary);
}
.gitops-gate-fields {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.15rem;
}
.gitops-gate-flabel {
font-family: var(--forge-mono);
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.gitops-gate-field {
font-family: var(--forge-mono);
font-size: 0.7rem;
padding: 0.1rem 0.42rem;
border-radius: var(--radius-sm);
background: var(--forge-accent-soft);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-700);
}
:global([data-theme='dark']) .gitops-gate-field {
color: var(--color-brand-300);
}
/* ── Runtime / storage panels ────────────────────
Two narrow operational-status cards displayed in a 2-up grid