This commit is contained in:
Kasra Bigdeli
2025-09-01 21:48:30 -07:00
parent 2baddabef6
commit 61d157fdae
6 changed files with 855 additions and 0 deletions

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

@@ -7,6 +7,8 @@ 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 +271,86 @@ router.get('/template/app', function (req, res, next) {
.catch(ApiStatusCodes.createCatcher(res))
})
router.post('/deploy', function (req, res, next) {
const template = req.body.template
const values = req.body.values
const deploymentJobRegistry = OneClickDeploymentJobRegistry.getInstance()
return Promise.resolve() //
.then(function () {
if (!template) {
throw ApiStatusCodes.createError(
ApiStatusCodes.ILLEGAL_PARAMETER,
'Template is required'
)
}
const jobId = deploymentJobRegistry.createJob()
Logger.dev(`Starting one-click deployment with jobId: ${jobId}`)
Logger.dev(`Template: ${JSON.stringify(template, null, 2)}`)
Logger.dev(`Values: ${JSON.stringify(values, null, 2)}`)
new OneClickAppDeployManager((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

View File

@@ -0,0 +1,282 @@
import { IHashMapGeneric } from '../../models/ICacheGeneric'
import {
IDockerComposeService,
IOneClickTemplate,
} from '../../models/IOneClickAppModels'
import Utils from '../../utils/Utils'
import OneClickAppDeploymentHelper from './OneClickAppDeploymentHelper'
export const ONE_CLICK_APP_NAME_VAR_NAME = '$$cap_appname'
interface IDeploymentStep {
stepName: string
stepPromise: () => Promise<void>
}
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 =
new OneClickAppDeploymentHelper()
private template: IOneClickTemplate | undefined
constructor(
private onDeploymentStateChanged: (
deploymentState: IDeploymentState
) => void
) {
//
}
startDeployProcess(
template: IOneClickTemplate,
values: IHashMapGeneric<string>
) {
const self = this
let stringified = JSON.stringify(template)
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,209 @@
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'
// TODO - Replace with actual API implementation
class ApiManager {
registerNewApp(
appName: string,
projectId: string,
hasVolume: boolean,
skipIfExists: boolean
): Promise<any> {
// Mock implementation
return Promise.resolve({ success: true })
}
registerProject(projectDef: ProjectDefinition): Promise<any> {
// Mock implementation
return Promise.resolve({ id: 'mock-project-id' })
}
getAllApps(): Promise<any> {
// Mock implementation
return Promise.resolve({ appDefinitions: [] })
}
updateConfigAndSave(appName: string, appDef: IAppDef): Promise<any> {
// Mock implementation
return Promise.resolve({ success: true })
}
uploadCaptainDefinitionContent(
appName: string,
captainDefinition: ICaptainDefinition,
tarFileBase64: string,
skipIfExists: boolean
): Promise<any> {
// Mock implementation
return Promise.resolve({ success: true })
}
}
export default class OneClickAppDeploymentHelper {
private apiManager: ApiManager = new ApiManager()
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 (data) {
projectMemoryCache.projectId = data.id
})
})
}
createConfigurationPromise(
appName: string,
dockerComposeService: IDockerComposeService,
capAppName: string
) {
const self = this
return Promise.resolve().then(function () {
return self.apiManager
.getAllApps()
.then(function (data) {
const appDefs = 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,127 @@
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() {}
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)
*/
public 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
}
}

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
}
}