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:
Tomas DvorakandDevin committed 2026-10-08 17:08:35 +02:00
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 &amp; 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">
+39
View File
@@ -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 */
+10
View File
@@ -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,
};
}