Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {

const provider = data?.workerStatus?.provider ?? null;
const isFlyProvider = provider === 'fly';
const isNorthflankProvider = provider === 'northflank';
const runtimeId = data?.workerStatus?.runtimeId ?? null;
const storageId = data?.workerStatus?.storageId ?? null;
const flyMachineId = data?.workerStatus?.flyMachineId ?? null;
Expand Down Expand Up @@ -1838,7 +1839,10 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {

// Poll status during resize phases
const resizePolling =
resizePhase === 'stopping' || resizePhase === 'starting' || resizePhase === 'waiting';
resizePhase === 'stopping' ||
resizePhase === 'starting' ||
resizePhase === 'waiting' ||
(isNorthflankProvider && resizePhase === 'resizing');
useQuery({
queryKey: ['machine-resize-poll', userId, instanceId, resizePolling],
queryFn: async () => {
Expand All @@ -1865,6 +1869,19 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
if (!data || !userId) return;

try {
if (isNorthflankProvider) {
setResizePhase('resizing');
await resizeMachineMutation({
userId,
instanceId: data.id,
instanceType: selectedInstanceType,
});
invalidateMachineQueries();
setResizePhase('done');
toast.success('Northflank resize completed');
return;
}

// Step 1: Stop if running — retry up to 3 times since Fly can be slow
if (currentStatus !== 'stopped') {
setResizePhase('stopping');
Expand Down Expand Up @@ -3295,7 +3312,10 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
<div className="space-y-1">
<p className="text-sm font-medium">
{resizePhase === 'stopping' && 'Stopping machine...'}
{resizePhase === 'resizing' && 'Updating machine size...'}
{resizePhase === 'resizing' &&
(isNorthflankProvider
? 'Resizing Northflank deployment...'
: 'Updating machine size...')}
{resizePhase === 'starting' && 'Starting machine with new size...'}
{resizePhase === 'waiting' && 'Waiting for machine to be ready...'}
</p>
Expand Down Expand Up @@ -3342,9 +3362,11 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
<div className="flex items-center gap-3 rounded border border-green-600/30 bg-green-600/5 p-4">
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
<div>
<p className="text-sm font-medium text-green-600">Machine resize complete</p>
<p className="text-sm font-medium text-green-600">Runtime resize complete</p>
<p className="text-muted-foreground text-xs">
Machine is running with the new size.
{isNorthflankProvider
? 'Northflank completed the deployment rollout.'
: 'Machine is running with the new size.'}
</p>
</div>
<Button
Expand Down Expand Up @@ -3398,11 +3420,12 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-orange-500">
<AlertTriangle className="h-5 w-5" />
Resize Machine
Resize runtime
</DialogTitle>
<DialogDescription className="pt-3">
This will stop the machine, update its CPU/memory and storage spec, and restart it.
The user will be disconnected during the restart.
{isNorthflankProvider
? 'Northflank will resize this instance by rolling the deployment onto the target compute plan. The instance may restart during the rollout.'
: 'This will stop the machine, update its CPU/memory and storage spec, and restart it. The user will be disconnected during the restart.'}
<span className="text-foreground mt-2 block font-medium">
User: {data?.user_email ?? data?.user_id}
</span>
Expand Down Expand Up @@ -3444,6 +3467,14 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
})}
</select>
</div>
{isNorthflankProvider && (
<Alert className="border-muted-foreground/30 bg-muted/30">
<AlertDescription className="text-muted-foreground">
Northflank applies the compute change through a deployment rollout. The worker
waits for Northflank to report completion before saving the new tier.
</AlertDescription>
</Alert>
)}
{data?.workerStatus?.provider === 'fly' &&
getTier(selectedInstanceType).volumeSizeGb >
(data?.workerStatus?.volumeSizeGb ?? 10) && (
Expand All @@ -3456,6 +3487,18 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
</AlertDescription>
</Alert>
)}
{isNorthflankProvider &&
getTier(selectedInstanceType).volumeSizeGb >
(data?.workerStatus?.volumeSizeGb ?? 10) && (
<Alert className="border-orange-500/30 bg-orange-500/10">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<AlertDescription className="text-orange-700 dark:text-orange-300">
Northflank volume will grow from {data?.workerStatus?.volumeSizeGb ?? 10} GB
to {getTier(selectedInstanceType).volumeSizeGb} GB. Volumes can grow but
cannot be shrunk.
</AlertDescription>
</Alert>
)}
{data?.workerStatus?.provider === 'docker-local' &&
getTier(selectedInstanceType).volumeSizeGb !==
(data?.workerStatus?.volumeSizeGb ?? 10) && (
Expand Down
73 changes: 68 additions & 5 deletions services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9225,21 +9225,84 @@ describe('resizeMachine', () => {
expect(storage._store.get('instanceType')).toBe('perf-4-8');
});

it('rejects Northflank resize before persisting tier changes', async () => {
it('persists Northflank resize after volume and deployment plan updates are accepted', async () => {
const { instance, storage } = createInstance();
await seedProvisioned(storage, {
await seedNorthflankInstance(storage, {
provider: 'northflank',
providerState: { provider: 'northflank' },
status: 'stopped',
providerState: northflankProviderState(),
status: 'running',
instanceType: 'perf-1-3',
machineSize: { cpus: 1, memory_mb: 3072, cpu_kind: 'performance' },
volumeSizeGb: 10,
});
vi.mocked(fetch).mockImplementation(async input => {
const url = fetchInputUrl(input);
if (url.endsWith('/volumes/volume-1')) {
return new Response(JSON.stringify({ data: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/services/deployment/service-1')) {
return new Response(JSON.stringify({ data: { id: 'service-1', name: 'kc-ki-test' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unhandled Northflank API request: ${url}`);
});

const result = await instance.resizeMachine('perf-4-8');

expect(result.newTier).toBe('perf-4-8');
expect(storage._store.get('instanceType')).toBe('perf-4-8');
expect(storage._store.get('machineSize')).toEqual({
cpus: 4,
memory_mb: 8192,
cpu_kind: 'performance',
});
expect(storage._store.get('volumeSizeGb')).toBe(20);
expect(storage._store.get('providerState')).toEqual(
expect.objectContaining({ ingressHost: 'kc-ki-test.code.run' })
);
});

it('leaves Northflank tier state unchanged when provider resize fails', async () => {
const { instance, storage } = createInstance();
await seedNorthflankInstance(storage, {
status: 'running',
instanceType: 'perf-1-3',
machineSize: { cpus: 1, memory_mb: 3072, cpu_kind: 'performance' },
volumeSizeGb: 10,
});
vi.mocked(fetch).mockImplementation(async input => {
const url = fetchInputUrl(input);
if (url.endsWith('/volumes/volume-1')) {
return new Response(JSON.stringify({ data: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.endsWith('/services/deployment/service-1')) {
return new Response(JSON.stringify({ error: 'deployment patch failed' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unhandled Northflank API request: ${url}`);
});

await expect(instance.resizeMachine('perf-4-8')).rejects.toThrow(
'Instance tier resize is not yet supported on Northflank instances'
'Northflank API patchDeploymentService failed (500)'
);

expect(storage._store.get('instanceType')).toBe('perf-1-3');
expect(storage._store.get('machineSize')).toEqual({
cpus: 1,
memory_mb: 3072,
cpu_kind: 'performance',
});
expect(storage._store.get('volumeSizeGb')).toBe(10);
});

it('clears any active admin size override and reports it in the response', async () => {
Expand Down
23 changes: 19 additions & 4 deletions services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2962,9 +2962,11 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
cannotPrefix: 'resize',
beforePhrase: 'resizing machine tier',
notSupportedSubject: 'Instance tier resize',
// Tier resize calls fly.extendVolume on storage growth; Fly volume
// extends require the machine to be stopped.
requireStopped: true,
// Fly tier resize calls fly.extendVolume on storage growth, which
// requires the machine to be stopped. Northflank uses deployment
// rollout semantics and does not require a stopped instance.
requireStopped: this.s.provider !== 'northflank',
allowNorthflank: true,
});

const targetTier = getTier(targetTierKey);
Expand Down Expand Up @@ -3004,6 +3006,18 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
await this.persist({ volumeSizeGb: targetTier.volumeSizeGb });
}

if (this.s.provider === 'northflank') {
const result = await this.provider().resizeRuntime?.({
env: this.env,
state: this.s,
targetTier: targetTier.key,
});
if (!result) {
throw new Error('Provider northflank does not support tier resize');
}
await this.persistProviderResult(result);
}

// Capture and clear any active admin override before applying the tier
// change. The customer is now paying for the new tier; carrying a
// pre-existing override would either silently downgrade them (override
Expand Down Expand Up @@ -3091,6 +3105,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
beforePhrase?: string;
notSupportedSubject: string;
requireStopped: boolean;
allowNorthflank?: boolean;
}): void {
if (!this.s.userId) {
throw new Error('Instance is not provisioned');
Expand All @@ -3115,7 +3130,7 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
{ status: 409 }
);
}
if (this.s.provider === 'northflank') {
if (this.s.provider === 'northflank' && args.allowNorthflank !== true) {
throw new Error(`${args.notSupportedSubject} is not yet supported on Northflank instances`);
}
}
Expand Down
32 changes: 32 additions & 0 deletions services/kiloclaw/src/northflank/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isNorthflankNotFound,
listServices,
putProjectSecret,
updateVolume,
} from './client';
import { getNorthflankConfig } from './config';
import type { NorthflankClientConfig } from './client';
Expand Down Expand Up @@ -154,6 +155,37 @@ describe('Northflank Worker fetch client', () => {
expect(url).toBe('https://api.northflank.com/v1/projects/project-1/volumes/kc-ki-test');
});

it('updates volume storage size through the documented empty response endpoint', async () => {
const fetchMock = mockFetchSequence([[200, { data: {} }]]);

await expect(
updateVolume(config, 'project-1', 'volume-1', { storageSizeMb: 20480 })
).resolves.toBeUndefined();

const [url, init] = firstFetchCall(fetchMock);
expect(url).toBe('https://api.northflank.com/v1/projects/project-1/volumes/volume-1');
const requestInit = expectRequestInit(init);
expect(requestInit.method).toBe('POST');
expect(JSON.parse(expectStringBody(requestInit.body))).toEqual({
spec: {
storageSize: 20480,
},
});
});

it('updates volumes through team-scoped routes when a team ID is configured', async () => {
const fetchMock = mockFetchSequence([[200, { data: {} }]]);

await updateVolume({ ...config, teamId: 'team-1' }, 'project-1', 'volume-1', {
storageSizeMb: 40960,
});

const [url] = firstFetchCall(fetchMock);
expect(url).toBe(
'https://api.northflank.com/v1/teams/team-1/projects/project-1/volumes/volume-1'
);
});

it('finds services by deterministic name with a direct GET', async () => {
const fetchMock = mockFetchSequence([
[200, { data: { id: 'kc-ki-test', name: 'kc-ki-test' } }],
Expand Down
21 changes: 21 additions & 0 deletions services/kiloclaw/src/northflank/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const NorthflankSecretDetailsSchema = z.object({ id: z.string(), name: z.string(
const ProjectResponseSchema = z.object({ data: NorthflankProjectSchema });
const VolumeResponseSchema = z.object({ data: NorthflankVolumeSchema });
const VolumeListResponseSchema = z.object({ data: z.array(NorthflankVolumeSchema) });
const EmptyDataResponseSchema = z.object({ data: z.object({}).passthrough() });
const ServiceResponseSchema = z.object({ data: NorthflankServiceSchema });
const ServiceListResponseSchema = z.object({
data: z.object({ services: z.array(NorthflankServiceSchema) }).passthrough(),
Expand Down Expand Up @@ -419,6 +420,26 @@ export async function getVolume(
return response.data;
}

export async function updateVolume(
config: NorthflankClientConfig,
projectId: string,
volumeId: string,
input: { storageSizeMb: number }
): Promise<void> {
await requestJson(
config,
`/projects/${encodeURIComponent(projectId)}/volumes/${encodeURIComponent(volumeId)}`,
jsonInit('POST', {
spec: {
storageSize: input.storageSizeMb,
},
}),
EmptyDataResponseSchema,
[200],
'updateVolume'
);
}

export async function deleteVolume(
config: NorthflankClientConfig,
projectId: string,
Expand Down
23 changes: 19 additions & 4 deletions services/kiloclaw/src/northflank/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ describe('getNorthflankConfig', () => {
deploymentPlan: 'nf-compute-200',
deploymentPlans: {
'perf-1-3': 'nf-compute-200',
'perf-4-8': 'nf-compute-200',
'perf-4-16': 'nf-compute-200',
'perf-4-8': 'nf-compute-400',
'perf-4-16': 'nf-compute-400-16',
},
storageClassName: 'nf-multi-rw',
storageAccessMode: 'ReadWriteMany',
Expand Down Expand Up @@ -54,8 +54,8 @@ describe('getNorthflankConfig', () => {
deploymentPlan: 'nf-compute-200',
deploymentPlans: {
'perf-1-3': 'nf-compute-200',
'perf-4-8': 'nf-compute-200',
'perf-4-16': 'nf-compute-200',
'perf-4-8': 'nf-compute-400',
'perf-4-16': 'nf-compute-400-16',
},
storageClassName: 'nf-ssd-rwo',
storageAccessMode: 'ReadWriteOnce',
Expand All @@ -79,6 +79,21 @@ describe('getNorthflankConfig', () => {
'NF_VOLUME_SIZE_MB must be a positive integer'
);
});

it('accepts explicit per-tier deployment plan overrides', () => {
expect(
getNorthflankConfig({
...baseEnv,
NF_DEPLOYMENT_PLAN_PERF_1_3: 'custom-1-3',
NF_DEPLOYMENT_PLAN_PERF_4_8: 'custom-4-8',
NF_DEPLOYMENT_PLAN_PERF_4_16: 'custom-4-16',
} as never).deploymentPlans
).toEqual({
'perf-1-3': 'custom-1-3',
'perf-4-8': 'custom-4-8',
'perf-4-16': 'custom-4-16',
});
});
});

describe('northflankClientConfig', () => {
Expand Down
Loading