diff --git a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx index 74d37f7f4..8ea9e8995 100644 --- a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx +++ b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx @@ -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; @@ -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 () => { @@ -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'); @@ -3295,7 +3312,10 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {

{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...'}

@@ -3342,9 +3362,11 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
-

Machine resize complete

+

Runtime resize complete

- Machine is running with the new size. + {isNorthflankProvider + ? 'Northflank completed the deployment rollout.' + : 'Machine is running with the new size.'}

+ {isNorthflankProvider && ( + + + Northflank applies the compute change through a deployment rollout. The worker + waits for Northflank to report completion before saving the new tier. + + + )} {data?.workerStatus?.provider === 'fly' && getTier(selectedInstanceType).volumeSizeGb > (data?.workerStatus?.volumeSizeGb ?? 10) && ( @@ -3456,6 +3487,18 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { )} + {isNorthflankProvider && + getTier(selectedInstanceType).volumeSizeGb > + (data?.workerStatus?.volumeSizeGb ?? 10) && ( + + + + Northflank volume will grow from {data?.workerStatus?.volumeSizeGb ?? 10} GB + to {getTier(selectedInstanceType).volumeSizeGb} GB. Volumes can grow but + cannot be shrunk. + + + )} {data?.workerStatus?.provider === 'docker-local' && getTier(selectedInstanceType).volumeSizeGb !== (data?.workerStatus?.volumeSizeGb ?? 10) && ( diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 55fb7541c..fdab70522 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -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 () => { diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index 54db7f4e6..98c347b40 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -2962,9 +2962,11 @@ export class KiloClawInstance extends DurableObject { 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); @@ -3004,6 +3006,18 @@ export class KiloClawInstance extends DurableObject { 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 @@ -3091,6 +3105,7 @@ export class KiloClawInstance extends DurableObject { beforePhrase?: string; notSupportedSubject: string; requireStopped: boolean; + allowNorthflank?: boolean; }): void { if (!this.s.userId) { throw new Error('Instance is not provisioned'); @@ -3115,7 +3130,7 @@ export class KiloClawInstance extends DurableObject { { 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`); } } diff --git a/services/kiloclaw/src/northflank/client.test.ts b/services/kiloclaw/src/northflank/client.test.ts index 5af648875..857235fcd 100644 --- a/services/kiloclaw/src/northflank/client.test.ts +++ b/services/kiloclaw/src/northflank/client.test.ts @@ -13,6 +13,7 @@ import { isNorthflankNotFound, listServices, putProjectSecret, + updateVolume, } from './client'; import { getNorthflankConfig } from './config'; import type { NorthflankClientConfig } from './client'; @@ -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' } }], diff --git a/services/kiloclaw/src/northflank/client.ts b/services/kiloclaw/src/northflank/client.ts index cdbc2b2b0..aa943e5ae 100644 --- a/services/kiloclaw/src/northflank/client.ts +++ b/services/kiloclaw/src/northflank/client.ts @@ -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(), @@ -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 { + 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, diff --git a/services/kiloclaw/src/northflank/config.test.ts b/services/kiloclaw/src/northflank/config.test.ts index c91b584e4..afc5727a8 100644 --- a/services/kiloclaw/src/northflank/config.test.ts +++ b/services/kiloclaw/src/northflank/config.test.ts @@ -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', @@ -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', @@ -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', () => { diff --git a/services/kiloclaw/src/northflank/config.ts b/services/kiloclaw/src/northflank/config.ts index 2b226f653..ad29bd272 100644 --- a/services/kiloclaw/src/northflank/config.ts +++ b/services/kiloclaw/src/northflank/config.ts @@ -3,6 +3,9 @@ import type { NorthflankClientConfig } from './client'; import type { InstanceTierKey } from '@kilocode/kiloclaw-instance-tiers'; export const NORTHFLANK_API_BASE = 'https://api.northflank.com/v1'; +const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_1_3 = 'nf-compute-200'; +const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_8 = 'nf-compute-400'; +const DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_16 = 'nf-compute-400-16'; export type NorthflankConfig = { apiToken: string; @@ -59,11 +62,14 @@ export function getNorthflankConfig(env: KiloClawEnv): NorthflankConfig { deploymentPlan: requiredEnv(env, 'NF_DEPLOYMENT_PLAN'), deploymentPlans: { 'perf-1-3': - optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_1_3') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'), + optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_1_3') ?? + DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_1_3, 'perf-4-8': - optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_8') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'), + optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_8') ?? + DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_8, 'perf-4-16': - optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_16') ?? requiredEnv(env, 'NF_DEPLOYMENT_PLAN'), + optionalEnv(env, 'NF_DEPLOYMENT_PLAN_PERF_4_16') ?? + DEFAULT_NORTHFLANK_DEPLOYMENT_PLAN_PERF_4_16, }, storageClassName: optionalEnv(env, 'NF_STORAGE_CLASS_NAME') ?? 'nf-multi-rw', storageAccessMode: optionalEnv(env, 'NF_STORAGE_ACCESS_MODE') ?? 'ReadWriteMany', diff --git a/services/kiloclaw/src/providers/northflank/index.test.ts b/services/kiloclaw/src/providers/northflank/index.test.ts index 738401ef7..0507f3990 100644 --- a/services/kiloclaw/src/providers/northflank/index.test.ts +++ b/services/kiloclaw/src/providers/northflank/index.test.ts @@ -15,6 +15,7 @@ import { getVolume, patchDeploymentService, putProjectSecret, + updateVolume, waitForDeploymentCompleted, } from '../../northflank/client'; @@ -66,6 +67,7 @@ vi.mock('../../northflank/client', () => ({ isNorthflankNotFound: vi.fn(() => false), patchDeploymentService: vi.fn(), putProjectSecret: vi.fn(), + updateVolume: vi.fn(), waitForDeploymentCompleted: vi.fn(), })); @@ -364,7 +366,7 @@ describe('northflankProviderAdapter', () => { }); const createPayload = vi.mocked(createDeploymentService).mock.calls[0]?.[2]; - expect(createPayload?.billing.deploymentPlan).toBe('nf-compute-200'); + expect(createPayload?.billing.deploymentPlan).toBe('nf-compute-400-16'); }); it('falls back and warns for legacy Northflank tier labels', async () => { @@ -432,6 +434,131 @@ describe('northflankProviderAdapter', () => { expect(result.observation?.runtimeState).toBe('stopped'); }); + it('resizes runtime by growing volume and patching the accepted deployment plan', async () => { + vi.mocked(updateVolume).mockResolvedValue(undefined); + vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' }); + + const result = await northflankProviderAdapter.resizeRuntime?.({ + env: { ...env, NF_DEPLOYMENT_PLAN_PERF_4_8: 'nf-compute-perf-4-8' } as never, + state: { + sandboxId: 'ki_123', + volumeSizeGb: 10, + providerState: { + provider: 'northflank', + projectId: 'project-1', + serviceId: 'service-1', + serviceName: 'kc-ki-123', + volumeId: 'volume-1', + ingressHost: 'old.code.run', + }, + } as never, + targetTier: 'perf-4-8', + }); + + expect(updateVolume).toHaveBeenCalledWith( + expect.objectContaining({ apiToken: 'nf-token' }), + 'project-1', + 'volume-1', + { + storageSizeMb: 20480, + } + ); + expect(patchDeploymentService).toHaveBeenCalledWith( + expect.objectContaining({ apiToken: 'nf-token' }), + 'project-1', + 'service-1', + { billing: { deploymentPlan: 'nf-compute-perf-4-8' } } + ); + expect(waitForDeploymentCompleted).not.toHaveBeenCalled(); + expect(result?.providerState).toEqual( + expect.objectContaining({ provider: 'northflank', ingressHost: 'old.code.run' }) + ); + }); + + it('resizes runtime without volume update when storage does not grow', async () => { + vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' }); + + await northflankProviderAdapter.resizeRuntime?.({ + env: env as never, + state: { + sandboxId: 'ki_123', + volumeSizeGb: 20, + providerState: { + provider: 'northflank', + projectId: 'project-1', + serviceId: 'service-1', + volumeId: 'volume-1', + }, + } as never, + targetTier: 'perf-4-8', + }); + + expect(updateVolume).not.toHaveBeenCalled(); + expect(patchDeploymentService).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + 'service-1', + { billing: { deploymentPlan: 'nf-compute-400' } } + ); + expect(waitForDeploymentCompleted).not.toHaveBeenCalled(); + }); + + it('rejects resize when service ID is missing', async () => { + await expect( + northflankProviderAdapter.resizeRuntime?.({ + env: env as never, + state: { + sandboxId: 'ki_123', + volumeSizeGb: 10, + providerState: { provider: 'northflank', projectId: 'project-1', volumeId: 'volume-1' }, + } as never, + targetTier: 'perf-4-8', + }) + ).rejects.toThrow('Northflank resize requires an existing deployment service'); + + expect(updateVolume).not.toHaveBeenCalled(); + expect(patchDeploymentService).not.toHaveBeenCalled(); + }); + + it('rejects resize when storage grows and volume ID is missing', async () => { + await expect( + northflankProviderAdapter.resizeRuntime?.({ + env: env as never, + state: { + sandboxId: 'ki_123', + volumeSizeGb: 10, + providerState: { provider: 'northflank', projectId: 'project-1', serviceId: 'service-1' }, + } as never, + targetTier: 'perf-4-8', + }) + ).rejects.toThrow('Northflank resize requires an existing volume when storage grows'); + + expect(patchDeploymentService).not.toHaveBeenCalled(); + }); + + it('propagates deployment patch failures from resize', async () => { + vi.mocked(updateVolume).mockResolvedValue(undefined); + vi.mocked(patchDeploymentService).mockRejectedValue(new Error('deployment patch failed')); + + await expect( + northflankProviderAdapter.resizeRuntime?.({ + env: env as never, + state: { + sandboxId: 'ki_123', + volumeSizeGb: 10, + providerState: { + provider: 'northflank', + projectId: 'project-1', + serviceId: 'service-1', + volumeId: 'volume-1', + }, + } as never, + targetTier: 'perf-4-8', + }) + ).rejects.toThrow('deployment patch failed'); + expect(waitForDeploymentCompleted).not.toHaveBeenCalled(); + }); + it('skips writing the restricted secret on restart when bootstrap env is unchanged', async () => { const matchingHash = await computeSecretStateHash('service-1', runtimeSpec.bootstrapEnv); vi.mocked(patchDeploymentService).mockResolvedValue({ id: 'service-1', name: 'kc-ki-123' }); diff --git a/services/kiloclaw/src/providers/northflank/index.ts b/services/kiloclaw/src/providers/northflank/index.ts index 3d96e6c0c..a8b74e721 100644 --- a/services/kiloclaw/src/providers/northflank/index.ts +++ b/services/kiloclaw/src/providers/northflank/index.ts @@ -1,10 +1,11 @@ import type { CreateSecretRequest, CreateServiceDeploymentRequest } from '@northflank/js-client'; import type { NorthflankProviderState } from '../../schemas/instance-config'; import type { InstanceMutableState } from '../../durable-objects/kiloclaw-instance/types'; +import { DEFAULT_VOLUME_SIZE_GB } from '../../config'; import { getNorthflankProviderState } from '../../durable-objects/kiloclaw-instance/state'; import type { RuntimeSpec, InstanceProviderAdapter } from '../types'; import { northflankClientConfig } from '../../northflank/config'; -import { DEFAULT_INSTANCE_TIER } from '@kilocode/kiloclaw-instance-tiers'; +import { DEFAULT_INSTANCE_TIER, getTier } from '@kilocode/kiloclaw-instance-tiers'; import { resolveNorthflankPlan } from '../../northflank/config'; import { createDeploymentService, @@ -26,6 +27,7 @@ import { isNorthflankNotFound, patchDeploymentService, putProjectSecret, + updateVolume, waitForDeploymentCompleted, type NorthflankClientConfig, type NorthflankProject, @@ -836,6 +838,60 @@ export const northflankProviderAdapter: InstanceProviderAdapter = { }; }, + async resizeRuntime({ env, state, targetTier }) { + const config = northflankClientConfig(env); + const providerState = getNorthflankProviderState(state); + if (!providerState.projectId || !providerState.serviceId) { + throw new Error('Northflank resize requires an existing deployment service'); + } + + const tier = getTier(targetTier); + const currentVolumeSizeGb = state.volumeSizeGb ?? DEFAULT_VOLUME_SIZE_GB; + if (tier.volumeSizeGb > currentVolumeSizeGb) { + if (!providerState.volumeId) { + throw new Error('Northflank resize requires an existing volume when storage grows'); + } + // Northflank's documented update-volume endpoint returns only an + // empty success response and does not expose a separate completion + // status to poll. Treat a successful 200 as the provider's accepted + // storage update; the persisted tier is desired state once Northflank + // accepts the storage and compute-plan updates. + await updateVolume(config, providerState.projectId, providerState.volumeId, { + storageSizeMb: tier.volumeSizeGb * 1024, + }); + } + + const deploymentPlan = resolveNorthflankDeploymentPlan(config, targetTier, state.sandboxId); + logNorthflank('resize_runtime_patch_service', { + description: 'Patching Northflank service compute plan for instance tier resize', + apiOperation: 'PATCH /projects/{projectId}/services/deployment/{serviceId}', + sandboxId: state.sandboxId, + projectId: providerState.projectId, + serviceId: providerState.serviceId, + serviceName: providerState.serviceName, + targetTier, + deploymentPlan, + }); + await patchDeploymentService(config, providerState.projectId, providerState.serviceId, { + billing: { deploymentPlan }, + }); + + logNorthflank('resize_runtime_patch_accepted', { + description: + 'Northflank accepted the service compute-plan patch; persisted tier now reflects desired state', + apiOperation: 'PATCH /projects/{projectId}/services/deployment/{serviceId}', + sandboxId: state.sandboxId, + projectId: providerState.projectId, + serviceId: providerState.serviceId, + targetTier, + deploymentPlan, + }); + + return { + providerState, + }; + }, + async inspectRuntime({ env, state }) { const config = northflankClientConfig(env); const providerState = getNorthflankProviderState(state); diff --git a/services/kiloclaw/src/providers/types.ts b/services/kiloclaw/src/providers/types.ts index 05b69d5d6..ecd73aaa4 100644 --- a/services/kiloclaw/src/providers/types.ts +++ b/services/kiloclaw/src/providers/types.ts @@ -1,4 +1,5 @@ import type { KiloClawEnv } from '../types'; +import type { InstanceTierKey } from '@kilocode/kiloclaw-instance-tiers'; import type { MachineSize, PersistedState, @@ -80,6 +81,10 @@ export type DestroyRuntimeArgs = ProviderContext; export type DestroyStorageArgs = ProviderContext; +export type ResizeRuntimeArgs = ProviderContext & { + targetTier: InstanceTierKey; +}; + export type InstanceProviderAdapter = { readonly id: ProviderId; readonly capabilities: ProviderCapabilities; @@ -89,6 +94,7 @@ export type InstanceProviderAdapter = { startRuntime(args: StartRuntimeArgs): Promise; stopRuntime(args: StopRuntimeArgs): Promise; restartRuntime(args: RestartRuntimeArgs): Promise; + resizeRuntime?(args: ResizeRuntimeArgs): Promise; inspectRuntime(args: ProviderContext): Promise; destroyRuntime(args: DestroyRuntimeArgs): Promise; destroyStorage(args: DestroyStorageArgs): Promise; diff --git a/services/kiloclaw/wrangler.jsonc b/services/kiloclaw/wrangler.jsonc index 8b50e3c1a..8066153c5 100644 --- a/services/kiloclaw/wrangler.jsonc +++ b/services/kiloclaw/wrangler.jsonc @@ -138,13 +138,10 @@ "NF_TEAM_ID": "kilo-prod", "NF_REGION": "us-central", "NF_DEPLOYMENT_PLAN": "nf-compute-200", - // Per-tier Northflank compute plan overrides are intentionally NOT - // declared as empty defaults here — getNorthflankConfig falls back to - // NF_DEPLOYMENT_PLAN when an override is missing, and committing empty - // strings would silently propagate empty plans if optionalEnv ever - // stops trimming. Set NF_DEPLOYMENT_PLAN_PERF_1_3 / _PERF_4_8 / - // _PERF_4_16 per environment via `wrangler secret put` or env-specific - // overrides when per-tier hardware on Northflank is needed. + // getNorthflankConfig has source-level defaults for tier compute plans: + // perf-1-3=nf-compute-200, perf-4-8=nf-compute-400, + // perf-4-16=nf-compute-400-16. Override NF_DEPLOYMENT_PLAN_PERF_* + // only if an environment needs different Northflank plan slugs. "NF_EDGE_HEADER_NAME": "x-kiloclaw-northflank-edge-prod", "NF_IMAGE_PATH_TEMPLATE": "ghcr.io/kilo-org/kiloclaw:{tag}", "NF_IMAGE_CREDENTIALS_ID": "kiloclaw",