A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604import { serviceConsistency, type ServiceConsistency, type ServiceConsistencyResponse,} from "$lib/service-consistency";import { env } from "$env/dynamic/private";import { readFileSync } from "node:fs";
import { ControllerRpcError, controllerRpcCall,} from "$lib/server/controller-rpc";import { currentRequestSignal } from "$lib/server/request-context";
export { ControllerRpcError };
type RpcRequest = Record<string, unknown>;
const controllerRpcBasePath = "/api/controller";
export function controllerProcedure(path: string): string { return `${controllerRpcBasePath}${path}`;}
export type PaginatedResult<T> = { items: T[]; totalCount: number;};
export type Capability = { enabled: boolean; reasonCode: string;};
export type GlobalCapabilities = { backup: Capability; dns: Capability; secrets: Capability; rusticMaintenance: Capability;};
export type SystemCapabilities = { global: GlobalCapabilities;};
export type ServiceActionCapabilities = { backup: Capability; restore: Capability; migrate: Capability; dnsUpdate: Capability; caddySync: Capability; cloudflareTunnelSync: Capability;};
export type NodeActionCapabilities = { caddySync: Capability; caddyReload: Capability; rusticMaintenance: Capability;};
export type BackupActionCapabilities = { restore: Capability;};
export type SystemStatus = { version: string; configuredNodeCount: number; onlineNodeCount: number; serviceCount: number; runningServiceCount: number;};
export type GitConfigSummary = { remoteUrl: string; branch: string; pullInterval: string; hasAuth: boolean; authorName: string; authorEmail: string;};
export type NodeConfigSummary = { id: string; displayName: string; enabled: boolean; publicIpv4: string; publicIpv6: string;};
export type AccessTokenSummary = { name: string; enabled: boolean; comment: string;};
export type CurrentConfig = { listenAddr: string; git?: GitConfigSummary; nodes: NodeConfigSummary[]; accessTokens: AccessTokenSummary[];};
export type EditableControllerConfig = { yaml: string; revision: string;};
export type ServiceSummary = { name: string; folder?: string; isDeclared: boolean; runtimeStatus: string; updatedAt: string; instanceCount: number; runningCount: number; targetNodeCount: number;};
export type ServiceWorkspaceSummary = { folder: string; displayName: string; serviceName: string; hasMeta: boolean; isDeclared: boolean; runtimeStatus: string; updatedAt: string; nodes: string[]; enabled: boolean; actions: ServiceActionCapabilities;};
export type ServiceInstanceSummary = { serviceName: string; nodeId: string; runtimeStatus: string; updatedAt: string; isDeclared: boolean; pendingDeployRevision: string; consistency: ServiceConsistency;};
export type ServiceContainerSummary = { containerId: string; name: string; image: string; state: string; status: string; created: string; composeProject: string; composeService: string;};
export type ServiceInstanceDetail = ServiceInstanceSummary & { containers: ServiceContainerSummary[];};
export type NodeSummary = { nodeId: string; displayName: string; enabled: boolean; isOnline: boolean; lastHeartbeat: string; actions: NodeActionCapabilities;};
export type TaskSummary = { taskId: string; type: string; status: string; serviceName: string; nodeId: string; createdAt: string;};
export type TaskStepSummary = { stepName: string; status: string; startedAt: string; finishedAt: string;};
export type TaskDetail = { taskId: string; type: string; source: string; serviceName: string; nodeId: string; status: string; createdAt: string; startedAt: string; finishedAt: string; repoRevision: string; errorSummary: string; triggeredBy: string; resultRevision: string; attemptOfTaskId: string; steps: TaskStepSummary[];};
export type NodeDockerStats = { containersTotal: number; containersRunning: number; containersStopped: number; containersPaused: number; images: number; networks: number; volumes: number; volumesSizeBytes: number; disksUsageBytes: number; dockerServerVersion: string;};
export type ServiceDetail = { name: string; runtimeStatus: string; updatedAt: string; nodes: string[]; enabled: boolean; directory: string; instances: ServiceInstanceDetail[]; actions: ServiceActionCapabilities;};
export type TaskActionResult = { taskId: string; status: string; repoRevision: string;};
export type ServiceActionResult = { tasks: TaskActionResult[]; repoWrite?: RepoWriteResult;};
export type ImageUpdateCheckSummary = { serviceName: string; nodeId: string; imageName: string; imageRef: string; policyType: string; currentValue: string; currentTag: string; currentDigest: string; candidateTag: string; candidateDigest: string; candidateTags: string[]; updateAvailable: boolean; checkStatus: string; errorSummary: string; checkedAt: string;};
export type ImageUpdateSelection = { imageName: string; targetTag?: string; useDetected?: boolean;};
export type ContainerRemoveOptions = { force?: boolean; removeVolumes?: boolean;};
export type ServiceAction = | "deploy" | "update" | "stop" | "restart" | "backup" | "image_check" | "dns_update" | "caddy_sync" | "cloudflare_tunnel_sync";
export type ComposeRecreateMode = "auto" | "no_recreate" | "force_recreate";
export async function migrateService( serviceName: string, sourceNodeId: string, targetNodeId: string,): Promise<TaskActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.ServiceCommandService/MigrateService", ), { serviceName, sourceNodeId, targetNodeId }, );}
export type BackupSummary = { backupId: string; taskId: string; serviceName: string; nodeId: string; dataName: string; status: string; startedAt: string; finishedAt: string;};
export type BackupDetail = BackupSummary & { nodeId: string; artifactRef: string; errorSummary: string; actions: BackupActionCapabilities;};
export type RepoFileEntry = { path: string; name: string; isDir: boolean; size: number;};
export type RepoFileContent = { path: string; content: string; size: number;};
export type RepoHead = { branch: string; headRevision: string; cleanWorktree: boolean; hasRemote: boolean; syncStatus: string; lastSyncError: string; lastSuccessfulPullAt: string;};
export type RepoWriteResult = { commitId: string; syncStatus: string; pushError: string; lastSuccessfulPullAt: string;};
export type RepoSyncResult = { headRevision: string; branch: string; syncStatus: string; lastSyncError: string; lastSuccessfulPullAt: string;};
export type RepoCommitSummary = { commitId: string; subject: string; committedAt: string;};
export type DashboardData = { system: SystemStatus; services: ServiceSummary[]; nodes: NodeSummary[]; tasks: TaskSummary[]; backups: BackupSummary[]; totalTaskCount: number; totalBackupCount: number;};
export function controllerConfig() { const baseUrl = env.WEB_CONTROLLER_ADDR?.trim(); const token = env.WEB_CONTROLLER_ACCESS_TOKEN?.trim(); let headers: Record<string, string>; try { headers = parseControllerHeaders(); } catch (error) { return { ready: false as const, reason: error instanceof Error ? error.message : "Invalid controller headers.", }; }
if (!baseUrl || !token) { return { ready: false as const, reason: "Set WEB_CONTROLLER_ADDR and WEB_CONTROLLER_ACCESS_TOKEN in the web server environment.", }; }
return { ready: true as const, baseUrl: baseUrl.replace(/\/$/, ""), token, headers, };}
const reservedControllerHeaderNames = new Set([ "authorization", "connect-protocol-version", "connect-timeout-ms", "content-type",]);
function parseControllerHeaders(): Record<string, string> { const inline = env.WEB_CONTROLLER_HEADERS?.trim(); const file = env.WEB_CONTROLLER_HEADERS_FILE?.trim(); if (inline && file) { throw new Error( "Set only one of WEB_CONTROLLER_HEADERS or WEB_CONTROLLER_HEADERS_FILE.", ); } const raw = file ? readFileSync(file, "utf8").trim() : inline; if (!raw) { return {}; } const parsed = JSON.parse(raw) as Record<string, unknown>; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("WEB_CONTROLLER_HEADERS must be a JSON object."); } const headers: Record<string, string> = {}; for (const [name, value] of Object.entries(parsed)) { const normalizedName = name.trim(); if ( !normalizedName || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(normalizedName) ) { throw new Error(`Invalid WEB_CONTROLLER_HEADERS header name: ${name}`); } if (reservedControllerHeaderNames.has(normalizedName.toLowerCase())) { throw new Error( `WEB_CONTROLLER_HEADERS header ${normalizedName} is reserved.`, ); } if (typeof value !== "string" || !value.trim()) { throw new Error( `WEB_CONTROLLER_HEADERS header ${normalizedName} must be a non-empty string.`, ); } headers[normalizedName] = value.trim(); } return headers;}
function parseCapability( capability?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; } | null,): Capability { return { enabled: capability?.enabled === true, reasonCode: normalizeCapabilityReasonCode( capability?.reasonCode ?? capability?.reason_code, ), };}
function normalizeEnumSuffix(prefix: string, value: unknown): string { if (typeof value !== "string") { return ""; } const normalized = value.trim(); if (!normalized) { return ""; } if (normalized.startsWith(prefix)) { return normalized.slice(prefix.length).toLowerCase(); } return normalized.toLowerCase();}
function normalizeTaskStatus(value: unknown): string { return normalizeEnumSuffix("TASK_STATUS_", value);}
function normalizeTaskType(value: unknown): string { return normalizeEnumSuffix("TASK_TYPE_", value);}
function normalizeTaskSource(value: unknown): string { return normalizeEnumSuffix("TASK_SOURCE_", value);}
function normalizeTaskStepName(value: unknown): string { return normalizeEnumSuffix("TASK_STEP_NAME_", value);}
function normalizeCapabilityReasonCode(value: unknown): string { return normalizeEnumSuffix("CAPABILITY_REASON_CODE_", value);}
function encodeTaskStatus(value: string): string { return `TASK_STATUS_${value.trim().toUpperCase()}`;}
function encodeTaskType(value: string): string { return `TASK_TYPE_${value.trim().toUpperCase()}`;}
function encodeTaskDecision(value: "approve" | "reject"): string { return value === "approve" ? "TASK_CONFIRMATION_DECISION_APPROVE" : "TASK_CONFIRMATION_DECISION_REJECT";}
function parseServiceActionCapabilities( actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; cloudflareTunnelSync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; cloudflare_tunnel_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null,): ServiceActionCapabilities { return { backup: parseCapability(actions?.backup), restore: parseCapability(actions?.restore), migrate: parseCapability(actions?.migrate), dnsUpdate: parseCapability(actions?.dnsUpdate ?? actions?.dns_update), caddySync: parseCapability(actions?.caddySync ?? actions?.caddy_sync), cloudflareTunnelSync: parseCapability( actions?.cloudflareTunnelSync ?? actions?.cloudflare_tunnel_sync, ), };}
function parseNodeActionCapabilities( actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null,): NodeActionCapabilities { return { caddySync: parseCapability(actions?.caddySync ?? actions?.caddy_sync), caddyReload: parseCapability(actions?.caddyReload ?? actions?.caddy_reload), rusticMaintenance: parseCapability( actions?.rusticMaintenance ?? actions?.rustic_maintenance, ), };}
function parseBackupActionCapabilities( actions?: { restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null,): BackupActionCapabilities { return { restore: parseCapability(actions?.restore), };}
function parseGlobalCapabilities( global?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; dns?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; secrets?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; } | null,): GlobalCapabilities { return { backup: parseCapability(global?.backup), dns: parseCapability(global?.dns), secrets: parseCapability(global?.secrets), rusticMaintenance: parseCapability( global?.rusticMaintenance ?? global?.rustic_maintenance, ), };}
export async function loadDashboard(): Promise<DashboardData> { const config = controllerConfig(); if (!config.ready) { throw new Error(config.reason); }
const [system, tasksResult, backupsResult] = await Promise.all([ loadSystemStatus(), loadTasks(1, 6), loadBackups(1, 6), ]);
return { system, services: [], nodes: [], tasks: tasksResult.items, backups: backupsResult.items, totalTaskCount: tasksResult.totalCount, totalBackupCount: backupsResult.totalCount, };}
export async function loadSystemStatus(): Promise<SystemStatus> { const config = requireControllerConfig(); return rpcCall<SystemStatus>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetSystemStatus", ), {}, );}
export async function loadCurrentConfig(): Promise<CurrentConfig> { const config = requireControllerConfig(); const response = await rpcCall<{ listenAddr?: string; listen_addr?: string; git?: { remoteUrl?: string; remote_url?: string; branch?: string; pullInterval?: string; pull_interval?: string; hasAuth?: boolean; has_auth?: boolean; authorName?: string; author_name?: string; authorEmail?: string; author_email?: string; }; nodes?: Array<{ id?: string; displayName?: string; display_name?: string; enabled?: boolean; publicIpv4?: string; public_ipv4?: string; publicIpv6?: string; public_ipv6?: string; }>; accessTokens?: Array<{ name?: string; enabled?: boolean; comment?: string; }>; access_tokens?: Array<{ name?: string; enabled?: boolean; comment?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetCurrentConfig", ), {}, );
const rawGit = response.git; const rawTokens = response.accessTokens ?? response.access_tokens;
return { listenAddr: response.listenAddr ?? response.listen_addr ?? "", git: rawGit ? { remoteUrl: rawGit.remoteUrl ?? rawGit.remote_url ?? "", branch: rawGit.branch ?? "", pullInterval: rawGit.pullInterval ?? rawGit.pull_interval ?? "", hasAuth: rawGit.hasAuth ?? rawGit.has_auth ?? false, authorName: rawGit.authorName ?? rawGit.author_name ?? "", authorEmail: rawGit.authorEmail ?? rawGit.author_email ?? "", } : undefined, nodes: (response.nodes ?? []).map((node) => ({ id: node.id ?? "", displayName: node.displayName ?? node.display_name ?? "", enabled: node.enabled ?? false, publicIpv4: node.publicIpv4 ?? node.public_ipv4 ?? "", publicIpv6: node.publicIpv6 ?? node.public_ipv6 ?? "", })), accessTokens: (rawTokens ?? []).map((token) => ({ name: token.name ?? "", enabled: token.enabled ?? false, comment: token.comment ?? "", })), };}
export async function reloadControllerConfig(): Promise<{ accepted: boolean }> { const config = requireControllerConfig(); return rpcCall<{ accepted: boolean }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/ReloadControllerConfig", ), {}, );}
export async function loadEditableControllerConfig(): Promise<EditableControllerConfig> { const config = requireControllerConfig(); const response = await rpcCall<{ yaml?: string; revision?: string; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ControllerConfigService/GetEditableConfig", ), {}, ); return { yaml: response.yaml ?? "", revision: response.revision ?? "" };}
export async function updateEditableControllerConfig( yaml: string, baseRevision: string,): Promise<{ revision: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ revision?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ControllerConfigService/UpdateEditableConfig", ), { yaml, baseRevision }, ); return { revision: response.revision ?? "" };}
export async function loadSystemCapabilities(): Promise<SystemCapabilities> { const config = requireControllerConfig(); const response = await rpcCall<{ global?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown }; secrets?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.SystemService/GetCapabilities", ), {}, ); return { global: parseGlobalCapabilities(response.global), };}
export async function loadServices( page = 1, pageSize = 50,): Promise<PaginatedResult<ServiceSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ services?: Array< ServiceSummary & { instance_count?: number; running_count?: number; target_node_count?: number; } >; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/ListServices", ), { page, pageSize }, ); return { items: (response.services ?? []).map((service) => ({ ...service, instanceCount: service.instanceCount ?? service.instance_count ?? 0, runningCount: service.runningCount ?? service.running_count ?? 0, targetNodeCount: service.targetNodeCount ?? service.target_node_count ?? 0, })), totalCount: response.totalCount ?? 0, };}
export async function loadServiceWorkspaces(): Promise< ServiceWorkspaceSummary[]> { const config = requireControllerConfig(); const response = await rpcCall<{ workspaces?: Array<{ folder: string; displayName?: string; display_name?: string; serviceName?: string; service_name?: string; hasMeta?: boolean; has_meta?: boolean; isDeclared?: boolean; is_declared?: boolean; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; nodes?: string[]; enabled?: boolean; actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/ListServiceWorkspaces", ), {}, ); return (response.workspaces ?? []).map((workspace) => ({ folder: workspace.folder, displayName: workspace.displayName ?? workspace.display_name ?? workspace.folder, serviceName: workspace.serviceName ?? workspace.service_name ?? "", hasMeta: workspace.hasMeta ?? workspace.has_meta ?? false, isDeclared: workspace.isDeclared ?? workspace.is_declared ?? false, runtimeStatus: workspace.runtimeStatus ?? workspace.runtime_status ?? "unknown", updatedAt: workspace.updatedAt ?? workspace.updated_at ?? "", nodes: workspace.nodes ?? [], enabled: workspace.enabled ?? false, actions: parseServiceActionCapabilities(workspace.actions), }));}
export async function loadServiceWorkspace( folder: string,): Promise<ServiceWorkspaceSummary | null> { const config = requireControllerConfig(); const response = await rpcCall<{ workspace?: { folder: string; displayName?: string; display_name?: string; serviceName?: string; service_name?: string; hasMeta?: boolean; has_meta?: boolean; isDeclared?: boolean; is_declared?: boolean; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; nodes?: string[]; enabled?: boolean; actions?: { backup?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; migrate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dnsUpdate?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; dns_update?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/GetServiceWorkspace", ), { folder }, ); if (!response.workspace) { return null; } const workspace = response.workspace; return { folder: workspace.folder, displayName: workspace.displayName ?? workspace.display_name ?? workspace.folder, serviceName: workspace.serviceName ?? workspace.service_name ?? "", hasMeta: workspace.hasMeta ?? workspace.has_meta ?? false, isDeclared: workspace.isDeclared ?? workspace.is_declared ?? false, runtimeStatus: workspace.runtimeStatus ?? workspace.runtime_status ?? "unknown", updatedAt: workspace.updatedAt ?? workspace.updated_at ?? "", nodes: workspace.nodes ?? [], enabled: workspace.enabled ?? false, actions: parseServiceActionCapabilities(workspace.actions), };}
export async function loadNodes(): Promise<NodeSummary[]> { const config = requireControllerConfig(); const response = await rpcCall<{ nodes?: Array<{ nodeId?: string; node_id?: string; displayName?: string; display_name?: string; enabled?: boolean; isOnline?: boolean; is_online?: boolean; lastHeartbeat?: string; last_heartbeat?: string; actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.NodeQueryService/ListNodes"), {}, ); return (response.nodes ?? []).map((node) => ({ nodeId: node.nodeId ?? node.node_id ?? "", displayName: node.displayName ?? node.display_name ?? node.nodeId ?? node.node_id ?? "", enabled: node.enabled ?? false, isOnline: node.isOnline ?? node.is_online ?? false, lastHeartbeat: node.lastHeartbeat ?? node.last_heartbeat ?? "", actions: parseNodeActionCapabilities(node.actions), }));}
export type TaskFilter = { serviceName?: string[]; nodeId?: string[]; status?: string[]; type?: string[]; excludeServiceName?: string[]; excludeNodeId?: string[]; excludeStatus?: string[]; excludeType?: string[];};
export type BackupFilter = { serviceName?: string[]; status?: string[]; dataName?: string[]; nodeId?: string[]; excludeServiceName?: string[]; excludeStatus?: string[]; excludeDataName?: string[]; excludeNodeId?: string[];};
export async function loadTasks( page = 1, pageSize = 50, filter?: TaskFilter,): Promise<PaginatedResult<TaskSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ tasks?: Array<{ taskId?: string; task_id?: string; type?: unknown; status?: unknown; serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; createdAt?: string; created_at?: string; }>; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.TaskService/ListTasks"), { page, pageSize, status: (filter?.status ?? []).map(encodeTaskStatus), service_name: filter?.serviceName ?? [], node_id: filter?.nodeId ?? [], type: (filter?.type ?? []).map(encodeTaskType), exclude_status: (filter?.excludeStatus ?? []).map(encodeTaskStatus), exclude_service_name: filter?.excludeServiceName ?? [], exclude_node_id: filter?.excludeNodeId ?? [], exclude_type: (filter?.excludeType ?? []).map(encodeTaskType), }, ); return { items: (response.tasks ?? []).map((task) => ({ taskId: task.taskId ?? task.task_id ?? "", type: normalizeTaskType(task.type), status: normalizeTaskStatus(task.status), serviceName: task.serviceName ?? task.service_name ?? "", nodeId: task.nodeId ?? task.node_id ?? "", createdAt: task.createdAt ?? task.created_at ?? "", })), totalCount: response.totalCount ?? 0, };}
export async function loadBackups( page = 1, pageSize = 100, filter?: BackupFilter,): Promise<PaginatedResult<BackupSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ backups?: BackupSummary[]; totalCount?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.BackupQueryService/ListBackups", ), { page, pageSize, service_name: filter?.serviceName ?? [], status: filter?.status ?? [], data_name: filter?.dataName ?? [], node_id: filter?.nodeId ?? [], exclude_service_name: filter?.excludeServiceName ?? [], exclude_status: filter?.excludeStatus ?? [], exclude_data_name: filter?.excludeDataName ?? [], exclude_node_id: filter?.excludeNodeId ?? [], }, ); return { items: response.backups ?? [], totalCount: response.totalCount ?? 0, };}
export async function loadBackupDetail( backupId: string,): Promise<BackupDetail> { const config = requireControllerConfig(); const response = await rpcCall<{ backupId?: string; backup_id?: string; taskId?: string; task_id?: string; serviceName?: string; service_name?: string; dataName?: string; data_name?: string; nodeId?: string; node_id?: string; status?: string; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; artifactRef?: string; artifact_ref?: string; errorSummary?: string; error_summary?: string; actions?: { restore?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.BackupQueryService/GetBackup"), { backupId }, ); return { backupId: response.backupId ?? response.backup_id ?? backupId, taskId: response.taskId ?? response.task_id ?? "", serviceName: response.serviceName ?? response.service_name ?? "", dataName: response.dataName ?? response.data_name ?? "", nodeId: response.nodeId ?? response.node_id ?? "", status: response.status ?? "", startedAt: response.startedAt ?? response.started_at ?? "", finishedAt: response.finishedAt ?? response.finished_at ?? "", artifactRef: response.artifactRef ?? response.artifact_ref ?? "", errorSummary: response.errorSummary ?? response.error_summary ?? "", actions: parseBackupActionCapabilities(response.actions), };}
export async function restoreBackup( backupId: string, nodeId: string,): Promise<TaskActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.BackupCommandService/RestoreBackup", ), { backupId, nodeId, }, );}
export async function loadRepoHead(): Promise<RepoHead> { const config = requireControllerConfig(); return rpcCall<RepoHead>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoQueryService/GetRepoHead"), {}, );}
export async function listRepoCommits( pageSize: number, cursor = "",): Promise<{ commits: RepoCommitSummary[]; nextCursor: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ commits?: RepoCommitSummary[]; nextCursor?: string; next_cursor?: string; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoQueryService/ListRepoCommits", ), { pageSize, cursor }, ); return { commits: response.commits ?? [], nextCursor: response.nextCursor ?? response.next_cursor ?? "", };}
export async function loadRepoEntries( path = "", options: { recursive?: boolean } = {},): Promise<RepoFileEntry[]> { const config = requireControllerConfig(); const response = await rpcCall<{ entries?: RepoFileEntry[] }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoQueryService/ListRepoFiles", ), { path, recursive: options.recursive ?? false }, ); return response.entries ?? [];}
export async function loadRepoFile(path: string): Promise<RepoFileContent> { const config = requireControllerConfig(); return rpcCall<RepoFileContent>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoQueryService/GetRepoFile"), { path }, );}
export async function updateRepoFile( path: string, content: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/UpdateRepoFile", ), { path, content, baseRevision, commitMessage }, );}
export async function createRepoDirectory( path: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/CreateRepoDirectory", ), { path, baseRevision, commitMessage }, );}
export async function moveRepoPath( sourcePath: string, destinationPath: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/MoveRepoPath", ), { sourcePath, destinationPath, baseRevision, commitMessage }, );}
export async function deleteRepoPath( path: string, baseRevision: string, commitMessage = "",): Promise<RepoWriteResult> { const config = requireControllerConfig(); return rpcCall<RepoWriteResult>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.RepoCommandService/DeleteRepoPath", ), { path, baseRevision, commitMessage }, );}
export async function syncRepo(): Promise<RepoSyncResult> { const config = requireControllerConfig(); return rpcCall<RepoSyncResult>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.RepoCommandService/SyncRepo"), {}, );}
export async function loadServiceInstances( serviceName: string,): Promise<ServiceInstanceDetail[]> { const config = requireControllerConfig(); const response = await rpcCall<{ instances?: Array<{ serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; isDeclared?: boolean; is_declared?: boolean; consistency?: ServiceConsistencyResponse; pendingDeployRevision?: string; pending_deploy_revision?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceInstanceService/ListServiceInstances", ), { serviceName }, ); return (response.instances ?? []).map((instance) => ({ serviceName: instance.serviceName ?? instance.service_name ?? serviceName, nodeId: instance.nodeId ?? instance.node_id ?? "", runtimeStatus: instance.runtimeStatus ?? instance.runtime_status ?? "unknown", updatedAt: instance.updatedAt ?? instance.updated_at ?? "", isDeclared: instance.isDeclared ?? instance.is_declared ?? false, pendingDeployRevision: instance.pendingDeployRevision ?? instance.pending_deploy_revision ?? "", consistency: serviceConsistency(instance.consistency), containers: [], }));}
export async function loadServiceInstance( serviceName: string, nodeId: string, includeContainers = false,): Promise<ServiceInstanceDetail | null> { const config = requireControllerConfig(); const response = await rpcCall<{ instance?: { serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; runtimeStatus?: string; runtime_status?: string; updatedAt?: string; updated_at?: string; isDeclared?: boolean; is_declared?: boolean; consistency?: ServiceConsistencyResponse; pendingDeployRevision?: string; pending_deploy_revision?: string; containers?: Array<{ containerId?: string; container_id?: string; name?: string; image?: string; state?: string; status?: string; created?: string; composeProject?: string; compose_project?: string; composeService?: string; compose_service?: string; }>; }; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceInstanceService/GetServiceInstance", ), { serviceName, nodeId, includeContainers }, ); const instance = response.instance; if (!instance) { return null; } return { serviceName: instance.serviceName ?? instance.service_name ?? serviceName, nodeId: instance.nodeId ?? instance.node_id ?? nodeId, runtimeStatus: instance.runtimeStatus ?? instance.runtime_status ?? "unknown", updatedAt: instance.updatedAt ?? instance.updated_at ?? "", isDeclared: instance.isDeclared ?? instance.is_declared ?? false, pendingDeployRevision: instance.pendingDeployRevision ?? instance.pending_deploy_revision ?? "", consistency: serviceConsistency(instance.consistency), containers: (instance.containers ?? []).map((container) => ({ containerId: container.containerId ?? container.container_id ?? "", name: container.name ?? "", image: container.image ?? "", state: container.state ?? "unknown", status: container.status ?? "", created: container.created ?? "", composeProject: container.composeProject ?? container.compose_project ?? "", composeService: container.composeService ?? container.compose_service ?? "", })), };}
export async function runServiceAction( serviceName: string, action: ServiceAction, options: { nodeIds?: string[]; dataNames?: string[]; composeRecreateMode?: ComposeRecreateMode; imageUpdates?: ImageUpdateSelection[]; useAllDetectedImageUpdates?: boolean; backupBeforeUpdate?: boolean; baseRevision?: string; commitMessage?: string; } = {},): Promise<ServiceActionResult> { return callServiceAction( controllerProcedure( "/composia.controller.v1.ServiceCommandService/RunServiceAction", ), { serviceName, action: toServiceActionEnum(action), nodeIds: options.nodeIds ?? [], dataNames: options.dataNames ?? [], composeRecreateMode: toComposeRecreateModeEnum( options.composeRecreateMode ?? "auto", ), imageUpdates: (options.imageUpdates ?? []).map((update) => ({ imageName: update.imageName, targetTag: update.targetTag ?? "", useDetected: update.useDetected ?? false, })), useAllDetectedImageUpdates: options.useAllDetectedImageUpdates ?? false, backupBeforeUpdate: options.backupBeforeUpdate, baseRevision: options.baseRevision ?? "", commitMessage: options.commitMessage ?? "", }, );}
export async function loadServiceImageUpdateChecks( serviceName: string, nodeId?: string,): Promise<ImageUpdateCheckSummary[]> { const config = requireControllerConfig(); const response = await rpcCall<{ checks?: Array<{ serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; imageName?: string; image_name?: string; imageRef?: string; image_ref?: string; policyType?: string; policy_type?: string; currentValue?: string; current_value?: string; currentTag?: string; current_tag?: string; currentDigest?: string; current_digest?: string; candidateTag?: string; candidate_tag?: string; candidateDigest?: string; candidate_digest?: string; candidateTags?: string[]; candidate_tags?: string[]; updateAvailable?: boolean; update_available?: boolean; checkStatus?: string; check_status?: string; errorSummary?: string; error_summary?: string; checkedAt?: string; checked_at?: string; }>; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.ServiceQueryService/GetServiceImageUpdateChecks", ), { serviceName, nodeId: nodeId ?? "" }, ); return (response.checks ?? []).map((check) => ({ serviceName: check.serviceName ?? check.service_name ?? serviceName, nodeId: check.nodeId ?? check.node_id ?? "", imageName: check.imageName ?? check.image_name ?? "", imageRef: check.imageRef ?? check.image_ref ?? "", policyType: check.policyType ?? check.policy_type ?? "", currentValue: check.currentValue ?? check.current_value ?? "", currentTag: check.currentTag ?? check.current_tag ?? "", currentDigest: check.currentDigest ?? check.current_digest ?? "", candidateTag: check.candidateTag ?? check.candidate_tag ?? "", candidateDigest: check.candidateDigest ?? check.candidate_digest ?? "", candidateTags: check.candidateTags ?? check.candidate_tags ?? [], updateAvailable: check.updateAvailable ?? check.update_available ?? false, checkStatus: check.checkStatus ?? check.check_status ?? "unknown", errorSummary: check.errorSummary ?? check.error_summary ?? "", checkedAt: check.checkedAt ?? check.checked_at ?? "", }));}
export async function loadNodeDetail( nodeId: string,): Promise<NodeSummary | null> { const config = requireControllerConfig(); const response = await rpcCall<{ node?: { nodeId?: string; node_id?: string; displayName?: string; display_name?: string; enabled?: boolean; isOnline?: boolean; is_online?: boolean; lastHeartbeat?: string; last_heartbeat?: string; actions?: { caddySync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_sync?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddyReload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; caddy_reload?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rusticMaintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; rustic_maintenance?: { enabled?: unknown; reasonCode?: unknown; reason_code?: unknown; }; }; }; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.NodeQueryService/GetNode"), { nodeId }, ); if (!response.node) { return null; } return { nodeId: response.node.nodeId ?? response.node.node_id ?? nodeId, displayName: response.node.displayName ?? response.node.display_name ?? response.node.nodeId ?? response.node.node_id ?? nodeId, enabled: response.node.enabled ?? false, isOnline: response.node.isOnline ?? response.node.is_online ?? false, lastHeartbeat: response.node.lastHeartbeat ?? response.node.last_heartbeat ?? "", actions: parseNodeActionCapabilities(response.node.actions), };}
export async function loadNodeDockerStats( nodeId: string,): Promise<NodeDockerStats | null> { const config = requireControllerConfig(); const response = await rpcCall<{ stats?: NodeDockerStats }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeQueryService/GetNodeDockerStats", ), { nodeId }, ); return response.stats ?? null;}
export async function pruneNodeDocker( nodeId: string, target = "all",): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/PruneNodeDocker", ), { nodeId, target }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export async function forgetNodeRustic( options: { nodeId?: string; serviceName?: string; dataName?: string; } = {},): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/ForgetNodeRustic", ), { nodeId: options.nodeId ?? "", serviceName: options.serviceName ?? "", dataName: options.dataName ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export async function initNodeRustic( options: { nodeId?: string; } = {},): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/InitNodeRustic", ), { nodeId: options.nodeId ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export async function pruneNodeRustic( options: { nodeId?: string; serviceName?: string; dataName?: string; } = {},): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/PruneNodeRustic", ), { nodeId: options.nodeId ?? "", serviceName: options.serviceName ?? "", dataName: options.dataName ?? "", }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export async function reloadNodeCaddy( nodeId: string,): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/ReloadNodeCaddy", ), { nodeId }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export async function syncNodeCaddyFiles( nodeId: string, options: { serviceName?: string; fullRebuild?: boolean } = {},): Promise<{ taskId: string }> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.NodeMaintenanceService/SyncNodeCaddyFiles", ), { nodeId, serviceName: options.serviceName ?? "", fullRebuild: options.fullRebuild ?? false, }, ); return { taskId: response.taskId ?? response.task_id ?? "" };}
export type DockerContainerSummary = { id: string; name: string; image: string; state: string; status: string; created: string; labels: Record<string, string>; ports: string[]; networks: string[]; imageId: string;};
export type ContainerActionResult = TaskActionResult;
export type ContainerAction = "start" | "stop" | "restart";
export type ContainerExecSession = { sessionId: string; websocketPath: string;};
export type DockerNetworkSummary = { id: string; name: string; driver: string; scope: string; internal: boolean; attachable: boolean; created: string; labels: Record<string, string>; subnet: string; gateway: string; containersCount: number; ipv6Enabled: boolean;};
export type DockerVolumeSummary = { name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record<string, string>; sizeBytes: number; containersCount: number; inUse: boolean;};
export type DockerImageSummary = { id: string; repoTags: string[]; size: number; created: string; repoDigests: string[]; virtualSize: number; architecture: string; os: string; author: string; containersCount: number; isDangling: boolean;};
export type DockerListQuery = { page?: number; pageSize?: number; search?: string; sortBy?: string; sortDesc?: boolean;};
export type DockerVolumeListQuery = DockerListQuery & { includeSizes?: boolean;};
const dockerVolumeSizeRpcTimeoutMs = 310_000;
export function controllerErrorStatus(error: unknown, fallback = 500): number { if (error instanceof ControllerRpcError) { return error.status; } return fallback;}
export function controllerErrorCode(error: unknown): string | null { if (error instanceof ControllerRpcError) { return error.code; } return null;}
export function controllerErrorMessage( error: unknown, fallback: string,): string { if (error instanceof ControllerRpcError) { return error.message; } if (error instanceof Error) { return error.message; } return fallback;}
function toNumber(value: unknown): number { if (typeof value === "number") { return value; } if (typeof value === "string") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } return 0;}
export async function listNodeContainers( nodeId: string, query: DockerListQuery = {},): Promise<PaginatedResult<DockerContainerSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ containers?: Array<{ id: string; name: string; image: string; state: string; status: string; created: string; labels: Record<string, string>; ports: string[]; networks: string[]; imageId?: string; image_id?: string; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeContainers", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.containers ?? []).map((c) => ({ id: c.id, name: c.name, image: c.image, state: c.state, status: c.status, created: c.created, labels: c.labels, ports: c.ports ?? [], networks: c.networks ?? [], imageId: c.imageId ?? c.image_id ?? "", })), totalCount: response.totalCount ?? response.total_count ?? 0, };}
export async function inspectNodeContainer( nodeId: string, containerId: string,): Promise<string> { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeContainer", ), { nodeId, containerId }, ); return response.rawJson ?? response.raw_json ?? "{}";}
export async function runContainerAction( nodeId: string, containerId: string, action: ContainerAction,): Promise<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RunContainerAction", ), { nodeId, containerId, action: toContainerActionEnum(action) }, );}
export async function removeNodeContainer( nodeId: string, containerId: string, options: ContainerRemoveOptions = {},): Promise<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveContainer", ), { nodeId, containerId, force: options.force ?? false, removeVolumes: options.removeVolumes ?? false, }, );}
export async function openContainerExec( nodeId: string, containerId: string, command: string[] = [], rows = 24, cols = 80, browserOrigin: string,): Promise<ContainerExecSession> { const config = requireControllerConfig(); return rpcCall<ContainerExecSession>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerCommandService/OpenContainerExec", ), { nodeId, containerId, command, rows, cols }, { "X-Composia-Web-Origin": browserOrigin }, );}
export async function listNodeNetworks( nodeId: string, query: DockerListQuery = {},): Promise<PaginatedResult<DockerNetworkSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ networks?: Array<{ id: string; name: string; driver: string; scope: string; internal: boolean; attachable: boolean; created: string; labels: Record<string, string>; subnet: string; gateway: string; containersCount?: number; containers_count?: number; ipv6Enabled?: boolean; ipv6_enabled?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeNetworks", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.networks ?? []).map((n) => ({ id: n.id, name: n.name, driver: n.driver, scope: n.scope, internal: n.internal, attachable: n.attachable, created: n.created, labels: n.labels, subnet: n.subnet, gateway: n.gateway, containersCount: n.containersCount ?? n.containers_count ?? 0, ipv6Enabled: n.ipv6Enabled ?? n.ipv6_enabled ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, };}
export async function inspectNodeNetwork( nodeId: string, networkId: string,): Promise<string> { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeNetwork", ), { nodeId, networkId }, ); return response.rawJson ?? response.raw_json ?? "{}";}
export async function removeNodeNetwork( nodeId: string, networkId: string,): Promise<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveNetwork", ), { nodeId, networkId }, );}
export async function listNodeVolumes( nodeId: string, query: DockerVolumeListQuery = {},): Promise<PaginatedResult<DockerVolumeSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ volumes?: Array<{ name: string; driver: string; mountpoint: string; scope: string; created: string; labels: Record<string, string>; sizeBytes?: number | string; size_bytes?: number | string; containersCount?: number; containers_count?: number; inUse?: boolean; in_use?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeVolumes", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, includeSizes: query.includeSizes ?? false, }, {}, query.includeSizes ? dockerVolumeSizeRpcTimeoutMs : undefined, ); return { items: (response.volumes ?? []).map((v) => ({ name: v.name, driver: v.driver, mountpoint: v.mountpoint, scope: v.scope, created: v.created, labels: v.labels, sizeBytes: toNumber(v.sizeBytes ?? v.size_bytes), containersCount: v.containersCount ?? v.containers_count ?? 0, inUse: v.inUse ?? v.in_use ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, };}
export async function inspectNodeVolume( nodeId: string, volumeName: string,): Promise<string> { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeVolume", ), { nodeId, volumeName }, ); return response.rawJson ?? response.raw_json ?? "{}";}
export async function removeNodeVolume( nodeId: string, volumeName: string,): Promise<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveVolume", ), { nodeId, volumeName }, );}
export async function listNodeImages( nodeId: string, query: DockerListQuery = {},): Promise<PaginatedResult<DockerImageSummary>> { const config = requireControllerConfig(); const response = await rpcCall<{ images?: Array<{ id: string; repoTags?: string[]; repo_tags?: string[]; size: number | string; created: string; repoDigests?: string[]; repo_digests?: string[]; virtualSize?: number | string; virtual_size?: number | string; architecture: string; os: string; author: string; containersCount?: number; containers_count?: number; isDangling?: boolean; is_dangling?: boolean; }>; totalCount?: number; total_count?: number; }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/ListNodeImages", ), { nodeId, page: query.page ?? 0, pageSize: query.pageSize ?? 0, search: query.search ?? "", sortBy: query.sortBy ?? "", sortDesc: query.sortDesc ?? false, }, ); return { items: (response.images ?? []).map((i) => ({ id: i.id, repoTags: i.repoTags ?? i.repo_tags ?? [], size: toNumber(i.size), created: i.created, repoDigests: i.repoDigests ?? i.repo_digests ?? [], virtualSize: toNumber(i.virtualSize ?? i.virtual_size), architecture: i.architecture, os: i.os, author: i.author, containersCount: i.containersCount ?? i.containers_count ?? 0, isDangling: i.isDangling ?? i.is_dangling ?? false, })), totalCount: response.totalCount ?? response.total_count ?? 0, };}
export async function inspectNodeImage( nodeId: string, imageId: string,): Promise<string> { const config = requireControllerConfig(); const response = await rpcCall<{ rawJson?: string; raw_json?: string }>( config.baseUrl, config.token, controllerProcedure( "/composia.controller.v1.DockerQueryService/InspectNodeImage", ), { nodeId, imageId }, ); return response.rawJson ?? response.raw_json ?? "{}";}
export async function removeNodeImage( nodeId: string, imageId: string, force = false,): Promise<ContainerActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.DockerCommandService/RemoveImage", ), { nodeId, imageId, force }, );}
export async function loadTaskDetail(taskId: string): Promise<TaskDetail> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string; type?: unknown; source?: unknown; serviceName?: string; service_name?: string; nodeId?: string; node_id?: string; status?: unknown; createdAt?: string; created_at?: string; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; repoRevision?: string; repo_revision?: string; errorSummary?: string; error_summary?: string; triggeredBy?: string; triggered_by?: string; resultRevision?: string; result_revision?: string; attemptOfTaskId?: string; attempt_of_task_id?: string; steps?: Array<{ stepName?: unknown; step_name?: unknown; status?: unknown; startedAt?: string; started_at?: string; finishedAt?: string; finished_at?: string; }>; }>( config.baseUrl, config.token, controllerProcedure("/composia.controller.v1.TaskService/GetTask"), { taskId }, ); return { taskId: response.taskId ?? response.task_id ?? taskId, type: normalizeTaskType(response.type), source: normalizeTaskSource(response.source), serviceName: response.serviceName ?? response.service_name ?? "", nodeId: response.nodeId ?? response.node_id ?? "", status: normalizeTaskStatus(response.status), createdAt: response.createdAt ?? response.created_at ?? "", startedAt: response.startedAt ?? response.started_at ?? "", finishedAt: response.finishedAt ?? response.finished_at ?? "", repoRevision: response.repoRevision ?? response.repo_revision ?? "", errorSummary: response.errorSummary ?? response.error_summary ?? "", triggeredBy: response.triggeredBy ?? response.triggered_by ?? "", resultRevision: response.resultRevision ?? response.result_revision ?? "", attemptOfTaskId: response.attemptOfTaskId ?? response.attempt_of_task_id ?? "", steps: (response.steps ?? []).map((step) => ({ stepName: normalizeTaskStepName(step.stepName ?? step.step_name), status: normalizeTaskStatus(step.status), startedAt: step.startedAt ?? step.started_at ?? "", finishedAt: step.finishedAt ?? step.finished_at ?? "", })), };}
export async function runTaskAgain(taskId: string): Promise<TaskActionResult> { return callTaskAction( controllerProcedure("/composia.controller.v1.TaskService/RunTaskAgain"), { taskId, }, );}
export async function resolveTaskConfirmation( taskId: string, decision: "approve" | "reject", comment = "",): Promise<TaskActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.TaskService/ResolveTaskConfirmation", ), { taskId, decision: encodeTaskDecision(decision), comment, }, );}
export type MigrationRollbackOptions = { rollbackDns?: boolean; deploySource?: boolean; stopTarget?: boolean; cleanupTarget?: boolean;};
export async function createMigrationRollback( taskId: string, options: MigrationRollbackOptions,): Promise<TaskActionResult> { return callTaskAction( controllerProcedure( "/composia.controller.v1.TaskService/CreateMigrationRollback", ), { taskId, rollbackDns: options.rollbackDns ?? false, deploySource: options.deploySource ?? false, stopTarget: options.stopTarget ?? false, cleanupTarget: options.cleanupTarget ?? false, }, );}
function requireControllerConfig() { const config = controllerConfig(); if (!config.ready) { throw new Error(config.reason); } return config;}
async function callServiceAction( procedure: string, body: RpcRequest,): Promise<ServiceActionResult> { const config = requireControllerConfig(); const response = await rpcCall<{ tasks?: Array<{ taskId?: string; task_id?: string; status?: string; repoRevision?: string; repo_revision?: string; }>; repoWrite?: { commitId?: string; commit_id?: string; syncStatus?: string; sync_status?: string; pushError?: string; push_error?: string; lastSuccessfulPullAt?: string; last_successful_pull_at?: string; }; repo_write?: { commitId?: string; commit_id?: string; syncStatus?: string; sync_status?: string; pushError?: string; push_error?: string; lastSuccessfulPullAt?: string; last_successful_pull_at?: string; }; }>(config.baseUrl, config.token, procedure, body);
const repoWrite = response.repoWrite ?? response.repo_write; return { tasks: (response.tasks ?? []).map((task) => ({ taskId: task.taskId ?? task.task_id ?? "", status: normalizeTaskStatus(task.status), repoRevision: task.repoRevision ?? task.repo_revision ?? "", })), repoWrite: repoWrite ? { commitId: repoWrite.commitId ?? repoWrite.commit_id ?? "", syncStatus: repoWrite.syncStatus ?? repoWrite.sync_status ?? "", pushError: repoWrite.pushError ?? repoWrite.push_error ?? "", lastSuccessfulPullAt: repoWrite.lastSuccessfulPullAt ?? repoWrite.last_successful_pull_at ?? "", } : undefined, };}
async function callTaskAction( procedure: string, body: RpcRequest,): Promise<TaskActionResult> { const config = requireControllerConfig(); const response = await rpcCall<{ taskId?: string; task_id?: string; status?: string; repoRevision?: string; repo_revision?: string; }>(config.baseUrl, config.token, procedure, body);
return { taskId: response.taskId ?? response.task_id ?? "", status: normalizeTaskStatus(response.status), repoRevision: response.repoRevision ?? response.repo_revision ?? "", };}
function toServiceActionEnum(action: ServiceAction): number { switch (action) { case "deploy": return 1; case "update": return 2; case "stop": return 3; case "restart": return 4; case "backup": return 5; case "image_check": return 9; case "dns_update": return 6; case "caddy_sync": return 7; case "cloudflare_tunnel_sync": return 8; }}
function toComposeRecreateModeEnum(mode: ComposeRecreateMode): number { switch (mode) { case "no_recreate": return 2; case "force_recreate": return 3; case "auto": default: return 1; }}
function toContainerActionEnum(action: ContainerAction): number { switch (action) { case "start": return 1; case "stop": return 2; case "restart": return 3; }}
async function rpcCall<T>( baseUrl: string, token: string, procedure: string, body: RpcRequest, extraHeaders: Record<string, string> = {}, timeoutMs?: number,): Promise<T> { return controllerRpcCall<T>({ baseUrl, token, procedure, body, controllerHeaders: parseControllerHeaders(), extraHeaders, requestSignal: currentRequestSignal(), timeoutMs, });}