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
+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