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
34 changes: 17 additions & 17 deletions E2E_TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,31 +491,31 @@ Tier: destructive for all three files. Require ephemeral mode.

### SDK prerequisite

- [ ] Correct `defaultRegistryId` versus backend `defaultPushRegistryId` in `caprover-api`.
- [ ] Publish and consume the corrected package.
- [x] Correct `defaultRegistryId` versus backend `defaultPushRegistryId` in [`caprover-api` PR #17](https://github.com/caprover/caprover-api/pull/17).
- [x] Publish and consume `caprover-api@0.0.25` through the [release PR #19](https://github.com/caprover/caprover-api/pull/19).

### Lightweight registry contracts

- [ ] Read the initial registry list.
- [ ] Reject an unknown default registry ID.
- [ ] Specify a reachable registry endpoint and deliberately invalid credentials; verify the expected authentication rejection and registry error status.
- [ ] Treat DNS failures, connection failures, and timeouts as test failures. They must not satisfy the invalid-credentials assertion.
- [x] Read the initial registry list.
- [x] Reject an unknown default registry ID.
- [x] Specify a reachable registry endpoint and deliberately invalid credentials; verify the expected authentication rejection and registry error status.
- [x] Treat DNS failures, connection failures, and timeouts as test failures. They must not satisfy the invalid-credentials assertion.

### GoAccess

- [ ] Preserve settings.
- [ ] Enable GoAccess and generate traffic.
- [ ] Retrieve report listings and a live report.
- [ ] Verify missing-app and missing-report behavior.
- [ ] Restore settings.
- [x] Preserve settings.
- [x] Enable GoAccess and generate traffic.
- [x] Retrieve report listings and a live report.
- [x] Verify missing-app and missing-report behavior.
- [x] Restore settings.

### NetData

- [ ] Preserve settings.
- [ ] Enable NetData with notifications disabled.
- [ ] Verify service and proxied endpoint.
- [ ] Disable NetData and verify removal.
- [ ] Restore settings.
- [x] Preserve settings.
- [x] Enable NetData with notifications disabled.
- [x] Verify service and proxied endpoint.
- [x] Disable NetData and verify removal.
- [x] Restore settings.

Full self-hosted registry build-and-push coverage belongs in the controlled SSL workflow because enabling it requests a real certificate.

Expand Down Expand Up @@ -628,7 +628,7 @@ This table should be updated whenever `caprover-api` adds or removes a public me
- [x] PR14 merged ([caprover-e2e PR #33](https://github.com/caprover/caprover-e2e/pull/33))
- [x] PR15 merged ([caprover-e2e PR #35](https://github.com/caprover/caprover-e2e/pull/35))
- [x] PR16 merged ([caprover-e2e PR #36](https://github.com/caprover/caprover-e2e/pull/36))
- [ ] PR17 merged
- [x] PR17 merged ([caprover-e2e PR #38](https://github.com/caprover/caprover-e2e/pull/38); [fresh-server run](https://github.com/caprover/caprover-e2e/actions/runs/35820353928): 36 files, 104 tests)
- [ ] PR18a Git webhook workflow implemented or linked to a follow-up issue
- [ ] PR18b SSL and self-hosted registry workflow implemented or linked to a follow-up issue
- [ ] PR18c multi-node workflow implemented or linked to a follow-up issue
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"test:all": "vitest run --mode all"
},
"dependencies": {
"caprover-api": "0.0.23",
"caprover-api": "0.0.25",
"ssh2": "1.17.0"
},
"devDependencies": {
Expand Down
80 changes: 80 additions & 0 deletions src/clients/caprover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ type OneClickAppRepositories = Awaited<
type OneClickDeploymentState = Awaited<
ReturnType<CapRoverAPI['getOneClickAppDeployProgress']>
>
type RegistriesResponse = Awaited<
ReturnType<CapRoverAPI['getDockerRegistries']>
>
type GoAccessInfo = CapRoverModels.GoAccessInfo
type GoAccessState = Awaited<ReturnType<CapRoverAPI['getGoAccessInfo']>>
type GoAccessReport = Awaited<
ReturnType<CapRoverAPI['getGoAccessReports']>
>[number]
type NetDataInfo = CapRoverModels.NetDataInfo

export interface OneClickValuePair {
key: string
Expand Down Expand Up @@ -129,6 +138,72 @@ export class CapRoverClient {
)
}

getDockerRegistries(): Promise<RegistriesResponse> {
return this.request(
() => this.api.getDockerRegistries(),
'listing Docker registries'
)
}

addDockerRegistry(registry: CapRoverModels.IRegistryInfo): Promise<void> {
return this.request(
() => this.api.addDockerRegistry(registry),
'adding Docker registry'
)
}

setDefaultPushDockerRegistry(id: string): Promise<void> {
return this.request(
() => this.api.setDefaultPushDockerRegistry(id),
'setting default push registry'
)
}

getGoAccessInfo(): Promise<GoAccessState> {
return this.request(
() => this.api.getGoAccessInfo(),
'retrieving GoAccess settings'
)
}

updateGoAccessInfo(info: GoAccessInfo): Promise<void> {
return this.request(
() => this.api.updateGoAccessInfo(info),
'updating GoAccess settings',
DEPLOYMENT_TIMEOUT_MS
)
}

getGoAccessReports(appName: string): Promise<GoAccessReport[]> {
return this.request(
() => this.api.getGoAccessReports(appName),
'listing GoAccess reports'
)
}

getGoAccessReport(url: string): Promise<string> {
return this.request(
() => this.api.getGoAccessReport(url),
'retrieving GoAccess report',
DEPLOYMENT_TIMEOUT_MS
)
}

getNetDataInfo(): Promise<NetDataInfo> {
return this.request(
() => this.api.getNetDataInfo(),
'retrieving NetData settings'
)
}

updateNetDataInfo(info: NetDataInfo): Promise<void> {
return this.request(
() => this.api.updateNetDataInfo(info),
'updating NetData settings',
DEPLOYMENT_TIMEOUT_MS
)
}

setDiskCleanupSettings(settings: DiskCleanupSettings): Promise<void> {
return this.request(
() =>
Expand Down Expand Up @@ -489,4 +564,9 @@ export type {
OneClickAppRepositories,
OneClickAppsResponse,
OneClickDeploymentState,
RegistriesResponse,
GoAccessInfo,
GoAccessState,
GoAccessReport,
NetDataInfo,
}
33 changes: 33 additions & 0 deletions src/inspectors/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,39 @@ const VOLUME_MARKER_PATH = '/e2e-volume/marker'
export class DockerInspector {
constructor(private readonly ssh: SshClient) {}

async getContainerState(
name: string
): Promise<'absent' | 'running' | 'stopped'> {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Unsafe Docker container name: ${name}`)
}
const result = await this.ssh.exec(`docker inspect ${shellQuote(name)}`)
if (result.exitCode !== 0) {
if (
/no such (object|container)/i.test(
`${result.stdout}\n${result.stderr}`
)
)
return 'absent'
throw new Error(
`docker inspect failed for ${name}: ${result.stderr.trim()}`
)
}
const containers = parseJson<Array<{ State?: { Running?: boolean } }>>(
result.stdout,
`container ${name}`
)
if (
containers.length !== 1 ||
typeof containers[0].State?.Running !== 'boolean'
) {
throw new Error(
`Docker returned no valid state for container ${name}`
)
}
return containers[0].State.Running ? 'running' : 'stopped'
}

async validateEnvironment(): Promise<void> {
const result = await this.exec("docker info --format '{{json .Swarm}}'")
const swarm = parseJson<DockerSwarmInfo>(result.stdout, 'Docker info')
Expand Down
102 changes: 102 additions & 0 deletions tests/goaccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect, test } from 'vitest'
import { GoAccessInfo } from '../src/clients/caprover'
import { waitForServiceStable } from '../src/helpers/deployment'
import { createTestNames } from '../src/helpers/names'
import { eventually } from '../src/helpers/retry'
import { cleanUpApp, withTestContext } from '../src/helpers/test-context'
import { requireEphemeral } from '../src/test-selection'

const NGINX_IMAGE =
'nginx:1.29.8-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de'
const CONTAINER = 'captain-goaccess-container'

test('GoAccess records routed traffic and serves a live report', async () => {
requireEphemeral()
await withTestContext(async (context, cleanup, rootDomain) => {
const { runId } = createTestNames()
const appName = `e2e-${runId}-goaccess`
const appUrl = `http://${appName}.${rootDomain}`
const api = context.caprover

cleanUpApp(context, cleanup, appName)
await api.createApp(appName)
await api.deployImage(appName, NGINX_IMAGE)
await waitForServiceStable(context, appName)
await context.http.waitUntilReachable(appUrl, 'Welcome to nginx!')

const original = await api.getGoAccessInfo()
const restore: GoAccessInfo = {
isEnabled: original.isEnabled,
data: {
rotationFrequencyCron: original.data.rotationFrequencyCron,
logRetentionDays: original.data.logRetentionDays ?? 180,
},
}
cleanup.add(async () => {
await api.updateGoAccessInfo(restore)
await eventually(
async () => {
expect(await api.getGoAccessInfo()).toEqual(restore)
expect(
await context.docker.getContainerState(CONTAINER)
).toBe(original.isEnabled ? 'running' : 'absent')
},
{
timeoutMs: 60_000,
description: 'GoAccess settings and container restoration',
}
)
})

const enabled: GoAccessInfo = {
isEnabled: true,
data: { rotationFrequencyCron: '0 0 1 * *', logRetentionDays: 7 },
}
await api.updateGoAccessInfo(enabled)
await eventually(
async () => {
expect(await api.getGoAccessInfo()).toEqual(enabled)
expect(await context.docker.getContainerState(CONTAINER)).toBe(
'running'
)
},
{ timeoutMs: 60_000, description: 'GoAccess container startup' }
)

for (let request = 0; request < 3; request++) {
expect((await context.http.get(appUrl)).status).toBe(200)
}

const reports = await api.getGoAccessReports(appName)
const live = reports.find(
(report) =>
report.domainName === `${appName}.${rootDomain}` &&
report.name.endsWith('--Live.html')
)
expect(live).toBeDefined()
expect(live?.url).toMatch(
new RegExp(`^/user/system/goaccess/${appName}/files/`)
)

await eventually(
async () => {
const html = await api.getGoAccessReport(live!.url)
expect(html.length).toBeGreaterThan(500)
expect(html.toLowerCase()).toContain('<html')
},
{ timeoutMs: 60_000, description: 'GoAccess live HTML report' }
)

await expect(
api.getGoAccessReports(`e2e-${runId}-missing`)
).rejects.toMatchObject({ captainStatus: 1000 })
const missingUrl = live!.url.replace(
'--Live.html',
`--Missing-${runId}.html`
)
expect(missingUrl).not.toBe(live!.url)
await expect(api.getGoAccessReport(missingUrl)).rejects.toMatchObject({
captainStatus: 1111,
})
})
})
Loading