3071cda512
Report deploy status back to the Git provider as a commit status (pending/success/failure) for git-sourced workloads (static + dockerfile). - GitProvider.SetCommitStatus on gitea/github/gitlab over the existing SSRF-safe client; fixed "tinyforge" context so redeploys update one row. postJSON returns status-code-only errors (never echoes the upstream body, which a hostile provider could use to reflect the auth token into the best-effort log line). - Best-effort deploy hook: pending on deploy start, success/failure on outcome, gated on a per-workload report_commit_status flag. Never fails or blocks a deploy; emits nothing on the unchanged-SHA short-circuit. - UI ToggleSwitch (create + edit) + reportCommitStatus in sourceForms.ts + en/ru i18n. - Tests: per-provider state mapping + request shape; reporter gating (enabled/disabled/empty-SHA/nil/error-swallow). Reviewed via go-reviewer + security-reviewer (0 CRITICAL/HIGH; one MEDIUM body-echo log-leak fixed).
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
/**
|
|
* Shared source-config form model for the four workload Source kinds
|
|
* (image / compose / static / dockerfile).
|
|
*
|
|
* Before this module the seed (JSON -> form fields) and serialize
|
|
* (form fields -> source_config JSON) logic lived inline and DUPLICATED
|
|
* verbatim in both `routes/apps/new/+page.svelte` and
|
|
* `routes/apps/[id]/+page.svelte`. A drift between the two silently
|
|
* changes the `source_config` shape the backend stores, which breaks
|
|
* deploys. This module is the single source of truth so the create
|
|
* wizard and the detail-page edit form serialize identically.
|
|
*
|
|
* The functions are pure (no Svelte runes, no DOM) so they unit-test in
|
|
* a plain node environment. Components hold the state objects as `$state`
|
|
* and call these to seed / serialize.
|
|
*
|
|
* Fidelity contract: output key order, defaults, and the preserve/scrub
|
|
* behaviour below MUST match the legacy inline helpers exactly. Tests in
|
|
* `sourceForms.test.ts` lock the shapes.
|
|
*/
|
|
|
|
export type GitProvider = 'gitea' | 'github' | 'gitlab';
|
|
|
|
/** Image source: deploy a pre-built image from a registry. */
|
|
export interface ImageFormState {
|
|
ref: string;
|
|
port: number;
|
|
healthcheck: string;
|
|
defaultTag: string;
|
|
registryName: string;
|
|
cpuLimit: number;
|
|
memoryLimit: number;
|
|
maxInstances: number;
|
|
}
|
|
|
|
/** Compose source: a docker-compose stack. */
|
|
export interface ComposeFormState {
|
|
yaml: string;
|
|
projectName: string;
|
|
}
|
|
|
|
/**
|
|
* Git-discovery fields shared by the static and dockerfile sources —
|
|
* both clone a repo via the same provider/owner/repo/branch/token path.
|
|
* Extracted so a single discovery component can bind this slice of
|
|
* either form.
|
|
*/
|
|
export interface GitSourceState {
|
|
provider: GitProvider;
|
|
baseURL: string;
|
|
repoOwner: string;
|
|
repoName: string;
|
|
branch: string;
|
|
accessToken: string;
|
|
}
|
|
|
|
/** Static source: serve files (optionally Deno) from a repo folder. */
|
|
export interface StaticFormState extends GitSourceState {
|
|
folderPath: string;
|
|
mode: 'static' | 'deno';
|
|
renderMarkdown: boolean;
|
|
/** Report deploy outcome back to the git provider as a commit status. */
|
|
reportCommitStatus: boolean;
|
|
}
|
|
|
|
/** Dockerfile source: build an image from a Dockerfile in a repo. */
|
|
export interface DockerfileFormState extends GitSourceState {
|
|
contextPath: string;
|
|
dockerfilePath: string;
|
|
port: number;
|
|
/** Report deploy outcome back to the git provider as a commit status. */
|
|
reportCommitStatus: boolean;
|
|
}
|
|
|
|
// ── Defaults ────────────────────────────────────────────────────────
|
|
|
|
export function emptyImageState(): ImageFormState {
|
|
return {
|
|
ref: '',
|
|
port: 0,
|
|
healthcheck: '',
|
|
defaultTag: 'latest',
|
|
registryName: '',
|
|
cpuLimit: 0,
|
|
memoryLimit: 0,
|
|
maxInstances: 1
|
|
};
|
|
}
|
|
|
|
export function emptyComposeState(): ComposeFormState {
|
|
return { yaml: '', projectName: '' };
|
|
}
|
|
|
|
function emptyGitSourceState(): GitSourceState {
|
|
return {
|
|
provider: 'gitea',
|
|
baseURL: '',
|
|
repoOwner: '',
|
|
repoName: '',
|
|
branch: 'main',
|
|
accessToken: ''
|
|
};
|
|
}
|
|
|
|
export function emptyStaticState(): StaticFormState {
|
|
return {
|
|
...emptyGitSourceState(),
|
|
folderPath: '',
|
|
mode: 'static',
|
|
renderMarkdown: false,
|
|
reportCommitStatus: false
|
|
};
|
|
}
|
|
|
|
export function emptyDockerfileState(): DockerfileFormState {
|
|
return {
|
|
...emptyGitSourceState(),
|
|
contextPath: '',
|
|
dockerfilePath: 'Dockerfile',
|
|
port: 0,
|
|
reportCommitStatus: false
|
|
};
|
|
}
|
|
|
|
// ── Parse helpers ───────────────────────────────────────────────────
|
|
|
|
/** Parse to an object for seeding; malformed / non-object JSON -> {}. */
|
|
function parseObject(jsonText: string): Record<string, unknown> {
|
|
try {
|
|
const parsed: unknown = JSON.parse(jsonText);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
|
|
function tryParse(jsonText: string): Record<string, unknown> | null {
|
|
try {
|
|
const parsed: unknown = JSON.parse(jsonText);
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function strOr(value: unknown, fallback: string): string {
|
|
return typeof value === 'string' ? value : fallback;
|
|
}
|
|
|
|
/** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */
|
|
function strOrTruthy(value: unknown, fallback: string): string {
|
|
return typeof value === 'string' && value ? value : fallback;
|
|
}
|
|
|
|
function numOr(value: unknown, fallback: number): number {
|
|
return typeof value === 'number' ? value : fallback;
|
|
}
|
|
|
|
function normProvider(value: unknown): GitProvider {
|
|
return value === 'github' || value === 'gitlab' ? value : 'gitea';
|
|
}
|
|
|
|
// ── Seed: source_config JSON -> form state ──────────────────────────
|
|
|
|
export function seedImageState(jsonText: string): ImageFormState {
|
|
const o = parseObject(jsonText);
|
|
return {
|
|
ref: strOr(o.image, ''),
|
|
port: numOr(o.port, 0),
|
|
healthcheck: strOr(o.healthcheck, ''),
|
|
defaultTag: strOr(o.default_tag, 'latest'),
|
|
registryName: strOr(o.registry_name, ''),
|
|
cpuLimit: numOr(o.cpu_limit, 0),
|
|
memoryLimit: numOr(o.memory_limit, 0),
|
|
maxInstances: numOr(o.max_instances, 1)
|
|
};
|
|
}
|
|
|
|
export function seedComposeState(jsonText: string): ComposeFormState {
|
|
const o = parseObject(jsonText);
|
|
return {
|
|
yaml: strOr(o.compose_yaml, ''),
|
|
projectName: strOr(o.compose_project_name, '')
|
|
};
|
|
}
|
|
|
|
export function seedStaticState(jsonText: string): StaticFormState {
|
|
const o = parseObject(jsonText);
|
|
return {
|
|
provider: normProvider(o.provider),
|
|
baseURL: strOr(o.base_url, ''),
|
|
repoOwner: strOr(o.repo_owner, ''),
|
|
repoName: strOr(o.repo_name, ''),
|
|
branch: strOrTruthy(o.branch, 'main'),
|
|
accessToken: strOr(o.access_token, ''),
|
|
folderPath: strOr(o.folder_path, ''),
|
|
mode: o.mode === 'deno' ? 'deno' : 'static',
|
|
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
|
|
reportCommitStatus:
|
|
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
|
|
};
|
|
}
|
|
|
|
export function seedDockerfileState(jsonText: string): DockerfileFormState {
|
|
const o = parseObject(jsonText);
|
|
return {
|
|
provider: normProvider(o.provider),
|
|
baseURL: strOr(o.base_url, ''),
|
|
repoOwner: strOr(o.repo_owner, ''),
|
|
repoName: strOr(o.repo_name, ''),
|
|
branch: strOrTruthy(o.branch, 'main'),
|
|
accessToken: strOr(o.access_token, ''),
|
|
contextPath: strOr(o.context_path, ''),
|
|
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
|
|
port: numOr(o.port, 0),
|
|
reportCommitStatus:
|
|
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
|
|
};
|
|
}
|
|
|
|
// ── Serialize: form state -> source_config object ───────────────────
|
|
|
|
/**
|
|
* Preserve `env` (object) and `volumes` (array) from an existing config
|
|
* — they're edited in dedicated detail-page panels, not the source form,
|
|
* and must survive a form round-trip.
|
|
*/
|
|
function preserveEnvVolumes(existingJson: string): {
|
|
env: Record<string, string>;
|
|
volumes: unknown[];
|
|
} {
|
|
const existing = tryParse(existingJson);
|
|
let env: Record<string, string> = {};
|
|
let volumes: unknown[] = [];
|
|
if (existing) {
|
|
if (existing.env && typeof existing.env === 'object') {
|
|
env = existing.env as Record<string, string>;
|
|
}
|
|
if (Array.isArray(existing.volumes)) {
|
|
volumes = existing.volumes;
|
|
}
|
|
}
|
|
return { env, volumes };
|
|
}
|
|
|
|
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
|
|
const { env, volumes } = preserveEnvVolumes(existingJson);
|
|
return {
|
|
image: s.ref,
|
|
registry_name: s.registryName,
|
|
port: s.port,
|
|
healthcheck: s.healthcheck,
|
|
env,
|
|
volumes,
|
|
cpu_limit: s.cpuLimit,
|
|
memory_limit: s.memoryLimit,
|
|
default_tag: s.defaultTag,
|
|
max_instances: s.maxInstances
|
|
};
|
|
}
|
|
|
|
export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
|
|
return { compose_yaml: s.yaml, compose_project_name: s.projectName };
|
|
}
|
|
|
|
export function staticToConfig(s: StaticFormState, existingJson: string): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {
|
|
provider: s.provider,
|
|
base_url: s.baseURL,
|
|
repo_owner: s.repoOwner,
|
|
repo_name: s.repoName,
|
|
branch: s.branch || 'main',
|
|
folder_path: s.folderPath,
|
|
access_token: s.accessToken,
|
|
mode: s.mode,
|
|
render_markdown: s.renderMarkdown,
|
|
// New key appended at the END so existing byte-shape assertions for
|
|
// the other keys are minimally affected. Storage_* keys (added below
|
|
// only when present in the existing config) trail this on edit.
|
|
report_commit_status: s.reportCommitStatus
|
|
};
|
|
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
|
|
// as form controls) so a form round-trip doesn't silently drop them.
|
|
const existing = tryParse(existingJson);
|
|
if (existing) {
|
|
if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled;
|
|
if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Keys the dockerfile form owns. Everything else in an existing config is
|
|
* preserved on round-trip EXCEPT the static-only keys (folder_path / mode
|
|
* / render_markdown / storage_*) which are deliberately scrubbed: after a
|
|
* static -> dockerfile switch they'd otherwise linger as dead keys and
|
|
* make the backend log "unknown field" noise on every save.
|
|
*/
|
|
const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
|
|
'provider',
|
|
'base_url',
|
|
'repo_owner',
|
|
'repo_name',
|
|
'branch',
|
|
'access_token',
|
|
'context_path',
|
|
'dockerfile_path',
|
|
'port',
|
|
'report_commit_status',
|
|
'folder_path',
|
|
'mode',
|
|
'render_markdown',
|
|
'storage_enabled',
|
|
'storage_limit_mb'
|
|
]);
|
|
|
|
export function dockerfileToConfig(
|
|
s: DockerfileFormState,
|
|
existingJson: string
|
|
): Record<string, unknown> {
|
|
const preserved: Record<string, unknown> = {};
|
|
const existing = tryParse(existingJson);
|
|
if (existing) {
|
|
for (const [k, v] of Object.entries(existing)) {
|
|
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
|
|
}
|
|
}
|
|
return {
|
|
provider: s.provider,
|
|
base_url: s.baseURL,
|
|
repo_owner: s.repoOwner,
|
|
repo_name: s.repoName,
|
|
branch: s.branch || 'main',
|
|
access_token: s.accessToken,
|
|
context_path: s.contextPath,
|
|
dockerfile_path: s.dockerfilePath || 'Dockerfile',
|
|
port: s.port || 0,
|
|
// New owned key appended at the END of the owned block (before any
|
|
// preserved unknown keys) so existing byte-shape assertions hold.
|
|
report_commit_status: s.reportCommitStatus,
|
|
...preserved
|
|
};
|
|
}
|
|
|
|
/** Pretty-print a config object for the Advanced-JSON editor view. */
|
|
export function stringifyConfig(config: Record<string, unknown>): string {
|
|
return JSON.stringify(config, null, 2);
|
|
}
|
|
|
|
// ── Per-kind validity ───────────────────────────────────────────────
|
|
// Encodes the required fields per source kind. These back the wizard's
|
|
// step gating (replacing the prior opaque ~250-char boolean). Optional
|
|
// fields (folder_path, context_path, healthcheck, resource limits, ...)
|
|
// are intentionally not required here.
|
|
|
|
export function isImageValid(s: ImageFormState): boolean {
|
|
return s.ref.trim() !== '';
|
|
}
|
|
|
|
export function isComposeValid(s: ComposeFormState): boolean {
|
|
return s.yaml.trim() !== '';
|
|
}
|
|
|
|
function isGitSourceValid(s: GitSourceState): boolean {
|
|
return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== '';
|
|
}
|
|
|
|
export function isStaticValid(s: StaticFormState): boolean {
|
|
return isGitSourceValid(s);
|
|
}
|
|
|
|
export function isDockerfileValid(s: DockerfileFormState): boolean {
|
|
return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0;
|
|
}
|