mirror of
https://github.com/caprover/caprover
synced 2025-10-30 10:07:01 +00:00
Compare commits
37 Commits
683f353533
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77725773a2 | ||
|
|
26e14f7411 | ||
|
|
5d03bb3ea2 | ||
|
|
6fe86982bc | ||
|
|
587a63e3af | ||
|
|
be5d55d4ce | ||
|
|
cf4d6f99ce | ||
|
|
5e4f08db1c | ||
|
|
ec8371dc12 | ||
|
|
8bd58bf32c | ||
|
|
1b89d9123b | ||
|
|
e902762809 | ||
|
|
b47bdddc66 | ||
|
|
fa2f251db1 | ||
|
|
399bfd3c86 | ||
|
|
15595a6a62 | ||
|
|
9caba312a1 | ||
|
|
bc2318d056 | ||
|
|
2664437159 | ||
|
|
38096fff15 | ||
|
|
e098ba900b | ||
|
|
0fa8643fae | ||
|
|
a0247e068e | ||
|
|
c3abec0367 | ||
|
|
6a35863cd3 | ||
|
|
b592ebed6e | ||
|
|
ef3a4db51f | ||
|
|
c07a202523 | ||
|
|
80fe613b0f | ||
|
|
61d157fdae | ||
|
|
2baddabef6 | ||
|
|
596cecfca9 | ||
|
|
09a9bd778d | ||
|
|
7abb1c00a6 | ||
|
|
f97e3eedfe | ||
|
|
6883291673 | ||
|
|
47a5d8fa19 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -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.
|
||||
|
||||
|
||||
6
.github/workflows/lint_project.yml
vendored
6
.github/workflows/lint_project.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
4403
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
3
src/handlers/BaseHandlerResult.ts
Normal file
3
src/handlers/BaseHandlerResult.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface BaseHandlerResult {
|
||||
message: string
|
||||
}
|
||||
46
src/handlers/users/ProjectHandler.ts
Normal file
46
src/handlers/users/ProjectHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
71
src/handlers/users/apps/appdata/AppDataHandler.ts
Normal file
71
src/handlers/users/apps/appdata/AppDataHandler.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
305
src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts
Normal file
305
src/handlers/users/apps/appdefinition/AppDefinitionHandler.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
51
src/models/IOneClickAppModels.ts
Normal file
51
src/models/IOneClickAppModels.ts
Normal 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[]
|
||||
}
|
||||
}
|
||||
4
src/models/OneClickApp.ts
Normal file
4
src/models/OneClickApp.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface OneClickAppValuePair {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum CapRoverEventType {
|
||||
InstanceStarted = 'InstanceStarted',
|
||||
OneClickAppDetailsFetched = 'OneClickAppDetailsFetched',
|
||||
OneClickAppListFetched = 'OneClickAppListFetched',
|
||||
OneClickAppDeployStarted = 'OneClickAppDeployStarted',
|
||||
}
|
||||
|
||||
export interface ICapRoverEvent {
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AnalyticsLogger extends IEventsEmitter {
|
||||
case CapRoverEventType.InstanceStarted:
|
||||
case CapRoverEventType.OneClickAppDetailsFetched:
|
||||
case CapRoverEventType.OneClickAppListFetched:
|
||||
case CapRoverEventType.OneClickAppDeployStarted:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
309
src/user/oneclick/OneClickAppDeployManager.ts
Normal file
309
src/user/oneclick/OneClickAppDeployManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
240
src/user/oneclick/OneClickAppDeploymentHelper.ts
Normal file
240
src/user/oneclick/OneClickAppDeploymentHelper.ts
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
149
src/user/oneclick/OneClickDeploymentJobRegistry.ts
Normal file
149
src/user/oneclick/OneClickDeploymentJobRegistry.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,7 @@ export default class ProManager {
|
||||
case CapRoverEventType.InstanceStarted:
|
||||
case CapRoverEventType.OneClickAppDetailsFetched:
|
||||
case CapRoverEventType.OneClickAppListFetched:
|
||||
case CapRoverEventType.OneClickAppDeployStarted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -92,6 +92,8 @@ const data = {
|
||||
|
||||
isDebug: EnvVars.CAPTAIN_IS_DEBUG,
|
||||
|
||||
serviceContainerPort3000: 3000,
|
||||
|
||||
rootNameSpace: 'captain',
|
||||
|
||||
// *********************** Disk Paths ************************
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
102
src/utils/DockerComposeToServiceOverride.ts
Normal file
102
src/utils/DockerComposeToServiceOverride.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 / {
|
||||
|
||||
448
tests/OneClickAppRouter.test.ts
Normal file
448
tests/OneClickAppRouter.test.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user