mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(services): builder selection, resource reservations, static builder (C3)
services.builder picks the build strategy per service: auto detects from repo contents (Dockerfile → railpack → nixpacks), explicit overrides force a builder, and static builds assets in a node stage then serves them from an nginx image (static_build_cmd/static_dir, port 80 default). - cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation; cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears. - app_settings.default_cpu/default_memory override service-create fallbacks; capacity_policy=block fails deploys whose requested memory exceeds node capacity (default warn logs via slog). - Resource specs are validated — "512mb" fails instead of silently dropping the limit. - Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code); DockerfileBuilder passed the Dockerfile *path* as build-context content; BuildImage never drained the daemon stream so builds could abort and errors went unseen — stream is now consumed and errorDetail surfaced. - CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir on create+update; MCP args on services create/update; UI Build & Resources card; OpenAPI + llms.txt + CLI.md. Verified live: static build of a real repo → nginx image → deployed; reserves reach HostConfig (shares=256, MemoryReservation=128Mi); validation rejects bad specs; builder persists + clears. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e995c98d05
commit
912f6f5142
22 files changed
+798
-59
No files matched your search
@@ -448,6 +448,9 @@ export function ServiceDetailPage() {
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||
const [buildForm, setBuildForm] = useState<{
|
||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||
} | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: ['service-domains', serviceId],
|
||||
@@ -2193,7 +2196,11 @@ export function ServiceDetailPage() {
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() =>
|
||||
updateServiceMutation.mutate(
|
||||
{ volumes: volumesForm.filter((v) => v.source.trim() && v.target.startsWith('/')) },
|
||||
{
|
||||
volumes: volumesForm
|
||||
.filter((v) => v.source.trim() && v.target.startsWith('/'))
|
||||
.map((v) => ({ type: v.type ?? 'volume' as const, source: v.source, target: v.target, read_only: v.read_only ?? false })),
|
||||
},
|
||||
{ onSuccess: () => setVolumesForm(null) },
|
||||
)
|
||||
}
|
||||
@@ -2386,6 +2393,144 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build & Resources</h3>
|
||||
{buildForm === null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBuildForm({
|
||||
builder: service.builder ?? 'auto',
|
||||
cpuReserve: service.cpuReserve ?? '',
|
||||
memoryReserve: service.memoryReserve ?? '',
|
||||
staticCmd: service.staticBuildCmd ?? '',
|
||||
staticDir: service.staticDir ?? '',
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{buildForm === null ? (
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
|
||||
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
|
||||
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
|
||||
.filter(Boolean).join(' · ') || 'none'}
|
||||
</p>
|
||||
</div>
|
||||
{service.builder === 'static' && (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
|
||||
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
|
||||
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
|
||||
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
|
||||
<select
|
||||
value={buildForm.builder}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
|
||||
<input
|
||||
value={buildForm.cpuReserve}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
|
||||
placeholder="0.25"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
|
||||
<input
|
||||
value={buildForm.memoryReserve}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
|
||||
placeholder="128Mi"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{buildForm.builder === 'static' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
|
||||
<input
|
||||
value={buildForm.staticCmd}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
|
||||
placeholder="npm ci && npm run build"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
|
||||
<input
|
||||
value={buildForm.staticDir}
|
||||
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
|
||||
placeholder="dist"
|
||||
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() => {
|
||||
updateServiceMutation.mutate(
|
||||
{
|
||||
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
|
||||
cpu_reserve: buildForm.cpuReserve,
|
||||
memory_reserve: buildForm.memoryReserve,
|
||||
static_build_cmd: buildForm.staticCmd,
|
||||
static_dir: buildForm.staticDir,
|
||||
},
|
||||
{ onSuccess: () => setBuildForm(null) },
|
||||
);
|
||||
}}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBuildForm(null)}
|
||||
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
|
||||
</div>
|
||||
{updateServiceMutation.isError && (
|
||||
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -8050,6 +8050,19 @@ export interface components {
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Basic-auth usernames gating the route (hashes never returned) */
|
||||
basic_auth?: string[];
|
||||
/**
|
||||
* @description Build strategy; auto detects from repo contents
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (fraction, e.g. 0.25 → ~256 CPU shares) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder (default "npm ci && npm run build") */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (default dist) */
|
||||
static_dir?: string;
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
@@ -8107,6 +8120,19 @@ export interface components {
|
||||
restart_policy?: string;
|
||||
/** @description Volume/bind mounts applied to every replica */
|
||||
volumes?: components["schemas"]["ServiceVolume"][];
|
||||
/**
|
||||
* @description Build strategy (default auto)
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (fraction, e.g. 0.25) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (e.g. 128Mi) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (default dist) */
|
||||
static_dir?: string;
|
||||
};
|
||||
UpdateServiceRequest: {
|
||||
/** @description Service name */
|
||||
@@ -8151,6 +8177,19 @@ export interface components {
|
||||
maintenance_mode?: boolean;
|
||||
/** @description Replaces all basic-auth credentials; [] clears the gate */
|
||||
basic_auth?: components["schemas"]["BasicAuthCred"][];
|
||||
/**
|
||||
* @description Build strategy
|
||||
* @enum {string}
|
||||
*/
|
||||
builder?: "auto" | "railpack" | "nixpacks" | "dockerfile" | "static";
|
||||
/** @description Soft CPU reservation (empty string clears) */
|
||||
cpu_reserve?: string;
|
||||
/** @description Soft memory reservation (empty string clears) */
|
||||
memory_reserve?: string;
|
||||
/** @description Build command for the static builder (empty string clears) */
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (empty string clears) */
|
||||
static_dir?: string;
|
||||
};
|
||||
ServiceDomain: {
|
||||
/** Format: uuid */
|
||||
|
||||
@@ -58,6 +58,11 @@ export type ServiceEntity = {
|
||||
domains?: ServiceDomain[];
|
||||
maintenanceMode?: boolean;
|
||||
basicAuth?: string[];
|
||||
builder?: 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static';
|
||||
cpuReserve?: string;
|
||||
memoryReserve?: string;
|
||||
staticBuildCmd?: string;
|
||||
staticDir?: string;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
@@ -584,6 +589,11 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
domains: service.domains as ServiceDomain[] | undefined,
|
||||
maintenanceMode: service.maintenance_mode,
|
||||
basicAuth: service.basic_auth as string[] | undefined,
|
||||
builder: service.builder as ServiceEntity['builder'],
|
||||
cpuReserve: service.cpu_reserve,
|
||||
memoryReserve: service.memory_reserve,
|
||||
staticBuildCmd: service.static_build_cmd,
|
||||
staticDir: service.static_dir,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user