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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/clients/caprover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ type NginxConfig = Awaited<ReturnType<CapRoverAPI['getNginxConfig']>>
type UnusedImage = Awaited<
ReturnType<CapRoverAPI['getUnusedImages']>
>['unusedImages'][number]
type OneClickAppsResponse = Awaited<
ReturnType<CapRoverAPI['getAllOneClickApps']>
>
type OneClickAppDefinitionResponse = Awaited<
ReturnType<CapRoverAPI['getOneClickAppByName']>
>
type OneClickAppRepositories = Awaited<
ReturnType<CapRoverAPI['getAllOneClickAppRepos']>
>
type OneClickDeploymentState = Awaited<
ReturnType<CapRoverAPI['getOneClickAppDeployProgress']>
>

export interface OneClickValuePair {
key: string
value: string
}

const API_TIMEOUT_MS = 30_000
const DEPLOYMENT_TIMEOUT_MS = 90_000
Expand Down Expand Up @@ -385,6 +402,65 @@ export class CapRoverClient {
)
}

getAllOneClickApps(): Promise<OneClickAppsResponse> {
return this.request(
() => this.api.getAllOneClickApps(),
'listing one-click application templates'
)
}

getOneClickAppByName(
appName: string,
baseDomain: string
): Promise<OneClickAppDefinitionResponse> {
return this.request(
() => this.api.getOneClickAppByName(appName, baseDomain),
`retrieving one-click application template ${appName}`
)
}

getAllOneClickAppRepos(): Promise<OneClickAppRepositories> {
return this.request(
() => this.api.getAllOneClickAppRepos(),
'listing custom one-click repositories'
)
}

addCustomOneClickRepo(repositoryUrl: string): Promise<void> {
return this.request(
() => this.api.addNewCustomOneClickRepo(repositoryUrl),
`adding custom one-click repository ${repositoryUrl}`
)
}

deleteCustomOneClickRepo(repositoryUrl: string): Promise<void> {
return this.request(
() => this.api.deleteCustomOneClickRepo(repositoryUrl),
`deleting custom one-click repository ${repositoryUrl}`
)
}

startOneClickAppDeploy(
template: CapRoverModels.IOneClickTemplate,
values: OneClickValuePair[],
templateName?: string
): Promise<{ jobId: string }> {
return this.request(
() =>
this.api.startOneClickAppDeploy(template, values, templateName),
'starting one-click application deployment'
)
}

getOneClickAppDeployProgress(
jobId: string
): Promise<OneClickDeploymentState> {
return this.request(
() => this.api.getOneClickAppDeployProgress(jobId),
`retrieving one-click deployment progress ${jobId}`
)
}

destroy(): void {
this.api.destroy()
}
Expand All @@ -409,4 +485,8 @@ export type {
Theme,
UnusedImage,
VersionInfo,
OneClickAppDefinitionResponse,
OneClickAppRepositories,
OneClickAppsResponse,
OneClickDeploymentState,
}
107 changes: 107 additions & 0 deletions src/helpers/one-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { OneClickDeploymentState } from '../clients/caprover'
import { withTimeout } from './retry'

export interface OneClickProgressSnapshot {
currentStep: number
steps: string[]
error?: string
successMessage?: string
}

export interface OneClickDeploymentResult {
outcome: 'success' | 'error'
state: OneClickDeploymentState
history: OneClickProgressSnapshot[]
}

interface OneClickPollOptions {
timeoutMs?: number
intervalMs?: number
}

export async function waitForOneClickDeployment(
getProgress: () => Promise<OneClickDeploymentState>,
options: OneClickPollOptions = {}
): Promise<OneClickDeploymentResult> {
const timeoutMs = options.timeoutMs ?? 120_000
const intervalMs = options.intervalMs ?? 500
const deadline = Date.now() + timeoutMs
const history: OneClickProgressSnapshot[] = []
let previousStep = -1

while (true) {
const remainingBeforeRequest = deadline - Date.now()
if (remainingBeforeRequest <= 0) throw timeoutError(timeoutMs, history)

const state = await withTimeout(
getProgress(),
remainingBeforeRequest,
'One-click deployment progress request'
)
validateProgress(state, previousStep)
previousStep = state.currentStep
history.push({
currentStep: state.currentStep,
steps: [...state.steps],
...(state.error ? { error: state.error } : {}),
...(state.successMessage !== undefined
? { successMessage: state.successMessage }
: {}),
})

if (state.error)
return {
outcome: 'error',
state,
history,
}

if (state.currentStep >= state.steps.length)
return {
outcome: 'success',
state,
history,
}

const remainingMs = deadline - Date.now()
if (remainingMs <= 0) throw timeoutError(timeoutMs, history)
await delay(Math.min(intervalMs, remainingMs))
}
}

function timeoutError(
timeoutMs: number,
history: OneClickProgressSnapshot[]
): Error {
return new Error(
`Timed out after ${timeoutMs}ms waiting for one-click deployment. Last progress: ${JSON.stringify(history.at(-1))}`
)
}

function validateProgress(
state: OneClickDeploymentState,
previousStep: number
): void {
if (!Array.isArray(state.steps) || state.steps.length === 0) {
throw new Error('One-click deployment progress had no steps')
}
if (!Number.isInteger(state.currentStep) || state.currentStep < 0) {
throw new Error(
`One-click deployment progress had invalid currentStep: ${state.currentStep}`
)
}
if (state.currentStep > state.steps.length) {
throw new Error(
`One-click deployment progress exceeded its step count: ${state.currentStep}/${state.steps.length}`
)
}
if (state.currentStep < previousStep) {
throw new Error(
`One-click deployment progress regressed from ${previousStep} to ${state.currentStep}`
)
}
}

function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
62 changes: 47 additions & 15 deletions src/helpers/source-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'

export interface OneClickRepositoryFixtureData {
list: { oneClickApps: unknown[] }
templates: Record<string, unknown>
}

export async function sourceArchive(
marker: string,
alternate = false
Expand Down Expand Up @@ -50,30 +55,57 @@ export async function customPortsSourceArchive(): Promise<File> {
return fixtureArchive('custom-ports', 'custom-ports-app')
}

export async function oneClickRepositorySourceArchive(
data: OneClickRepositoryFixtureData
): Promise<File> {
const temporary = await mkdtemp(join(tmpdir(), 'caprover-e2e-one-click-'))
try {
const source = join(temporary, 'source')
await cp(
join(process.cwd(), 'tests/fixtures/one-click-repository-app'),
source,
{ recursive: true }
)
await writeFile(
join(source, 'repository-data.json'),
`${JSON.stringify(data, null, 2)}\n`
)
return await archiveFixture(
source,
'one-click-repository-source.tar',
temporary
)
} finally {
await rm(temporary, { recursive: true, force: true })
}
}

async function fixtureArchive(
name: string,
fixtureDirectory: string
): Promise<File> {
const temporary = await mkdtemp(join(tmpdir(), `caprover-e2e-${name}-`))
try {
const archive = join(temporary, 'source.tar')
await promisify(execFile)(
'tar',
[
'-cf',
archive,
'-C',
join(process.cwd(), 'tests/fixtures', fixtureDirectory),
'.',
],
{ timeout: 10_000 }
)
return new File(
[new Uint8Array(await readFile(archive))],
return await archiveFixture(
join(process.cwd(), 'tests/fixtures', fixtureDirectory),
`${name}-source.tar`,
{ type: 'application/x-tar' }
temporary
)
} finally {
await rm(temporary, { recursive: true, force: true })
}
}

async function archiveFixture(
source: string,
fileName: string,
destinationDirectory: string
): Promise<File> {
const archive = join(destinationDirectory, fileName)
await promisify(execFile)('tar', ['-cf', archive, '-C', source, '.'], {
timeout: 10_000,
})
return new File([new Uint8Array(await readFile(archive))], fileName, {
type: 'application/x-tar',
})
}
15 changes: 5 additions & 10 deletions tests/disk-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,14 @@ async function listImageIds(context: TestContext): Promise<string[]> {
}

async function getServiceImages(context: TestContext): Promise<string[]> {
const serviceIds = lines(await exec(context, 'docker service ls --quiet'))
for (const serviceId of serviceIds) {
if (!/^[a-z0-9]{25}$/.test(serviceId)) {
throw new Error(
`Docker returned an invalid service ID: ${serviceId}`
)
}
}
if (serviceIds.length === 0) return []
const serviceNames = lines(
await exec(context, "docker service ls --format '{{.Name}}'")
)
if (serviceNames.length === 0) return []
return lines(
await exec(
context,
`docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' ${serviceIds.map(shellQuote).join(' ')}`
`docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' ${serviceNames.map(shellQuote).join(' ')}`
)
)
}
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/one-click-repository-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
WORKDIR /app
COPY server.js repository-data.json ./
EXPOSE 8080
CMD ["node", "server.js"]
1 change: 1 addition & 0 deletions tests/fixtures/one-click-repository-app/captain-definition
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"schemaVersion":2,"dockerfilePath":"./Dockerfile"}
41 changes: 41 additions & 0 deletions tests/fixtures/one-click-repository-app/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { createServer } = require('node:http')
const data = require('./repository-data.json')

const server = createServer((request, response) => {
const url = new URL(request.url, 'http://repository.local')
if (request.method !== 'GET') {
response.writeHead(405)
response.end()
return
}

if (url.pathname === '/v4/list') {
sendJson(response, 200, data.list)
return
}

const prefix = '/v4/apps/'
if (url.pathname.startsWith(prefix)) {
let templateName
try {
templateName = decodeURIComponent(url.pathname.slice(prefix.length))
} catch {
sendJson(response, 400, { error: 'invalid template path' })
return
}
const template = data.templates[templateName]
if (template) {
sendJson(response, 200, template)
return
}
}

sendJson(response, 404, { error: 'template not found' })
})

server.listen(8080, '0.0.0.0')

function sendJson(response, status, value) {
response.writeHead(status, { 'content-type': 'application/json' })
response.end(JSON.stringify(value))
}
Loading
Loading