37 Commits

Author SHA1 Message Date
Kasra Bigdeli
77725773a2 Merge pull request #2350 from rosano/patch-1
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
Fix typo
2025-10-20 23:19:23 -07:00
Rosano
26e14f7411 Fix typo 2025-10-21 07:10:09 +01:00
Kasra Bigdeli
5d03bb3ea2 Fixed tests
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-10-13 22:55:17 -07:00
Kasra Bigdeli
6fe86982bc adding report for unused fields so that we can better prioritize adding support 2025-10-13 22:50:21 -07:00
Kasra Bigdeli
587a63e3af Merge pull request #2343 from internet2000/master
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
Allow numbers in project name, fix #2130
2025-09-29 22:16:44 -07:00
Alex Hoyau
be5d55d4ce Allow numbers in project name, fix #2130 2025-09-29 14:41:41 +02:00
Kasra Bigdeli
cf4d6f99ce Updated changelog
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-09-10 23:00:21 -07:00
Kasra Bigdeli
5e4f08db1c Allowing docker compose deploys 2025-09-10 22:49:30 -07:00
Kasra Bigdeli
ec8371dc12 Updated changelogs
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-09-09 22:05:46 -07:00
Kasra Bigdeli
8bd58bf32c Reverted unintended changes in https://github.com/caprover/caprover/pull/2336 2025-09-09 22:03:27 -07:00
Kasra Bigdeli
1b89d9123b Merge pull request #2336 from R3D347HR4Y/excludelogsfrombackups
FIX - Deletes the shared-logs from the copied /captain/data folder upon a backup of the config
2025-09-09 22:02:06 -07:00
Kasra Bigdeli
e902762809 Fied build
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-09-06 21:58:08 -07:00
Kasra Bigdeli
b47bdddc66 Updated changelog 2025-09-06 21:57:12 -07:00
Kasra Bigdeli
fa2f251db1 Added autocleanup for backend jobs 2025-09-06 21:55:25 -07:00
Kasra Bigdeli
399bfd3c86 Merge pull request #2334 from caprover/oneclick-move-backend
Moving one click apps to backend.
2025-09-06 21:52:35 -07:00
Kasra Bigdeli
15595a6a62 updated formatting 2025-09-06 19:34:23 -07:00
Kasra Bigdeli
9caba312a1 updated API to use array 2025-09-06 19:32:14 -07:00
R3D347HR4Y
bc2318d056 Reverted settings.json 2025-09-06 09:36:05 +02:00
R3D347HR4Y
2664437159 Update BackupManager.ts 2025-09-06 09:35:35 +02:00
R3D347HR4Y
38096fff15 FIX - Deletes the shared-logs from the copied /captain/data folder upon a backup of the config 2025-09-05 12:34:49 +02:00
Kasra Bigdeli
e098ba900b Fixing regression bugs 2025-09-03 22:33:39 -07:00
Kasra Bigdeli
0fa8643fae Moved uploadCaptainDefinitionContent to handler-- backend is almost done 2025-09-02 23:42:26 -07:00
Kasra Bigdeli
a0247e068e Moved updateConfigAndSave to handler 2025-09-02 23:33:45 -07:00
Kasra Bigdeli
c3abec0367 Moved GetAllApps to handler 2025-09-02 23:18:48 -07:00
Kasra Bigdeli
6a35863cd3 Fixed register project in one click app handler 2025-09-02 23:10:33 -07:00
Kasra Bigdeli
b592ebed6e Unify the definiton of handler resp 2025-09-02 23:06:24 -07:00
Kasra Bigdeli
ef3a4db51f updated project handler 2025-09-02 23:02:33 -07:00
Kasra Bigdeli
c07a202523 Moved register app to handler 2025-09-02 22:57:29 -07:00
Kasra Bigdeli
80fe613b0f update 2025-09-01 21:52:09 -07:00
Kasra Bigdeli
61d157fdae skeleton 2025-09-01 21:48:30 -07:00
Kasra Bigdeli
2baddabef6 Update php
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-08-22 08:19:17 -07:00
Kasra Bigdeli
596cecfca9 Added tests to lint file
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-08-17 20:00:05 -07:00
Kasra Bigdeli
09a9bd778d updated changelog
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-08-15 20:26:29 -07:00
Kasra Bigdeli
7abb1c00a6 Closed https://github.com/caprover/caprover/issues/2326 2025-08-15 20:24:00 -07:00
Kasra Bigdeli
f97e3eedfe Updated packages
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
2025-07-08 21:08:23 -07:00
Kasra Bigdeli
6883291673 Merge pull request #2311 from raisercostin/master
Some checks failed
Run build / build (push) Has been cancelled
Run formatter / check-code-formatting (push) Has been cancelled
Run lint / run-lint (push) Has been cancelled
Build and push the edge image / run-pre-checks (push) Has been cancelled
Build and push the edge image / build-publish-docker-hub (push) Has been cancelled
If configured with CAPTAIN_HOST_ADMIN_PORT nginx should redirect trafic to 3000 that is the container swarm available port
2025-06-29 11:22:55 -07:00
raisercostin
47a5d8fa19 The serviceExposedPort is only used in the template/root-nginx-conf.ejs and should be the container port that is 3000 (used inside swarm). - https://github.com/search?q=repo%3Acaprover%2Fcaprover+serviceExposedPort&type=code
Continues on https://github.com/caprover/caprover/pull/2220 .

Currently nginx cannot forward traffic to admin/3000 port (is using the one defined in configs.adminPortNumber3000).
2025-06-29 10:53:13 +03:00
31 changed files with 5475 additions and 1353 deletions

View File

@@ -6,7 +6,7 @@ Please make sure you have read [contribution guidelines](https://github.com/capr
### Most important items
- Make sure to communicate your proposed changes ob our Slack channel beforehand.
- Make sure to communicate your proposed changes on our Slack channel beforehand.
- Large PRs (50+ lines of code) will get rejected due to increased difficulty for review - unless they have been communicated beforehand with project maintainers.
- **Refactoring work will get rejected** unless it's been communicated with project's maintainers **beforehand**. There is a thousand ways to write the same code. Every time a code is changed, there is a potential for a new bug. We don't want to refactor the code just for the sake of refactoring.

View File

@@ -16,4 +16,8 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 22
- run: npm ci && npm run lint
- run: |
sudo bash -c "mkdir /captain && chown -R `whoami` /captain"
npm ci
npm run lint
npm run test

View File

@@ -1,5 +1,10 @@
## [Next Version - available as `edge`]
- New: Ability to deploy simplified Docker compose [PR-191](https://github.com/caprover/caprover-frontend/pull/191)
- Improved: Descriptive error on installations on incompatible systems (e.g. Proxmox LXC) [issues-2326](https://github.com/caprover/caprover/issues/2326)
- Improved: Moved one click app creation process to backend for more stability [PR-2334](https://github.com/caprover/caprover/pull/2334)
- Improved: Reduced the Backup size by excluding the GoAccess logs [PR-2336](https://github.com/caprover/caprover/pull/2336)
## [1.14.0] - 2025-06-07
- Added functionality for custom ports [issue-2220](https://github.com/caprover/caprover/pull/2220)

View File

@@ -2,3 +2,4 @@
# Uncomment the line above if you want to use a Dockerfile instead of templateId
COPY ./ /var/www/html/
RUN sh -c 'rm -rf /var/www/html/.git || echo 1'

4403
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,14 @@
"test": "jest"
},
"dependencies": {
"axios": "^1.9.0",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"configstore": "^5.0.1",
"cookie-parser": "~1.4.7",
"cron": "^4.3.0",
"cron": "^4.3.1",
"debug": "~4.4.1",
"dockerode": "^4.0.6",
"dockerode": "^4.0.7",
"ejs": "^3.1.10",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
@@ -31,42 +31,42 @@
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"multer": "^2.0.0",
"multer": "^2.0.1",
"on-finished": "^2.4.1",
"prettier": "3.5.3",
"public-ip": "^7.0.1",
"recursive-readdir": "^2.2.3",
"request": "^2.88.2",
"require-from-string": "^2.0.2",
"serve-favicon": "~2.5.0",
"shell-quote": "^1.8.2",
"simple-git": "^3.27.0",
"serve-favicon": "~2.5.1",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"ssh2": "^1.16.0",
"tar": "^7.4.3",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"validator": "^13.15.0",
"validator": "^13.15.15",
"yaml": "^2.8.0"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@eslint/js": "^9.30.1",
"@types/bcryptjs": "^3.0.0",
"@types/configstore": "^5.0.1",
"@types/cookie-parser": "^1.4.8",
"@types/cookie-parser": "^1.4.9",
"@types/debug": "^4.1.12",
"@types/dockerode": "^3.3.39",
"@types/dockerode": "^3.3.42",
"@types/ejs": "^3.1.5",
"@types/express": "^5.0.2",
"@types/express": "^5.0.3",
"@types/fs-extra": "^11.0.4",
"@types/http-proxy": "^1.17.16",
"@types/is-valid-path": "^0.1.2",
"@types/jest": "^29.5.14",
"@types/js-base64": "^3.3.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/moment": "^2.13.0",
"@types/morgan": "^1.9.9",
"@types/multer": "^1.4.12",
"@types/on-finished": "^2.3.4",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.0.0",
"@types/on-finished": "^2.3.5",
"@types/request": "^2.48.12",
"@types/require-from-string": "^1.2.3",
"@types/serve-favicon": "^2.5.7",
@@ -74,14 +74,14 @@
"@types/ssh2": "^1.15.5",
"@types/tar": "^6.1.13",
"@types/uuid": "^10.0.0",
"@types/validator": "^13.15.1",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"eslint": "^9.27.0",
"globals": "^16.2.0",
"jest": "^29.7.0",
"@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^30.0.4",
"madge": "^8.0.0",
"ts-jest": "^29.3.4",
"typescript-eslint": "^8.32.1"
"ts-jest": "^29.4.0",
"typescript-eslint": "^8.36.0"
}
}

View File

@@ -30,13 +30,38 @@ function isNameAllowed(name: string) {
const isNameFormattingOk =
!!name &&
name.length < 50 &&
/^[a-z]/.test(name) &&
/^[a-z0-9]/.test(name) &&
/[a-z0-9]$/.test(name) &&
/^[a-z0-9\-]+$/.test(name) &&
name.indexOf('--') < 0
return isNameFormattingOk && ['captain', 'registry'].indexOf(name) < 0
}
/**
* Checks valid docker volume names
* @param name
* @returns {boolean}
*
* According to Docker documentation:
* A volume name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes.
* A volume name may not start with a period or a dash and may not contain consecutive periods or dashes.
* The maximum length is 255 characters.
* https://docs.docker.com/engine/reference/commandline/volume_create/#create-a-volume
* example valid names: my-volume, My.Volume, my_volume, myvolume123, my.volume-name_123, myvolume
* example invalid names: -myvolume, .myvolume, my..volume, my--volume, my volume (space), my@volume, (special characters)
*/
function isDockerVolumeNameAllowed(name: string) {
const isNameFormattingOk =
!!name &&
name.length < 256 &&
/^[a-zA-Z0-9]/.test(name) &&
/[a-zA-Z0-9]$/.test(name) &&
/^[a-zA-Z0-9_.-]+$/.test(name) &&
name.indexOf('..') < 0 &&
name.indexOf('--') < 0
return isNameFormattingOk
}
function isPortValid(portNumber: number) {
return portNumber > 0 && portNumber < 65535
}
@@ -162,7 +187,7 @@ class AppsDataStore {
} else {
if (
!obj.volumeName ||
!isNameAllowed(obj.volumeName)
!isDockerVolumeNameAllowed(obj.volumeName)
) {
throw ApiStatusCodes.createError(
ApiStatusCodes.STATUS_ERROR_GENERIC,
@@ -217,7 +242,7 @@ class AppsDataStore {
if (!isNameAllowed(appName)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.STATUS_ERROR_BAD_NAME,
'App Name is not allowed. Only lowercase letters and single hyphens are allowed'
'App Name is not allowed. Only lowercase letters, numbers and single hyphens are allowed'
)
}
@@ -848,7 +873,7 @@ class AppsDataStore {
reject(
ApiStatusCodes.createError(
ApiStatusCodes.STATUS_ERROR_BAD_NAME,
'App Name is not allowed. Only lowercase letters and single hyphens are allowed'
'App Name is not allowed. Only lowercase letters, numbers and single hyphens are allowed'
)
)
return
@@ -890,7 +915,7 @@ class AppsDataStore {
reject(
ApiStatusCodes.createError(
ApiStatusCodes.STATUS_ERROR_BAD_NAME,
'App Name is not allowed. Only lowercase letters and single hyphens are allowed'
'App Name is not allowed. Only lowercase letters, numbers and single hyphens are allowed'
)
)
return

View File

@@ -0,0 +1,3 @@
export interface BaseHandlerResult {
message: string
}

View File

@@ -0,0 +1,46 @@
import { v4 as uuid } from 'uuid'
import DataStore from '../../datastore/DataStore'
import { ProjectDefinition } from '../../models/ProjectDefinition'
import Logger from '../../utils/Logger'
import { BaseHandlerResult } from '../BaseHandlerResult'
export interface RegisterProjectParams {
name: string
parentProjectId?: string
description: string
}
export interface RegisterProjectResult extends BaseHandlerResult {
data: ProjectDefinition
}
export async function registerProject(
params: RegisterProjectParams,
dataStore: DataStore
): Promise<RegisterProjectResult> {
const { name, parentProjectId, description } = params
Logger.d(`Creating project: ${name}`)
try {
const projectId = uuid()
const project = await dataStore
.getProjectsDataStore()
.saveProject(projectId, {
id: projectId,
name: name,
parentProjectId: parentProjectId,
description: description,
})
Logger.d(`Project created: ${name}`)
return {
message: `Project created: ${name}`,
data: project,
}
} catch (error: any) {
Logger.e(`Failed to create project: ${error}`)
throw error
}
}

View File

@@ -0,0 +1,71 @@
import ApiStatusCodes from '../../../../api/ApiStatusCodes'
import ServiceManager from '../../../../user/ServiceManager'
import Logger from '../../../../utils/Logger'
import { BaseHandlerResult } from '../../../BaseHandlerResult'
export interface UploadCaptainDefinitionContentParams {
appName: string
isDetachedBuild: boolean
captainDefinitionContent?: string
gitHash?: string
uploadedTarPathSource?: string
}
export async function uploadCaptainDefinitionContent(
params: UploadCaptainDefinitionContentParams,
serviceManager: ServiceManager
): Promise<BaseHandlerResult> {
const {
appName,
isDetachedBuild,
captainDefinitionContent,
gitHash,
uploadedTarPathSource,
} = params
const hasTar = !!uploadedTarPathSource
const hasCaptainDef = !!captainDefinitionContent
if (hasTar === hasCaptainDef) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_OPERATION,
'Either uploadedTarPathSource or captainDefinitionContent should be provided, but not both.'
)
}
const promiseToDeployNewVer = serviceManager.scheduleDeployNewVersion(
appName,
{
uploadedTarPathSource: hasTar
? {
uploadedTarPath: uploadedTarPathSource as string,
gitHash: `${gitHash || ''}`,
}
: undefined,
captainDefinitionContentSource: hasCaptainDef
? {
captainDefinitionContent:
captainDefinitionContent as string,
gitHash: `${gitHash || ''}`,
}
: undefined,
}
)
if (isDetachedBuild) {
// Avoid unhandled promise rejection
promiseToDeployNewVer.catch(function (err: any) {
Logger.e(err)
})
return {
message: 'Deploy is started',
}
}
await promiseToDeployNewVer
return {
message: 'Deploy is done',
}
}

View File

@@ -0,0 +1,305 @@
import DataStore from '../../../../datastore/DataStore'
import { ICaptainDefinition } from '../../../../models/ICaptainDefinition'
import ServiceManager from '../../../../user/ServiceManager'
import CaptainConstants from '../../../../utils/CaptainConstants'
import Logger from '../../../../utils/Logger'
import ApiStatusCodes from '../../../../api/ApiStatusCodes'
import {
AppDeployTokenConfig,
IAppEnvVar,
IAppPort,
IAppTag,
IAppVolume,
IHttpAuth,
RepoInfo,
} from '../../../../models/AppDefinition'
import { BaseHandlerResult } from '../../../BaseHandlerResult'
export interface RegisterAppDefinitionParams {
appName: string
projectId: string
hasPersistentData: boolean
isDetachedBuild: boolean
}
export async function registerAppDefinition(
params: RegisterAppDefinitionParams,
dataStore: DataStore,
serviceManager: ServiceManager
): Promise<BaseHandlerResult> {
const { appName, projectId, hasPersistentData, isDetachedBuild } = params
let appCreated = false
Logger.d(`Registering app started: ${appName}`)
try {
// Validate project if projectId is provided
if (projectId) {
await dataStore.getProjectsDataStore().getProject(projectId)
// if project is not found, it will throw an error
}
// Register the app definition
await dataStore
.getAppsDataStore()
.registerAppDefinition(appName, projectId, hasPersistentData)
appCreated = true
// Create captain definition content
const captainDefinitionContent: ICaptainDefinition = {
schemaVersion: 2,
imageName: CaptainConstants.configs.appPlaceholderImageName,
}
// Schedule deployment (unless detached build)
const promiseToIgnore = serviceManager
.scheduleDeployNewVersion(appName, {
captainDefinitionContentSource: {
captainDefinitionContent: JSON.stringify(
captainDefinitionContent
),
gitHash: '',
},
})
.catch(function (error) {
Logger.e(error)
})
if (!isDetachedBuild) {
await promiseToIgnore
}
Logger.d(`AppName is saved: ${appName}`)
return {
message: 'App Definition Saved',
}
} catch (error: any) {
// Cleanup if app was created but something failed
if (appCreated) {
try {
await dataStore.getAppsDataStore().deleteAppDefinition(appName)
} catch (cleanupError) {
Logger.e(
`Failed to cleanup app definition after error: ${cleanupError}`
)
}
}
// Re-throw the error
throw error
}
}
export interface GetAllAppDefinitionsResult extends BaseHandlerResult {
data: {
appDefinitions: any[]
rootDomain: string
captainSubDomain: string
defaultNginxConfig: any
}
}
export async function getAllAppDefinitions(
dataStore: DataStore,
serviceManager: ServiceManager
): Promise<GetAllAppDefinitionsResult> {
Logger.d('Getting all app definitions started')
try {
const apps = await dataStore.getAppsDataStore().getAppDefinitions()
const appsArray: any[] = []
Object.keys(apps).forEach(function (key) {
const app = apps[key]
app.appName = key
app.isAppBuilding = serviceManager.isAppBuilding(key)
app.appPushWebhook = app.appPushWebhook || undefined
appsArray.push(app)
})
const defaultNginxConfig = await dataStore.getDefaultAppNginxConfig()
Logger.d(`App definitions retrieved: ${appsArray.length} apps`)
return {
message: 'App definitions are retrieved.',
data: {
appDefinitions: appsArray,
rootDomain: dataStore.getRootDomain(),
captainSubDomain: CaptainConstants.configs.captainSubDomain,
defaultNginxConfig: defaultNginxConfig,
},
}
} catch (error: any) {
Logger.e(`Failed to get app definitions: ${error}`)
throw error
}
}
export interface UpdateAppDefinitionParams {
appName: string
projectId?: string
description?: string
instanceCount?: number | string
captainDefinitionRelativeFilePath?: string
envVars?: IAppEnvVar[]
volumes?: IAppVolume[]
tags?: IAppTag[]
nodeId?: string
notExposeAsWebApp?: boolean
containerHttpPort?: number | string
httpAuth?: any
forceSsl?: boolean
ports?: IAppPort[]
repoInfo?: RepoInfo | any
customNginxConfig?: string
redirectDomain?: string
preDeployFunction?: string
serviceUpdateOverride?: string
websocketSupport?: boolean
appDeployTokenConfig?: AppDeployTokenConfig
}
export async function updateAppDefinition(
params: UpdateAppDefinitionParams,
serviceManager: ServiceManager
): Promise<BaseHandlerResult> {
const {
appName,
projectId,
description,
instanceCount,
captainDefinitionRelativeFilePath,
envVars,
volumes,
tags,
nodeId,
notExposeAsWebApp,
containerHttpPort,
httpAuth,
forceSsl,
ports,
repoInfo: inputRepoInfo,
customNginxConfig,
redirectDomain,
preDeployFunction,
serviceUpdateOverride,
websocketSupport,
appDeployTokenConfig,
} = params
// Defaults & normalization
const normalizedDescription = `${description || ''}`
const instanceCountNum = Number(instanceCount ?? 0)
const containerHttpPortNum = Number(containerHttpPort) || 80
const normalizedEnvVars = envVars || []
const normalizedVolumes = volumes || []
const normalizedTags = tags || []
const normalizedPorts = ports || []
const normalizedNotExposeAsWebApp = !!notExposeAsWebApp
const normalizedForceSsl = !!forceSsl
const normalizedWebsocketSupport = !!websocketSupport
const normalizedRedirectDomain = `${redirectDomain || ''}`
const normalizedPreDeployFunction = `${preDeployFunction || ''}`
const normalizedServiceUpdateOverride = `${serviceUpdateOverride || ''}`
let normalizedDeployTokenConfig: AppDeployTokenConfig | undefined
if (!appDeployTokenConfig) {
normalizedDeployTokenConfig = { enabled: false }
} else {
normalizedDeployTokenConfig = {
enabled: !!appDeployTokenConfig.enabled,
appDeployToken: `${
appDeployTokenConfig.appDeployToken
? appDeployTokenConfig.appDeployToken
: ''
}`.trim(),
}
}
const repoInfo: any = inputRepoInfo || {}
if (repoInfo.user) {
repoInfo.user = repoInfo.user.trim()
}
if (repoInfo.repo) {
repoInfo.repo = repoInfo.repo.trim()
}
if (repoInfo.branch) {
repoInfo.branch = repoInfo.branch.trim()
}
if (
(repoInfo.branch ||
repoInfo.user ||
repoInfo.repo ||
repoInfo.password ||
repoInfo.sshKey) &&
(!repoInfo.branch ||
!repoInfo.repo ||
(!repoInfo.sshKey && !repoInfo.user && !repoInfo.password) ||
(repoInfo.password && !repoInfo.user) ||
(repoInfo.user && !repoInfo.password))
) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Missing required Github/BitBucket/Gitlab field'
)
}
if (
repoInfo &&
repoInfo.sshKey &&
repoInfo.sshKey.indexOf('ENCRYPTED') > 0 &&
!CaptainConstants.configs.disableEncryptedCheck
) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'You cannot use encrypted SSH keys'
)
}
if (
repoInfo &&
repoInfo.sshKey &&
repoInfo.sshKey.indexOf('END OPENSSH PRIVATE KEY-----') > 0
) {
repoInfo.sshKey = repoInfo.sshKey.trim()
repoInfo.sshKey = repoInfo.sshKey + '\n'
}
Logger.d(`Updating app started: ${appName}`)
await serviceManager.updateAppDefinition(
appName,
`${projectId || ''}`,
normalizedDescription,
instanceCountNum,
`${captainDefinitionRelativeFilePath || ''}`,
normalizedEnvVars,
normalizedVolumes,
normalizedTags,
`${nodeId || ''}`,
normalizedNotExposeAsWebApp,
containerHttpPortNum,
httpAuth as IHttpAuth,
normalizedForceSsl,
normalizedPorts,
repoInfo,
`${customNginxConfig || ''}`,
normalizedRedirectDomain,
normalizedPreDeployFunction,
normalizedServiceUpdateOverride,
normalizedWebsocketSupport,
normalizedDeployTokenConfig
)
Logger.d(`AppName is updated: ${appName}`)
return {
message: 'Updated App Definition Saved',
}
}

View File

@@ -0,0 +1,51 @@
import { IHashMapGeneric } from './ICacheGeneric'
export interface IOneClickAppIdentifier {
sortScore?: number // 0-1 and dynamically calculated based on search terms
isOfficial?: boolean
name: string
displayName: string
description: string
logoUrl: string
baseUrl: string
}
export interface IOneClickVariable {
id: string
label: string
defaultValue?: string
validRegex?: string
description?: string
}
export interface IDockerComposeService {
image?: string
volumes?: string[]
ports?: string[]
environment?: IHashMapGeneric<string>
depends_on?: string[]
hostname?: string
cap_add?: string[]
command?: string | string[]
// These are CapRover property, not DockerCompose. We use this instead of image if we need to extend the image.
caproverExtra?: {
dockerfileLines?: string[]
containerHttpPort: number
notExposeAsWebApp: boolean // This is actually a string "true", make sure to double negate!
websocketSupport: boolean // This is actually a string "true", make sure to double negate!
}
}
export interface IOneClickTemplate {
services: IHashMapGeneric<IDockerComposeService>
captainVersion: number
caproverOneClickApp: {
instructions: {
start: string
end: string
}
displayName: string
variables: IOneClickVariable[]
}
}

View File

@@ -0,0 +1,4 @@
export interface OneClickAppValuePair {
key: string
value: string
}

View File

@@ -1,7 +1,7 @@
import express = require('express')
import { v4 as uuid } from 'uuid'
import ApiStatusCodes from '../../api/ApiStatusCodes'
import BaseApi from '../../api/BaseApi'
import { registerProject } from '../../handlers/users/ProjectHandler'
import InjectionExtractor from '../../injection/InjectionExtractor'
import { ProjectDefinition } from '../../models/ProjectDefinition'
import Logger from '../../utils/Logger'
@@ -16,23 +16,15 @@ router.post('/register/', function (req, res, next) {
const parentProjectId = `${req.body.parentProjectId || ''}`.trim()
const description = `${req.body.description || ''}`.trim()
Promise.resolve()
.then(function () {
const projectId = uuid()
return dataStore.getProjectsDataStore().saveProject(projectId, {
id: projectId,
name: projectName,
parentProjectId: parentProjectId,
description: description,
})
})
.then(function (project) {
Logger.d(`Project created: ${projectName}`)
const resp = new BaseApi(
ApiStatusCodes.STATUS_OK,
`Project created: ${projectName}`
)
resp.data = project
return registerProject(
{ name: projectName, parentProjectId, description },
dataStore
)
.then(function (result) {
const resp = new BaseApi(ApiStatusCodes.STATUS_OK, result.message)
if (result.data) {
resp.data = result.data
}
res.send(resp)
})
.catch(ApiStatusCodes.createCatcher(res))

View File

@@ -1,8 +1,8 @@
import express = require('express')
import ApiStatusCodes from '../../../../api/ApiStatusCodes'
import BaseApi from '../../../../api/BaseApi'
import { uploadCaptainDefinitionContent as uploadCaptainDefinitionContentHandler } from '../../../../handlers/users/apps/appdata/AppDataHandler'
import InjectionExtractor from '../../../../injection/InjectionExtractor'
import Logger from '../../../../utils/Logger'
import multer = require('multer')
const TEMP_UPLOAD = 'temp_upload/'
@@ -70,6 +70,7 @@ router.post('/:appName/', function (req, res, next) {
.catch(ApiStatusCodes.createCatcher(res))
})
// uploadCaptainDefinitionContent
router.post(
'/:appName/',
upload.single('sourceFile'),
@@ -79,63 +80,27 @@ router.post(
const appName = req.params.appName
const isDetachedBuild = !!req.query.detached
const captainDefinitionContent =
(req.body.captainDefinitionContent || '') + ''
const gitHash = (req.body.gitHash || '') + ''
const captainDefinitionContent = `${req.body.captainDefinitionContent || ''}`
const gitHash = `${req.body.gitHash || ''}`
const tarballSourceFilePath: string = req.file ? req.file.path : ''
if (!!tarballSourceFilePath === !!captainDefinitionContent) {
res.send(
new BaseApi(
ApiStatusCodes.ILLEGAL_OPERATION,
'Either tarballfile or captainDefinitionContent should be present.'
)
)
return
}
return Promise.resolve().then(function () {
const promiseToDeployNewVer =
serviceManager.scheduleDeployNewVersion(appName, {
uploadedTarPathSource: tarballSourceFilePath
? {
uploadedTarPath: tarballSourceFilePath,
gitHash,
}
: undefined,
captainDefinitionContentSource: captainDefinitionContent
? {
captainDefinitionContent,
gitHash,
}
: undefined,
})
if (isDetachedBuild) {
res.send(
new BaseApi(
ApiStatusCodes.STATUS_OK_DEPLOY_STARTED,
'Deploy is started'
)
)
// To avoid unhandled promise error
promiseToDeployNewVer.catch(function (err) {
Logger.e(err)
})
} else {
promiseToDeployNewVer
.then(function () {
res.send(
new BaseApi(
ApiStatusCodes.STATUS_OK,
'Deploy is done'
)
)
})
.catch(ApiStatusCodes.createCatcher(res))
}
})
return uploadCaptainDefinitionContentHandler(
{
appName,
isDetachedBuild,
captainDefinitionContent: captainDefinitionContent || undefined,
gitHash: gitHash || undefined,
uploadedTarPathSource: tarballSourceFilePath || undefined,
},
serviceManager
)
.then(function (result) {
const status = isDetachedBuild
? ApiStatusCodes.STATUS_OK_DEPLOY_STARTED
: ApiStatusCodes.STATUS_OK
res.send(new BaseApi(status, result.message))
})
.catch(ApiStatusCodes.createCatcher(res))
}
)

View File

@@ -1,12 +1,14 @@
import express = require('express')
import ApiStatusCodes from '../../../../api/ApiStatusCodes'
import BaseApi from '../../../../api/BaseApi'
import {
getAllAppDefinitions,
registerAppDefinition,
updateAppDefinition,
} from '../../../../handlers/users/apps/appdefinition/AppDefinitionHandler'
import InjectionExtractor from '../../../../injection/InjectionExtractor'
import { AppDeployTokenConfig, IAppDef } from '../../../../models/AppDefinition'
import { ICaptainDefinition } from '../../../../models/ICaptainDefinition'
import { CaptainError } from '../../../../models/OtherTypes'
import { AppDeployTokenConfig } from '../../../../models/AppDefinition'
import CaptainManager from '../../../../user/system/CaptainManager'
import CaptainConstants from '../../../../utils/CaptainConstants'
import Logger from '../../../../utils/Logger'
import Utils from '../../../../utils/Utils'
@@ -60,39 +62,14 @@ router.get('/', function (req, res, next) {
InjectionExtractor.extractUserFromInjected(res).user.dataStore
const serviceManager =
InjectionExtractor.extractUserFromInjected(res).user.serviceManager
const appsArray: IAppDef[] = []
return dataStore
.getAppsDataStore()
.getAppDefinitions()
.then(function (apps) {
const promises: Promise<void>[] = []
Object.keys(apps).forEach(function (key, index) {
const app = apps[key]
app.appName = key
app.isAppBuilding = serviceManager.isAppBuilding(key)
app.appPushWebhook = app.appPushWebhook || undefined
appsArray.push(app)
})
return Promise.all(promises)
})
.then(function () {
return dataStore.getDefaultAppNginxConfig()
})
.then(function (defaultNginxConfig) {
return getAllAppDefinitions(dataStore, serviceManager)
.then(function (result) {
const baseApi = new BaseApi(
ApiStatusCodes.STATUS_OK,
'App definitions are retrieved.'
result.message
)
baseApi.data = {
appDefinitions: appsArray,
rootDomain: dataStore.getRootDomain(),
captainSubDomain: CaptainConstants.configs.captainSubDomain,
defaultNginxConfig: defaultNginxConfig,
}
baseApi.data = result.data
res.send(baseApi)
})
.catch(ApiStatusCodes.createCatcher(res))
@@ -190,69 +167,13 @@ router.post('/register/', function (req, res, next) {
const hasPersistentData = !!req.body.hasPersistentData
const isDetachedBuild = !!req.query.detached
let appCreated = false
Logger.d(`Registering app started: ${appName}`)
return Promise.resolve()
.then(function () {
if (projectId) {
return dataStore.getProjectsDataStore().getProject(projectId)
// if project is not found, it will throw an error
}
})
.then(function () {
return dataStore
.getAppsDataStore()
.registerAppDefinition(appName, projectId, hasPersistentData)
})
.then(function () {
appCreated = true
})
.then(function () {
const captainDefinitionContent: ICaptainDefinition = {
schemaVersion: 2,
imageName: CaptainConstants.configs.appPlaceholderImageName,
}
const promiseToIgnore = serviceManager
.scheduleDeployNewVersion(appName, {
captainDefinitionContentSource: {
captainDefinitionContent: JSON.stringify(
captainDefinitionContent
),
gitHash: '',
},
})
.catch(function (error) {
Logger.e(error)
})
if (!isDetachedBuild) return promiseToIgnore
})
.then(function () {
Logger.d(`AppName is saved: ${appName}`)
res.send(
new BaseApi(ApiStatusCodes.STATUS_OK, 'App Definition Saved')
)
})
.catch(function (error: CaptainError) {
function createRejectionPromise() {
return new Promise<void>(function (resolve, reject) {
reject(error)
})
}
if (appCreated) {
return dataStore
.getAppsDataStore()
.deleteAppDefinition(appName)
.then(function () {
return createRejectionPromise()
})
} else {
return createRejectionPromise()
}
return registerAppDefinition(
{ appName, projectId, hasPersistentData, isDetachedBuild },
dataStore,
serviceManager
)
.then(function (result) {
res.send(new BaseApi(ApiStatusCodes.STATUS_OK, result.message))
})
.catch(ApiStatusCodes.createCatcher(res))
})
@@ -329,6 +250,7 @@ router.post('/rename/', function (req, res, next) {
.catch(ApiStatusCodes.createCatcher(res))
})
// Update app configs
router.post('/update/', function (req, res, next) {
const serviceManager =
InjectionExtractor.extractUserFromInjected(res).user.serviceManager
@@ -339,103 +261,33 @@ router.post('/update/', function (req, res, next) {
const captainDefinitionRelativeFilePath =
req.body.captainDefinitionRelativeFilePath
const notExposeAsWebApp = req.body.notExposeAsWebApp
const tags = req.body.tags || []
const tags = req.body.tags
const customNginxConfig = req.body.customNginxConfig
const forceSsl = !!req.body.forceSsl
const websocketSupport = !!req.body.websocketSupport
const forceSsl = req.body.forceSsl
const websocketSupport = req.body.websocketSupport
const repoInfo = req.body.appPushWebhook
? req.body.appPushWebhook.repoInfo || {}
: {}
const envVars = req.body.envVars || []
const volumes = req.body.volumes || []
const ports = req.body.ports || []
const instanceCount = req.body.instanceCount || '0'
const redirectDomain = req.body.redirectDomain || ''
const preDeployFunction = req.body.preDeployFunction || ''
const serviceUpdateOverride = req.body.serviceUpdateOverride || ''
const containerHttpPort = Number(req.body.containerHttpPort) || 80
? req.body.appPushWebhook.repoInfo
: undefined
const envVars = req.body.envVars
const volumes = req.body.volumes
const ports = req.body.ports
const instanceCount = req.body.instanceCount
const redirectDomain = req.body.redirectDomain
const preDeployFunction = req.body.preDeployFunction
const serviceUpdateOverride = req.body.serviceUpdateOverride
const containerHttpPort = req.body.containerHttpPort
const httpAuth = req.body.httpAuth
let appDeployTokenConfig = req.body.appDeployTokenConfig as
const appDeployTokenConfig = req.body.appDeployTokenConfig as
| AppDeployTokenConfig
| undefined
const description = req.body.description || ''
const description = req.body.description
if (!appDeployTokenConfig) {
appDeployTokenConfig = { enabled: false }
} else {
appDeployTokenConfig = {
enabled: !!appDeployTokenConfig.enabled,
appDeployToken: `${
appDeployTokenConfig.appDeployToken
? appDeployTokenConfig.appDeployToken
: ''
}`.trim(),
}
}
if (repoInfo.user) {
repoInfo.user = repoInfo.user.trim()
}
if (repoInfo.repo) {
repoInfo.repo = repoInfo.repo.trim()
}
if (repoInfo.branch) {
repoInfo.branch = repoInfo.branch.trim()
}
if (
(repoInfo.branch ||
repoInfo.user ||
repoInfo.repo ||
repoInfo.password ||
repoInfo.sshKey) &&
(!repoInfo.branch ||
!repoInfo.repo ||
(!repoInfo.sshKey && !repoInfo.user && !repoInfo.password) ||
(repoInfo.password && !repoInfo.user) ||
(repoInfo.user && !repoInfo.password))
) {
res.send(
new BaseApi(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Missing required Github/BitBucket/Gitlab field'
)
)
return
}
if (
repoInfo &&
repoInfo.sshKey &&
repoInfo.sshKey.indexOf('ENCRYPTED') > 0 &&
!CaptainConstants.configs.disableEncryptedCheck
) {
res.send(
new BaseApi(
ApiStatusCodes.ILLEGAL_PARAMETER,
'You cannot use encrypted SSH keys'
)
)
return
}
if (
repoInfo &&
repoInfo.sshKey &&
repoInfo.sshKey.indexOf('END OPENSSH PRIVATE KEY-----') > 0
) {
repoInfo.sshKey = repoInfo.sshKey.trim()
repoInfo.sshKey = repoInfo.sshKey + '\n'
}
Logger.d(`Updating app started: ${appName}`)
return serviceManager
.updateAppDefinition(
return updateAppDefinition(
{
appName,
projectId,
description,
Number(instanceCount),
instanceCount,
captainDefinitionRelativeFilePath,
envVars,
volumes,
@@ -452,16 +304,12 @@ router.post('/update/', function (req, res, next) {
preDeployFunction,
serviceUpdateOverride,
websocketSupport,
appDeployTokenConfig
)
.then(function () {
Logger.d(`AppName is updated: ${appName}`)
res.send(
new BaseApi(
ApiStatusCodes.STATUS_OK,
'Updated App Definition Saved'
)
)
appDeployTokenConfig,
},
serviceManager
)
.then(function (result) {
res.send(new BaseApi(ApiStatusCodes.STATUS_OK, result.message))
})
.catch(ApiStatusCodes.createCatcher(res))
})

View File

@@ -3,10 +3,13 @@ import axios from 'axios'
import ApiStatusCodes from '../../../api/ApiStatusCodes'
import BaseApi from '../../../api/BaseApi'
import InjectionExtractor from '../../../injection/InjectionExtractor'
import { EventLogger } from '../../../user/events/EventLogger'
import {
CapRoverEventFactory,
CapRoverEventType,
} from '../../../user/events/ICapRoverEvent'
import OneClickAppDeployManager from '../../../user/oneclick/OneClickAppDeployManager'
import { OneClickDeploymentJobRegistry } from '../../../user/oneclick/OneClickDeploymentJobRegistry'
import CaptainConstants from '../../../utils/CaptainConstants'
import Logger from '../../../utils/Logger'
@@ -269,4 +272,153 @@ router.get('/template/app', function (req, res, next) {
.catch(ApiStatusCodes.createCatcher(res))
})
router.post('/deploy', function (req, res, next) {
const dataStore =
InjectionExtractor.extractUserFromInjected(res).user.dataStore
const serviceManager =
InjectionExtractor.extractUserFromInjected(res).user.serviceManager
const eventLogger =
InjectionExtractor.extractUserFromInjected(res).user.userManager
.eventLogger
const template = req.body.template
const values = req.body.values
const templateName = req.body.templateName
const deploymentJobRegistry = OneClickDeploymentJobRegistry.getInstance()
return Promise.resolve() //
.then(function () {
if (!template) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Template is required'
)
}
const jobId = deploymentJobRegistry.createJob()
reportAnalyticsOnAppDeploy(templateName, template, eventLogger)
new OneClickAppDeployManager(
dataStore,
serviceManager,
(deploymentState) => {
deploymentJobRegistry.updateJobProgress(
jobId,
deploymentState
)
Logger.dev(`Deployment state updated for jobId: ${jobId}`)
Logger.dev(
`Deployment state: ${JSON.stringify(deploymentState, null, 2)}`
)
}
).startDeployProcess(template, values)
const baseApi = new BaseApi(
ApiStatusCodes.STATUS_OK,
'One-click deployment started'
)
baseApi.data = { jobId }
res.send(baseApi)
})
.catch(ApiStatusCodes.createCatcher(res))
})
router.get('/deploy/progress', function (req, res, next) {
const jobId = req.query.jobId as string
const deploymentJobRegistry = OneClickDeploymentJobRegistry.getInstance()
return Promise.resolve() //
.then(function () {
// Validate input
if (!jobId) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Job ID is required'
)
}
// Check if job exists
if (!deploymentJobRegistry.jobExists(jobId)) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Job ID not found'
)
}
Logger.d(`Getting deployment progress for jobId: ${jobId}`)
// Get the current job state from deployment manager
const jobState = deploymentJobRegistry.getJobState(jobId)
if (!jobState) {
throw ApiStatusCodes.createError(
ApiStatusCodes.STATUS_ERROR_GENERIC,
'Unable to retrieve job state'
)
}
const baseApi = new BaseApi(
ApiStatusCodes.STATUS_OK,
'Deployment progress retrieved'
)
baseApi.data = jobState
res.send(baseApi)
})
.catch(ApiStatusCodes.createCatcher(res))
})
export default router
// This function analyzes the provided template to identify any unused fields in Docker service definitions.
// It then logs an analytics event with the unused fields and the template name (if it's an official or known template).
// This helps track which fields users are using and may inform future improvements to the one-click app templates.
export function reportAnalyticsOnAppDeploy(
templateName: any,
template: any,
eventLogger: EventLogger
) {
const unusedDockerServiceFieldNames: string[] = []
if (
templateName === 'TEMPLATE_ONE_CLICK' ||
templateName === 'DOCKER_COMPOSE'
) {
if (template?.services) {
template.services.forEach((service: any) => {
if (service && typeof service === 'object') {
Object.keys(service).forEach((key) => {
if (
!'image,environment,ports,volumes,depends_on,hostname,command,cap_add'
.split(',')
.includes(key)
) {
// log the unused keys so that we can track what to add next
if (!unusedDockerServiceFieldNames.includes(key)) {
unusedDockerServiceFieldNames.push(key)
}
}
})
}
})
}
}
// we do not want to log private repos names
const templateNameToReport =
templateName === 'TEMPLATE_ONE_CLICK' ||
templateName === 'DOCKER_COMPOSE' ||
(typeof templateName === 'string' &&
templateName.startsWith('OFFICIAL_'))
? templateName
: 'UNKNOWN'
eventLogger.trackEvent(
CapRoverEventFactory.create(
CapRoverEventType.OneClickAppDeployStarted,
{
unusedFields: unusedDockerServiceFieldNames,
templateName: templateNameToReport,
}
)
)
}

View File

@@ -30,7 +30,7 @@ function startServer() {
* Get port from environment and store in Express.
*/
const port = 3000
const port = CaptainConstants.serviceContainerPort3000
app.set('port', port)
/**

View File

@@ -5,6 +5,7 @@ export enum CapRoverEventType {
InstanceStarted = 'InstanceStarted',
OneClickAppDetailsFetched = 'OneClickAppDetailsFetched',
OneClickAppListFetched = 'OneClickAppListFetched',
OneClickAppDeployStarted = 'OneClickAppDeployStarted',
}
export interface ICapRoverEvent {

View File

@@ -22,6 +22,7 @@ export class AnalyticsLogger extends IEventsEmitter {
case CapRoverEventType.InstanceStarted:
case CapRoverEventType.OneClickAppDetailsFetched:
case CapRoverEventType.OneClickAppListFetched:
case CapRoverEventType.OneClickAppDeployStarted:
return true
}
}

View File

@@ -0,0 +1,309 @@
import DataStore from '../../datastore/DataStore'
import { IHashMapGeneric } from '../../models/ICacheGeneric'
import {
IDockerComposeService,
IOneClickTemplate,
} from '../../models/IOneClickAppModels'
import { OneClickAppValuePair } from '../../models/OneClickApp'
import Utils from '../../utils/Utils'
import ServiceManager from '../ServiceManager'
import OneClickAppDeploymentHelper from './OneClickAppDeploymentHelper'
export const ONE_CLICK_APP_NAME_VAR_NAME = '$$cap_appname'
interface IDeploymentStep {
stepName: string
stepPromise: () => Promise<any>
}
export interface IDeploymentState {
steps: string[]
error: string
successMessage?: string
currentStep: number
}
function replaceWith(
replaceThis: string,
withThis: string,
mainString: string
) {
return mainString.split(replaceThis).join(withThis)
}
export default class OneClickAppDeployManager {
private deploymentHelper: OneClickAppDeploymentHelper
private template: IOneClickTemplate | undefined
constructor(
dataStore: DataStore,
serviceManager: ServiceManager,
private onDeploymentStateChanged: (
deploymentState: IDeploymentState
) => void
) {
this.deploymentHelper = new OneClickAppDeploymentHelper(
dataStore,
serviceManager
)
}
startDeployProcess(
template: IOneClickTemplate,
valuesArray: OneClickAppValuePair[]
) {
const self = this
let stringified = JSON.stringify(template)
const values: IHashMapGeneric<string> = {}
valuesArray.forEach((element) => {
values[element.key] = element.value
})
if (
template.caproverOneClickApp.variables.find(
(v) => v.id === ONE_CLICK_APP_NAME_VAR_NAME
) &&
(!values[ONE_CLICK_APP_NAME_VAR_NAME] ||
values[ONE_CLICK_APP_NAME_VAR_NAME].trim().length === 0)
) {
this.onDeploymentStateChanged({
steps: ['Parsing the template'],
error: `App name ($$cap_appname) is required.`,
currentStep: 0,
})
return
}
for (
let index = 0;
index < template.caproverOneClickApp.variables.length;
index++
) {
const element = template.caproverOneClickApp.variables[index]
stringified = replaceWith(
element.id,
values[element.id] || '',
stringified
)
}
try {
this.template = JSON.parse(stringified)
} catch (error) {
this.onDeploymentStateChanged({
steps: ['Parsing the template'],
error: `Cannot parse: ${stringified}\n\n\n\n${error}`,
currentStep: 0,
})
return
}
// Dependency tree and sort apps using "createAppsArrayInOrder"
// Call "createDeploymentStepPromises" for all apps.
// populate steps as string[]
// create promise chain with catch -> error. Each promise gets followed by currentStep++ promise
// Start running promises,
const apps = this.createAppsArrayInOrder()
if (!apps) {
self.onDeploymentStateChanged({
steps: ['Parsing the template'],
error: 'Cannot parse the template. Dependency tree cannot be resolved. Infinite loop!!',
currentStep: 0,
})
} else if (apps.length === 0) {
self.onDeploymentStateChanged({
steps: ['Parsing the template'],
error: 'Cannot parse the template. No services found!!',
currentStep: 0,
})
} else {
const steps: IDeploymentStep[] = []
const capAppName = values[ONE_CLICK_APP_NAME_VAR_NAME]
const projectMemoryCache = { projectId: '' }
if (apps.length > 1) {
steps.push(
self.createDeploymentStepForProjectCreation(
capAppName,
projectMemoryCache
)
)
}
for (let index = 0; index < apps.length; index++) {
const appToDeploy = apps[index]
steps.push(
...self.createDeploymentStepPromises(
appToDeploy.appName,
appToDeploy.service,
capAppName,
projectMemoryCache
)
)
}
const stepsTexts: string[] = ['Parsing the template']
for (let index = 0; index < steps.length; index++) {
stepsTexts.push(steps[index].stepName)
}
let currentStep = 0
const onNextStepPromiseCreator = function () {
return new Promise<void>(function (resolve) {
currentStep++
self.onDeploymentStateChanged(
Utils.copyObject({
steps: stepsTexts,
error: '',
currentStep,
successMessage:
currentStep >= stepsTexts.length
? self.template!.caproverOneClickApp
.instructions.end
: undefined,
})
)
resolve()
})
}
let promise = onNextStepPromiseCreator()
for (let index = 0; index < steps.length; index++) {
const element = steps[index]
promise = promise
.then(element.stepPromise)
.then(onNextStepPromiseCreator)
}
promise.catch(function (error) {
self.onDeploymentStateChanged(
Utils.copyObject({
steps: stepsTexts,
error: `Failed: ${error}`,
currentStep,
})
)
})
}
}
/**
* Outputs an array which includes all services in order based on their dependencies.
* The first element is an app without any dependencies. The second element can be an app that depends on the first app.
*/
private createAppsArrayInOrder() {
const apps: {
appName: string
service: IDockerComposeService
}[] = []
let numberOfServices = 0
const servicesMap = this.template!.services
Object.keys(servicesMap).forEach(function (key) {
numberOfServices++
})
let itCount = 0
while (apps.length < numberOfServices) {
if (itCount > numberOfServices) {
// we are stuck in an infinite loop
return undefined
}
itCount++
Object.keys(servicesMap).forEach(function (appName) {
for (let index = 0; index < apps.length; index++) {
const element = apps[index]
if (element.appName === appName) {
// already added
return
}
}
const service = servicesMap[appName]
const dependsOn = service.depends_on || []
for (let index = 0; index < dependsOn.length; index++) {
const element = dependsOn[index]
let dependencyAlreadyAdded = false
for (let j = 0; j < apps.length; j++) {
if (element === apps[j].appName) {
dependencyAlreadyAdded = true
}
}
if (!dependencyAlreadyAdded) return
}
apps.push({
appName,
service,
})
})
}
return apps
}
private createDeploymentStepForProjectCreation(
capAppName: string,
projectMemoryCache: { projectId: string }
): IDeploymentStep {
const self = this
return {
stepName: `Creating project ${capAppName}`,
stepPromise: function () {
return self.deploymentHelper.createRegisterPromiseProject(
capAppName,
projectMemoryCache
)
},
}
}
private createDeploymentStepPromises(
appName: string,
dockerComposeService: IDockerComposeService,
capAppName: string,
projectMemoryCache: { projectId: string }
): IDeploymentStep[] {
const promises: IDeploymentStep[] = []
const self = this
promises.push({
stepName: `Registering ${appName}`,
stepPromise: function () {
return self.deploymentHelper.createRegisterPromise(
appName,
dockerComposeService,
projectMemoryCache
)
},
})
promises.push({
stepName: `Configuring ${appName} (volumes, ports, environmental variables)`,
stepPromise: function () {
return self.deploymentHelper.createConfigurationPromise(
appName,
dockerComposeService,
capAppName
)
},
})
promises.push({
stepName: `Deploying ${appName} (might take up to a minute)`,
stepPromise: function () {
return self.deploymentHelper.createDeploymentPromise(
appName,
dockerComposeService
)
},
})
return promises
}
}

View File

@@ -0,0 +1,240 @@
import DataStore from '../../datastore/DataStore'
import { uploadCaptainDefinitionContent as uploadCaptainDefinitionContentHandler } from '../../handlers/users/apps/appdata/AppDataHandler'
import {
getAllAppDefinitions,
registerAppDefinition,
updateAppDefinition,
} from '../../handlers/users/apps/appdefinition/AppDefinitionHandler'
import { registerProject } from '../../handlers/users/ProjectHandler'
import { IAppDef } from '../../models/AppDefinition'
import { ICaptainDefinition } from '../../models/ICaptainDefinition'
import { IDockerComposeService } from '../../models/IOneClickAppModels'
import { ProjectDefinition } from '../../models/ProjectDefinition'
import DockerComposeToServiceOverride from '../../utils/DockerComposeToServiceOverride'
import Utils from '../../utils/Utils'
import ServiceManager from '../ServiceManager'
class ApiManager {
constructor(
private dataStore: DataStore,
private serviceManager: ServiceManager
) {
// Initialize if needed
}
registerNewApp(
appName: string,
projectId: string,
hasPersistentData: boolean,
isDetachedBuild: boolean
): Promise<any> {
return registerAppDefinition(
{
appName,
projectId,
hasPersistentData,
isDetachedBuild,
},
this.dataStore,
this.serviceManager
)
}
registerProject(projectDef: ProjectDefinition) {
return registerProject(projectDef, this.dataStore)
}
getAllApps() {
return getAllAppDefinitions(this.dataStore, this.serviceManager)
}
updateConfigAndSave(appName: string, appDef: IAppDef) {
return updateAppDefinition({ appName, ...appDef }, this.serviceManager)
}
uploadCaptainDefinitionContent(
appName: string,
captainDefinition: ICaptainDefinition,
gitHash: string,
isDetachedBuild: boolean
) {
const captainDefinitionContent = JSON.stringify(captainDefinition)
return uploadCaptainDefinitionContentHandler(
{
appName,
isDetachedBuild,
captainDefinitionContent,
gitHash,
},
this.serviceManager
)
}
}
export default class OneClickAppDeploymentHelper {
private apiManager: ApiManager
constructor(dataStore: DataStore, serviceManager: ServiceManager) {
this.apiManager = new ApiManager(dataStore, serviceManager)
}
createRegisterPromise(
appName: string,
dockerComposeService: IDockerComposeService,
projectMemoryCache: { projectId: string }
) {
const self = this
return Promise.resolve().then(function () {
return self.apiManager.registerNewApp(
appName,
projectMemoryCache.projectId,
!!dockerComposeService.volumes &&
!!dockerComposeService.volumes.length,
false
)
})
}
createRegisterPromiseProject(
appName: string,
projectMemoryCache: { projectId: string }
) {
const self = this
return Promise.resolve().then(function () {
const projectDef: ProjectDefinition = {
id: '',
name: appName,
description: ``,
}
// change backend to ensure this returns project ID
return self.apiManager
.registerProject(projectDef)
.then(function (result) {
projectMemoryCache.projectId = result.data.id
})
})
}
createConfigurationPromise(
appName: string,
dockerComposeService: IDockerComposeService,
capAppName: string
) {
const self = this
return Promise.resolve().then(function () {
return self.apiManager
.getAllApps()
.then(function (result) {
const appDefs = result.data.appDefinitions as IAppDef[]
for (let index = 0; index < appDefs.length; index++) {
const element = appDefs[index]
if (element.appName === appName) {
return Utils.copyObject(element)
}
}
})
.then(function (appDef) {
if (!appDef) {
throw new Error(
'App was not found right after registering!!'
)
}
appDef.volumes = appDef.volumes || []
appDef.tags = [
{
tagName: capAppName,
},
]
const vols = dockerComposeService.volumes || []
for (let i = 0; i < vols.length; i++) {
const elements = vols[i].split(':')
if (elements[0].startsWith('/')) {
appDef.volumes.push({
hostPath: elements[0],
containerPath: elements[1],
})
} else {
appDef.volumes.push({
volumeName: elements[0],
containerPath: elements[1],
})
}
}
appDef.ports = appDef.ports || []
const ports = dockerComposeService.ports || []
for (let i = 0; i < ports.length; i++) {
const elements = ports[i].split(':')
appDef.ports.push({
hostPort: Number(elements[0]),
containerPort: Number(elements[1]),
})
}
appDef.envVars = appDef.envVars || []
const environment = dockerComposeService.environment || {}
Object.keys(environment).forEach(function (envKey) {
appDef.envVars.push({
key: envKey,
value: environment[envKey],
})
})
const overrideYaml =
DockerComposeToServiceOverride.convertUnconsumedComposeParametersToServiceOverride(
dockerComposeService
)
if (overrideYaml) {
appDef.serviceUpdateOverride = overrideYaml
}
if (dockerComposeService.caproverExtra) {
if (
dockerComposeService.caproverExtra.containerHttpPort
) {
appDef.containerHttpPort =
dockerComposeService.caproverExtra.containerHttpPort
}
if (
dockerComposeService.caproverExtra.notExposeAsWebApp
) {
appDef.notExposeAsWebApp = true
}
if (
dockerComposeService.caproverExtra.websocketSupport
) {
appDef.websocketSupport = true
}
}
return self.apiManager.updateConfigAndSave(appName, appDef)
})
})
}
createDeploymentPromise(
appName: string,
dockerComposeService: IDockerComposeService
) {
const self = this
return Promise.resolve().then(function () {
const captainDefinition: ICaptainDefinition = {
schemaVersion: 2,
}
if (dockerComposeService.image) {
captainDefinition.imageName = dockerComposeService.image
} else {
captainDefinition.dockerfileLines =
dockerComposeService.caproverExtra?.dockerfileLines
}
return self.apiManager.uploadCaptainDefinitionContent(
appName,
captainDefinition,
'',
false
)
})
}
}

View File

@@ -0,0 +1,149 @@
import Logger from '../../utils/Logger'
export interface IDeploymentState {
steps: string[]
error: string
successMessage?: string
currentStep: number
}
interface IJobInfo {
jobId: string
state: IDeploymentState
createdAt: Date
updatedAt: Date
}
/**
* In-memory registry for tracking one-click deployment job progress
*/
export class OneClickDeploymentJobRegistry {
private static instance: OneClickDeploymentJobRegistry
private jobs: Map<string, IJobInfo> = new Map()
private constructor() {
this.startAutomaticCleanup()
}
public static getInstance(): OneClickDeploymentJobRegistry {
if (!OneClickDeploymentJobRegistry.instance) {
OneClickDeploymentJobRegistry.instance =
new OneClickDeploymentJobRegistry()
}
return OneClickDeploymentJobRegistry.instance
}
/**
* Generate a unique job ID
*/
private generateJobId(): string {
return `deploy_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Create a new deployment job with initial state
* Returns the generated job ID
*/
public createJob(): string {
const jobId = this.generateJobId()
const initialState: IDeploymentState = {
steps: ['Queuing deployment'],
currentStep: 0,
error: '',
successMessage: '',
}
const jobInfo: IJobInfo = {
jobId,
state: initialState,
createdAt: new Date(),
updatedAt: new Date(),
}
this.jobs.set(jobId, jobInfo)
return jobId
}
/**
* Update the progress of a deployment job
*/
public updateJobProgress(
jobId: string,
newState: IDeploymentState
): boolean {
const jobInfo = this.jobs.get(jobId)
if (!jobInfo) {
return false
}
jobInfo.state = newState
jobInfo.updatedAt = new Date()
this.jobs.set(jobId, jobInfo)
return true
}
/**
* Get the current state of a deployment job
*/
public getJobState(jobId: string): IDeploymentState | null {
const jobInfo = this.jobs.get(jobId)
return jobInfo ? jobInfo.state : null
}
/**
* Check if a job exists
*/
public jobExists(jobId: string): boolean {
return this.jobs.has(jobId)
}
/**
* Remove a job from tracking (cleanup)
*/
public removeJob(jobId: string): boolean {
return this.jobs.delete(jobId)
}
/**
* Get all job IDs (for debugging/admin purposes)
*/
public getAllJobIds(): string[] {
return Array.from(this.jobs.keys())
}
/**
* Clean up old jobs (older than specified hours)
*/
cleanupOldJobs(olderThanHours: number = 24): number {
const cutoffTime = new Date()
cutoffTime.setHours(cutoffTime.getHours() - olderThanHours)
let removedCount = 0
for (const [jobId, jobInfo] of this.jobs.entries()) {
if (jobInfo.updatedAt < cutoffTime) {
this.jobs.delete(jobId)
removedCount++
}
}
return removedCount
}
/**
* Start automatic cleanup that runs every 4 hours
*/
private startAutomaticCleanup(): void {
// Clean up jobs older than 24 hours every 4 hours (4 * 60 * 60 * 1000 ms)
setInterval(
() => {
const removedCount = this.cleanupOldJobs(24)
if (removedCount > 0) {
Logger.d(
`OneClick deployment cleanup: removed ${removedCount} old job(s)`
)
}
},
4 * 60 * 60 * 1000
)
}
}

View File

@@ -243,6 +243,7 @@ export default class ProManager {
case CapRoverEventType.InstanceStarted:
case CapRoverEventType.OneClickAppDetailsFetched:
case CapRoverEventType.OneClickAppListFetched:
case CapRoverEventType.OneClickAppDeployStarted:
return false
}
}

View File

@@ -658,7 +658,7 @@ export default class BackupManager {
// https://github.com/jprichardson/node-fs-extra/issues/638
return new Promise(function (resolve, reject) {
const child = exec(
`mkdir -p {dest} && cp -rp ${CaptainConstants.captainDataDirectory} ${dest}`
`mkdir -p ${dest} && cp -rp ${CaptainConstants.captainDataDirectory} ${dest} && mkdir -p ${dest}/shared-logs && rm -rf ${dest}/shared-logs`
)
child.addListener('error', reject)
child.addListener('exit', resolve)

View File

@@ -531,8 +531,8 @@ class LoadBalancerManager {
hasRootSsl: hasRootSsl,
serviceName: CaptainConstants.captainServiceName,
domain: captainDomain,
serviceExposedPort:
CaptainConstants.configs.adminPortNumber3000,
serviceContainerPort3000:
CaptainConstants.serviceContainerPort3000,
defaultHtmlDir:
CaptainConstants.nginxStaticRootDir +
CaptainConstants.nginxDefaultHtmlDir,

View File

@@ -92,6 +92,8 @@ const data = {
isDebug: EnvVars.CAPTAIN_IS_DEBUG,
serviceContainerPort3000: 3000,
rootNameSpace: 'captain',
// *********************** Disk Paths ************************

View File

@@ -43,12 +43,92 @@ function checkSystemReq() {
return DockerApi.get().getDockerInfo()
})
.then(function (output) {
if (output.OperatingSystem.toLowerCase().indexOf('ubuntu') < 0) {
// Check for known incompatible hosts
if (
output.KernelVersion &&
output.KernelVersion.toLowerCase().includes('-pve')
) {
console.log(' ')
console.log('******* ERROR *******')
console.log(
'******* Warning ******* CapRover and Docker work best on Ubuntu - specially when it comes to storage drivers.'
'CapRover may not work properly on this host (Proxmox LXC containers).'
)
} else {
console.log(' Ubuntu detected.')
console.log(
'Proxmox containers are known to break Docker swarm networking.'
)
console.log(
'Consider using a different virtualization platform.'
)
throw new Error('Host is not compatible with CapRover.')
} else if (
output.KernelVersion &&
!output.KernelVersion.toLowerCase().includes('-generic')
) {
console.log(' Kernel version:', output.KernelVersion)
console.log(
' Warning: Non-generic kernel detected. CapRover compatibility may vary.'
)
}
// Check operating system
if (output.OperatingSystem) {
const osLower = output.OperatingSystem.toLowerCase()
if (osLower.indexOf('ubuntu') >= 0) {
// Check for Ubuntu 22+ specifically
const ubuntuMatch = osLower.match(/ubuntu\s+(\d+)/)
if (ubuntuMatch) {
const version = parseInt(ubuntuMatch[1])
if (version < 22) {
console.log(
' Ubuntu detected but version is below 22.'
)
console.log(
' Warning: Ubuntu 22+ is recommended for best compatibility.'
)
}
} else {
console.log(
'WARNING: Ubuntu detected, but version not found.'
)
}
} else {
console.log(
'******* Warning ******* CapRover and Docker work best on Ubuntu - specially when it comes to storage drivers.'
)
}
}
// Check backing filesystem
if (output.DriverStatus) {
const backingFsMatch = output.DriverStatus.find(
(item: any) => item[0] === 'Backing Filesystem'
)
if (backingFsMatch) {
const backingFs = backingFsMatch[1]
if (backingFs != 'extfs') {
console.log(' Backing filesystem:', backingFs)
console.log(
' WARNING: extfs is recommended for best compatibility.'
)
}
}
}
// Check userxattr
if (output.DriverStatus) {
const userxattrMatch = output.DriverStatus.find(
(item: any) => item[0] === 'userxattr'
)
if (userxattrMatch) {
const userxattr = userxattrMatch[1]
if (userxattr) {
console.log(' userxattr:', userxattr)
console.log(
' Warning: userxattr should be false for best compatibility.'
)
}
}
}
const totalMemInMb = Math.round(output.MemTotal / 1000.0 / 1000.0)
@@ -99,7 +179,7 @@ function startServerOnPort_80_443_3000() {
})
res.write(FIREWALL_PASSED)
res.end()
}).listen(3000)
}).listen(CaptainConstants.serviceContainerPort3000)
return new Promise<void>(function (resolve) {
setTimeout(function () {
@@ -407,7 +487,7 @@ export function install() {
ports.push({
protocol: 'tcp',
publishMode: 'host',
containerPort: 3000,
containerPort: CaptainConstants.serviceContainerPort3000,
hostPort: CaptainConstants.configs.adminPortNumber3000,
})

View File

@@ -0,0 +1,102 @@
import * as yaml from 'yaml'
import { IDockerComposeService } from '../models/IOneClickAppModels'
import Utils from './Utils'
export default class DockerComposeToServiceOverride {
/**
* Converts the unsupported docker compose parameters to CapRover service override definition.
* Port, replicas, env vars, volumes, and image are supplied through CapRover definition,
* network will be set to captain-overlay restart_policy is not generally needed,
* by default docker services restart automatically.
* Only parse parameters that are not from the aforementioned list.
* The only useful parameter that we are parsing at the moment is hostname: https://github.com/caprover/caprover/issues/404
*
* @param docker compose service definition
* @returns the override service definition in yaml format
*/
static convertUnconsumedComposeParametersToServiceOverride(
compose: IDockerComposeService
) {
const overrides = [] as any[]
overrides.push(DockerComposeToServiceOverride.parseHostname(compose))
overrides.push(DockerComposeToServiceOverride.parseCapAdd(compose))
overrides.push(DockerComposeToServiceOverride.parseCommand(compose))
// Add more overrides here if needed
let mergedOverride = {} as any
overrides.forEach((o) => {
mergedOverride = Utils.mergeObjects(mergedOverride, o)
})
if (Object.keys(mergedOverride).length === 0) {
return undefined
}
return yaml.stringify(mergedOverride)
}
private static parseCommand(compose: IDockerComposeService) {
const override = {} as any
function parseDockerCMD(cmdString: string) {
// Matches sequences inside quotes or sequences without spaces
const regex = /[^\s"']+|"([^"]*)"|'([^']*)'/g
let match
const args = []
while ((match = regex.exec(cmdString))) {
// If matched quotes, add the first group that is not undefined
if (match[1]) {
args.push(match[1])
} else if (match[2]) {
args.push(match[2])
} else {
args.push(match[0])
}
}
return args
}
if (compose.command) {
override.TaskTemplate = {
ContainerSpec: {
Command: Array.isArray(compose.command)
? compose.command
: parseDockerCMD(compose.command),
},
}
}
return override
}
private static parseHostname(compose: IDockerComposeService) {
const override = {} as any
const hostname = compose.hostname ? `${compose.hostname}`.trim() : ''
if (compose.hostname) {
override.TaskTemplate = {
ContainerSpec: {
Hostname: hostname,
},
}
}
return override
}
private static parseCapAdd(compose: IDockerComposeService) {
const override = {} as any
if (!!compose.cap_add && Array.isArray(compose.cap_add)) {
const capabilityAdd = compose.cap_add
.map((cap) => cap.toUpperCase())
.map((cap) => (cap.startsWith(`CAP`) ? cap : `CAP_${cap}`))
override.TaskTemplate = {
ContainerSpec: {
CapabilityAdd: capabilityAdd,
},
}
}
return override
}
}

View File

@@ -86,7 +86,7 @@
# https://docs.docker.com/engine/userguide/networking/configure-dns/
# https://github.com/moby/moby/issues/20026
resolver 127.0.0.11 valid=10s;
set $upstream http://<%-captain.serviceName%>:<%-captain.serviceExposedPort%>;
set $upstream http://<%-captain.serviceName%>:<%-captain.serviceContainerPort3000%>;
# IMPORTANT!! Except proxy_read_timeout, this block should be same as location /api/v2/user/apps/appData
location / {

View File

@@ -0,0 +1,448 @@
/**
* TEST FILE: OneClickAppRouter.test.ts
*
* This file tests the `reportAnalyticsOnAppDeploy` function which tracks analytics
* when one-click apps or Docker Compose templates are deployed in CapRover.
*
* FUNCTION OVERVIEW: reportAnalyticsOnAppDeploy(templateName, template, eventLogger)
*
* PURPOSE:
* • Collects anonymous analytics about app deployments for usage insights
* • Tracks template usage patterns and custom Docker service fields
* • Helps identify which features are being used by the community
*
* TEMPLATE NAME HANDLING:
* • TEMPLATE_ONE_CLICK - One-click app templates (reported as-is)
* • DOCKER_COMPOSE - User-provided Docker Compose files (reported as-is)
* • OFFICIAL_* - Official CapRover templates (reported as-is)
* • Custom/private names - Anonymized as "UNKNOWN" for privacy
* • Invalid inputs (null, undefined, non-string) - Defaults to "UNKNOWN"
*
* DOCKER SERVICE FIELD TRACKING:
* • Only tracks unused fields for TEMPLATE_ONE_CLICK and DOCKER_COMPOSE
* • Known Docker fields (image, ports, volumes, environment, etc.) are consumed by CapRover
* • Custom/unknown fields are collected in an array for analytics
* • Helps identify which Docker features users need but aren't supported
* • Handles edge cases: null services, non-object entries, missing properties
*
* EVENT LOGGING:
* • Creates a CapRoverEventType.OneClickAppDeployStarted event
* • Event metadata includes: templateName (string) and unusedFields (string[])
* • Uses CapRoverEventFactory to create events with proper structure
* • Calls eventLogger.trackEvent() exactly once per deployment
*
* ERROR HANDLING:
* • Robust handling of malformed inputs (null, undefined, wrong types)
* • Graceful degradation - never throws errors, always logs something
* • Array deduplication for repeated custom field names across services
* • Safe iteration over potentially corrupted service arrays
*/
// Jest hoists this mock before the imports
jest.mock('../src/user/events/ICapRoverEvent', () => ({
CapRoverEventFactory: {
create: jest.fn().mockImplementation((type: any, data: any) => ({
eventType: type,
eventMetadata: data,
timestamp: Date.now(),
})),
},
CapRoverEventType: {
OneClickAppDeployStarted: 'OneClickAppDeployStarted',
},
}))
import { reportAnalyticsOnAppDeploy } from '../src/routes/user/oneclick/OneClickAppRouter'
import { EventLogger } from '../src/user/events/EventLogger'
import {
CapRoverEventFactory,
CapRoverEventType,
ICapRoverEvent,
} from '../src/user/events/ICapRoverEvent'
describe('reportAnalyticsOnAppDeploy', () => {
let mockEventLogger: EventLogger
let trackedEvents: ICapRoverEvent[]
beforeEach(() => {
trackedEvents = []
// Create a proper mock that satisfies EventLogger interface
mockEventLogger = {
trackEvent: jest.fn((event: ICapRoverEvent) => {
trackedEvents.push(event)
}),
} as any as EventLogger
// Reset the factory mock
;(CapRoverEventFactory.create as jest.Mock).mockClear()
})
describe('Template name handling', () => {
test('should report TEMPLATE_ONE_CLICK template name as-is', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe(
'TEMPLATE_ONE_CLICK'
)
})
test('should report DOCKER_COMPOSE template name as-is', () => {
const templateName = 'DOCKER_COMPOSE'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe(
'DOCKER_COMPOSE'
)
})
test('should report OFFICIAL_ prefixed template names as-is', () => {
const templateName = 'OFFICIAL_NGINX'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe(
'OFFICIAL_NGINX'
)
})
test('should report private/custom template names as UNKNOWN', () => {
const templateName = 'my-private-template'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN')
})
test('should handle null templateName', () => {
const templateName = null
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN')
})
test('should handle undefined templateName', () => {
const templateName = undefined
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN')
})
})
describe('Docker service field tracking', () => {
test('should not track unused fields for non-Docker templates', () => {
const templateName = 'SOME_OTHER_TEMPLATE'
const template = {
services: [
{
image: 'nginx:latest',
custom_field: 'value',
another_field: 'value2',
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(trackedEvents[0].eventMetadata.unusedFields).toEqual([])
})
test('should track unused fields for TEMPLATE_ONE_CLICK', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [
{
image: 'nginx:latest',
environment: { VAR: 'value' },
custom_field: 'value',
another_field: 'value2',
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual(['custom_field', 'another_field'])
})
test('should track unused fields for DOCKER_COMPOSE', () => {
const templateName = 'DOCKER_COMPOSE'
const template = {
services: [
{
image: 'postgres:latest',
volumes: ['/data:/var/lib/postgresql/data'],
restart: 'always',
networks: ['backend'],
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual(['restart', 'networks'])
})
test('should not track known Docker service fields', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [
{
image: 'nginx:latest',
environment: { VAR: 'value' },
ports: ['80:80'],
volumes: ['/data:/data'],
depends_on: ['db'],
hostname: 'web',
command: 'nginx -g "daemon off;"',
cap_add: ['SYS_ADMIN'],
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
test('should handle multiple services with mixed fields', () => {
const templateName = 'DOCKER_COMPOSE'
const template = {
services: [
{
image: 'nginx:latest',
ports: ['80:80'],
custom_field1: 'value1',
},
{
image: 'postgres:latest',
environment: { POSTGRES_DB: 'mydb' },
custom_field2: 'value2',
another_custom: 'value3',
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([
'custom_field1',
'custom_field2',
'another_custom',
])
})
test('should handle services with no custom fields', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [
{
image: 'nginx:latest',
ports: ['80:80'],
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
test('should handle empty services array', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
test('should handle template without services property', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { other_property: 'value' }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
test('should handle null template', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = null
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
test('should handle undefined template', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = undefined
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual([])
})
})
describe('Event logging', () => {
test('should call eventLogger.trackEvent exactly once', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
})
test('should create event with correct type', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(trackedEvents[0].eventType).toBe(
CapRoverEventType.OneClickAppDeployStarted
)
})
test('should create event with correct metadata structure', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [
{
image: 'nginx:latest',
custom_field: 'value',
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const eventMetadata = trackedEvents[0].eventMetadata
expect(eventMetadata).toHaveProperty('unusedFields')
expect(eventMetadata).toHaveProperty('templateName')
expect(Array.isArray(eventMetadata.unusedFields)).toBe(true)
expect(typeof eventMetadata.templateName).toBe('string')
})
})
describe('Edge cases and error handling', () => {
test('should handle services with null/undefined service objects', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [null, undefined, { image: 'nginx:latest' }],
}
expect(() => {
reportAnalyticsOnAppDeploy(
templateName,
template,
mockEventLogger
)
}).not.toThrow()
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
})
test('should handle services array containing non-object values', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: ['string', 123, true, { image: 'nginx:latest' }],
}
expect(() => {
reportAnalyticsOnAppDeploy(
templateName,
template,
mockEventLogger
)
}).not.toThrow()
expect(mockEventLogger.trackEvent).toHaveBeenCalledTimes(1)
})
test('should handle template name that is not a string', () => {
const templateName = 123
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN')
})
test('should handle empty string template name', () => {
const templateName = ''
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(trackedEvents[0].eventMetadata.templateName).toBe('UNKNOWN')
})
test('should preserve array uniqueness for duplicate unused fields', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = {
services: [
{
image: 'nginx:latest',
custom_field: 'value1',
},
{
image: 'postgres:latest',
custom_field: 'value2', // Same field name, different value
},
],
}
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
const unusedFields = trackedEvents[0].eventMetadata.unusedFields
expect(unusedFields).toEqual(['custom_field'])
expect(unusedFields.length).toBe(1)
})
})
describe('Integration with CapRoverEventFactory', () => {
test('should use CapRoverEventFactory.create method', () => {
const templateName = 'TEMPLATE_ONE_CLICK'
const template = { services: [] }
reportAnalyticsOnAppDeploy(templateName, template, mockEventLogger)
expect(CapRoverEventFactory.create).toHaveBeenCalledWith(
CapRoverEventType.OneClickAppDeployStarted,
{
unusedFields: [],
templateName: 'TEMPLATE_ONE_CLICK',
}
)
})
})
})