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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user