Files
caprover/src/docker/DockerApi.ts
T

1880 lines
64 KiB
TypeScript

import Base64Provider = require('js-base64')
import Docker = require('dockerode')
import { v4 as uuid } from 'uuid'
import {
IAppDef,
IAppEnvVar,
IAppPort,
IAppVolume,
VolumesTypes,
} from '../models/AppDefinition'
import { DockerAuthObj, DockerRegistryConfig } from '../models/DockerAuthObj'
import { DockerSecret } from '../models/DockerSecret'
import DockerService from '../models/DockerService'
import { IHashMapGeneric } from '../models/ICacheGeneric'
import {
IDockerApiPort,
IDockerContainerResource,
PreDeployFunction,
} from '../models/OtherTypes'
import { ServerDockerInfo } from '../models/ServerDockerInfo'
import BuildLog from '../user/BuildLog'
import CaptainConstants from '../utils/CaptainConstants'
import EnvVars from '../utils/EnvVars'
import Logger from '../utils/Logger'
import Utils from '../utils/Utils'
import Dockerode = require('dockerode')
// @ts-expect-error "TODO"
import dockerodeUtils = require('dockerode/lib/util')
const Base64 = Base64Provider.Base64
const CAPROVER_MANAGED_SERVICE_LABEL = 'com.caprover.managed'
function safeParseChunk(chunk: string): {
stream?: string
error?: any
errorDetail?: any
}[] {
chunk = `${chunk}`.trim()
try {
// See https://github.com/caprover/caprover/issues/570
// This appears to be bug either in Docker or dockerone:
// Sometimes chunk appears as two JSON objects, like
// ```
// {"stream":"something......"}
// {"stream":"another line of things"}
// ```
const chunks = chunk.split('\n')
const returnVal = [] as any[]
chunks.forEach((chk) => {
returnVal.push(JSON.parse(chk))
})
return returnVal
} catch (ignore) {
return [
{
stream: `Cannot parse ${chunk}`,
},
]
}
}
export abstract class IDockerUpdateOrders {
public static readonly AUTO = 'auto'
public static readonly STOP_FIRST = 'stopFirst'
public static readonly START_FIRST = 'startFirst'
}
export type IDockerUpdateOrder = 'auto' | 'stopFirst' | 'startFirst'
export function getLegacyServiceDnsAlias(
serviceName: string,
namespace: string | undefined,
isLegacyAppName: boolean
) {
if (!namespace || isLegacyAppName) {
return undefined
}
return `srv-${namespace}--${serviceName}`
}
export interface IServiceNetworkAttachment {
Target: string
Aliases?: string[]
}
export function getServiceNetworkAttachments(
networks: string[],
legacyDnsAlias: string | undefined
) {
return networks.map((network) => {
const attachment: IServiceNetworkAttachment = {
Target: network,
}
if (legacyDnsAlias) {
attachment.Aliases = [legacyDnsAlias]
}
return attachment
})
}
export interface CreateContainerParams {
containerName?: string
imageName: string
/** Override command to run the container with */
command?: string[]
/** an array, hostPath & containerPath, mode */
volumes: IAppVolume[]
network: string
/**
*
* [
* {
* key: 'somekey'
* value: 'some value'
* }
* ]
*/
arrayOfEnvKeyAndValue: IAppEnvVar[]
addedCapabilities?: string[]
addedSecOptions?: string[]
authObj?: DockerAuthObj
/** If true, have the container always restart,
* If false act as a one off job runner that cleans up after itself */
sticky: boolean
/** If set true, waits for the container to exit before returning */
wait?: boolean
}
class DockerApi {
private dockerode: Docker
public dockerNeedsUpdate = false
constructor(connectionParams: Docker.DockerOptions) {
this.dockerode = new Docker(connectionParams)
}
static get() {
return dockerApiInstance
}
ensureSwarmExists() {
const self = this
return self.dockerode
.swarmInspect() //
.then(function (data) {
return data.ID
})
}
initSwarm(ip: string, portNumber?: number) {
const self = this
portNumber = portNumber || 2377
const port = `${portNumber}`
const advertiseAddr = `${ip}:${port}`
const swarmOptions = {
ListenAddr: `0.0.0.0:${port}`,
AdvertiseAddr: advertiseAddr,
ForceNewCluster: false,
}
Logger.d(`Starting swarm at ${advertiseAddr}`)
return self.dockerode.swarmInit(swarmOptions)
}
swarmLeave(forced: boolean) {
const self = this
return self.dockerode.swarmLeave({
force: !!forced,
})
}
getNodeIdByServiceName(
serviceName: string,
retryCount: number
): Promise<string> {
const self = this
retryCount = retryCount || 0
return self.dockerode
.listTasks({
filters: {
service: [serviceName],
'desired-state': ['running'],
},
})
.then(function (data) {
if (data.length > 0) {
return Promise.resolve(data[0].NodeID)
} else {
if (retryCount < 10) {
return new Promise<void>(function (resolve) {
setTimeout(function () {
resolve()
}, 3000)
}).then(function () {
Logger.d(
`Retrying to get NodeID for ${serviceName} retry count:${retryCount}`
)
return self.getNodeIdByServiceName(
serviceName,
retryCount + 1
)
})
}
throw new Error(
`There must be only one instance (not ${data.length}) of the service running to find node id. ${serviceName}`
)
}
})
}
getLeaderNodeId() {
const self = this
return Promise.resolve()
.then(function () {
return self.dockerode.listNodes()
})
.then(function (nodes) {
for (let idx = 0; idx < nodes.length; idx++) {
const node = nodes[idx]
if (node.ManagerStatus && node.ManagerStatus.Leader) {
return node.ID
}
}
})
}
getAllServices() {
const self = this
return Promise.resolve()
.then(function () {
return self.dockerode.listServices()
})
.then(function (services) {
return (services || []) as unknown as DockerService[]
})
}
createJoinCommand(
captainIpAddress: string,
token: string,
workerIp: string
) {
return `docker swarm join --token ${token} ${captainIpAddress}:2377 --advertise-addr ${workerIp}:2377`
}
getNodesInfo() {
const self = this
return Promise.resolve()
.then(function () {
return self.dockerode.listNodes()
})
.then(function (nodes) {
const ret: ServerDockerInfo[] = []
if (!nodes || !nodes.length) {
return ret
}
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i]
ret.push({
nodeId: n.ID,
type: n.Spec.Role,
isLeader:
n.Spec.Role === 'manager' &&
n.ManagerStatus &&
n.ManagerStatus.Leader === true,
hostname: n.Description.Hostname,
architecture: n.Description.Platform.Architecture,
operatingSystem: n.Description.Platform.OS,
nanoCpu: n.Description.Resources.NanoCPUs,
memoryBytes: n.Description.Resources.MemoryBytes,
dockerEngineVersion: n.Description.Engine.EngineVersion,
ip: n.Status.Addr,
state: n.Status.State, // whether down or ready (this means the machine is down)
status: n.Spec.Availability, // whether active, drain or pause (this is the expected behavior)
})
}
return ret
})
}
getJoinToken(isManager: boolean) {
const self = this
return Promise.resolve()
.then(function () {
return self.dockerode.swarmInspect()
})
.then(function (inspectData) {
if (!inspectData || !inspectData.JoinTokens) {
throw new Error('Inspect data does not contain tokens!!')
}
const token = isManager
? inspectData.JoinTokens.Manager
: inspectData.JoinTokens.Worker
if (!token) {
throw new Error(
'Inspect data does not contain the required token!!'
)
}
return token
})
}
buildImageFromDockerFile(
imageName: string,
newVersionNumber: number,
tarballFilePath: string,
buildLogs: BuildLog,
envVars: IAppEnvVar[],
registryConfig: DockerRegistryConfig
) {
const self = this
const newVersion = `${newVersionNumber}`
Logger.d('Building docker image. This might take a few minutes...')
return Promise.resolve()
.then(function () {
const buildargs: IHashMapGeneric<string> = {}
envVars.forEach((env) => {
buildargs[env.key] = env.value
})
if (envVars.length > 0) {
buildLogs.log(
'Ignore warnings for unconsumed build-args if there is any'
)
}
const optionsForBuild: Dockerode.ImageBuildOptions = {
t: imageName,
buildargs: buildargs,
version: CaptainConstants.configs.defaultDockerBuildVersion,
}
if (Object.keys(registryConfig).length > 0) {
// https://github.com/apocas/dockerode/blob/ed6ef39e0fc81963fedf208c7e0854d8a44cb9a8/lib/docker.js#L271-L274
// https://github.com/apocas/docker-modem/blob/master/lib/modem.js#L160-L163
// "X-Registry-Config" in docker API
optionsForBuild['registryconfig'] = registryConfig
}
return self.dockerode.buildImage(
tarballFilePath,
optionsForBuild
)
})
.then(function (stream) {
return new Promise<void>(function (resolve, reject) {
let errorMessage = ''
stream.setEncoding('utf8')
// THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE.
stream.on('data', function (chunkRaw) {
Logger.dev(`stream data ${chunkRaw}`)
safeParseChunk(chunkRaw).forEach((chunk) => {
const chuckStream = chunk.stream
if (chuckStream) {
// Logger.dev('stream data ' + chuckStream);
buildLogs.log(chuckStream)
}
if (chunk.error) {
Logger.e(chunk.error)
const errorDetails = JSON.stringify(
chunk.errorDetail
)
Logger.e(errorDetails)
buildLogs.log(errorDetails)
buildLogs.log(chunk.error)
errorMessage += '\n'
errorMessage += errorDetails
errorMessage += '\n'
errorMessage += chunk.error
}
})
})
// stream.pipe(process.stdout, {end: true});
// IncomingMessage
// https://nodejs.org/api/stream.html#stream_event_end
stream.on('end', function () {
if (errorMessage) {
reject(errorMessage)
return
}
resolve()
})
stream.on('error', function (chunk) {
errorMessage += chunk
})
})
})
.then(function () {
return self.dockerode.getImage(imageName).tag({
tag: newVersion,
repo: imageName,
})
})
}
pullImage(
imageNameIncludingTag: string,
authObj: DockerAuthObj | undefined
) {
const self = this
const parsedTag = dockerodeUtils.parseRepositoryTag(
imageNameIncludingTag
)
const repository = parsedTag.repository
const tag = parsedTag.tag || 'latest'
return Promise.resolve()
.then(function () {
return self.dockerode.createImage({
fromImage: repository,
tag: tag,
authconfig: authObj,
})
})
.then(function (stream) {
return new Promise<void>(function (resolve, reject) {
let errorMessage = ''
const logsBeforeError: string[] = []
for (let i = 0; i < 20; i++) {
logsBeforeError.push('')
}
stream.setEncoding('utf8')
// THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE.
stream.on('data', function (chunkRaw) {
Logger.dev(`stream data ${chunkRaw}`)
safeParseChunk(chunkRaw).forEach((chunk) => {
const chuckStream = chunk.stream
if (chuckStream) {
// Logger.dev('stream data ' + chuckStream);
logsBeforeError.shift()
logsBeforeError.push(chuckStream)
}
if (chunk.error) {
Logger.e(chunk.error)
Logger.e(JSON.stringify(chunk.errorDetail))
errorMessage += '\n [truncated] \n'
errorMessage += logsBeforeError.join('')
errorMessage += '\n'
errorMessage += chunk.error
}
})
})
// stream.pipe(process.stdout, {end: true});
// IncomingMessage
// https://nodejs.org/api/stream.html#stream_event_end
stream.on('end', function () {
if (errorMessage) {
reject(errorMessage)
return
}
resolve()
})
stream.on('error', function (chunk) {
errorMessage += chunk
})
})
})
}
/**
* This method container a lot of hacks to workaround some Docker issues.
* See https://github.com/githubsaturn/captainduckduck/issues/176
*
* @param nameOrId
* @param networkIdOrName
* @returns {Promise<void>}
*/
ensureContainerStoppedAndRemoved(
nameOrId: string,
networkIdOrName: string
) {
const self = this
Logger.d(`Ensuring Stopped & Removed Container: ${nameOrId}`)
return Promise.resolve()
.then(function () {
Logger.d(`Stopping ${nameOrId}`)
return self.dockerode.getContainer(nameOrId).stop({
t: 2,
})
})
.then(function () {
Logger.d(`Waiting to stop ${nameOrId}`)
return Promise.race([
self.dockerode.getContainer(nameOrId).wait(),
new Promise<void>(function (resolve, reject) {
setTimeout(function () {
resolve()
}, 7000)
}),
])
})
.catch(function (error) {
if (error && error.statusCode === 304) {
Logger.w(`Container already stopped: ${nameOrId}`)
return false
}
throw error
})
.then(function () {
Logger.d(`Removing ${nameOrId}`)
return self.dockerode.getContainer(nameOrId).remove({
force: true,
})
})
.then(function () {
return self.pruneContainers()
})
.then(function () {
Logger.d(`Disconnecting from network: ${nameOrId}`)
return self.dockerode.getNetwork(networkIdOrName).disconnect({
Force: true,
Container: nameOrId,
})
})
.catch(function (error) {
if (error && error.statusCode === 404) {
Logger.w(`Container not found: ${nameOrId}`)
return false
}
throw error
})
}
/**
* Creates a container that restarts unless stopped
* @param containerName
* @param imageName
* @param volumes an array, hostPath & containerPath, mode
* @param arrayOfEnvKeyAndValue:
* [
* {
* key: 'somekey'
* value: 'some value'
* }
* ]
* @param network
* @param addedCapabilities
* @returns {Promise.<>}
*/
createStickyContainer(
containerName: string,
imageName: string,
volumes: IAppVolume[],
network: string,
arrayOfEnvKeyAndValue: IAppEnvVar[],
addedCapabilities: string[],
addedSecOptions: string[],
authObj: DockerAuthObj | undefined
) {
return this.createContainer({
containerName,
imageName,
volumes,
network,
arrayOfEnvKeyAndValue,
addedCapabilities,
addedSecOptions,
authObj,
sticky: true,
})
}
/**
* Creates a docker container
*/
createContainer({
containerName,
imageName,
command,
volumes,
network,
arrayOfEnvKeyAndValue,
addedCapabilities,
addedSecOptions,
authObj,
sticky,
wait = false,
}: CreateContainerParams) {
const self = this
Logger.d(
`Creating ${sticky ? 'Sticky' : 'Ephemeral'} Container: ${imageName}`
)
const volumesMapped: string[] = []
volumes = volumes || []
for (let i = 0; i < volumes.length; i++) {
const v = volumes[i]
volumesMapped.push(
`${v.hostPath}:${v.containerPath}${v.mode ? `:${v.mode}` : ''}`
)
}
const envs: string[] = []
arrayOfEnvKeyAndValue = arrayOfEnvKeyAndValue || []
for (let i = 0; i < arrayOfEnvKeyAndValue.length; i++) {
const e = arrayOfEnvKeyAndValue[i]
envs.push(`${e.key}=${e.value}`)
}
let container: Docker.Container | undefined = undefined
return Promise.resolve()
.then(function () {
return self.pullImage(imageName, authObj)
})
.then(function () {
return self.dockerode.createContainer({
name: containerName,
Image: imageName,
Env: envs,
Cmd: command,
HostConfig: {
Binds: volumesMapped,
CapAdd: addedCapabilities,
SecurityOpt: addedSecOptions,
NetworkMode: network,
LogConfig: {
Type: 'json-file',
Config: {
'max-size':
CaptainConstants.configs.defaultMaxLogSize,
},
},
...(sticky
? {
RestartPolicy: {
Name: 'always',
},
}
: {}),
AutoRemove: !CaptainConstants.isDebug && !sticky,
},
})
})
.then(function (data) {
container = data
return data.start()
})
.then(function (startInfo) {
if (wait && container) {
return container.wait()
}
return startInfo
})
}
retag(currentName: string, targetName: string) {
const self = this
return Promise.resolve().then(function () {
const currentSplit = currentName.split(':')
const targetSplit = targetName.split(':')
if (targetSplit.length < 2 || targetSplit.length < 2) {
throw new Error(
'This method only support image tags with version'
)
}
if (currentSplit[currentSplit.length - 1].indexOf('/') > 0) {
throw new Error(
'This method only support image tags with version - current image.'
)
}
const targetVersion = targetSplit[targetSplit.length - 1]
if (targetVersion.indexOf('/') > 0) {
throw new Error(
'This method only support image tags with version - target image.'
)
}
return self.dockerode.getImage(currentName).tag({
tag: targetVersion,
repo: targetSplit.slice(0, targetSplit.length - 1).join(':'),
})
})
}
pushImage(imageName: string, authObj: DockerAuthObj, buildLogs: BuildLog) {
const self = this
buildLogs.log(`Pushing to remote: ${imageName}`)
buildLogs.log(`Server: ${authObj ? authObj.serveraddress : 'N/A'}`)
buildLogs.log('This might take a few minutes...')
return Promise.resolve()
.then(function () {
return self.dockerode.getImage(imageName).push({
authconfig: authObj,
})
})
.then(function (stream) {
return new Promise<void>(function (resolve, reject) {
let errorMessage = ''
stream.setEncoding('utf8')
// THIS BLOCK HAS TO BE HERE. "end" EVENT WON'T GET CALLED OTHERWISE.
stream.on('data', function (chunkRaw) {
Logger.dev(`stream data ${chunkRaw}`)
safeParseChunk(chunkRaw).forEach((chunk) => {
const chuckStream = chunk.stream
if (chuckStream) {
// Logger.dev('stream data ' + chuckStream);
buildLogs.log(chuckStream)
}
if (chunk.error) {
Logger.e(chunk.error)
const errorDetails = JSON.stringify(
chunk.errorDetail
)
Logger.e(errorDetails)
buildLogs.log(errorDetails)
buildLogs.log(chunk.error)
errorMessage += '\n'
errorMessage += errorDetails
errorMessage += chunk.error
}
})
})
// stream.pipe(process.stdout, {end: true});
// IncomingMessage
// https://nodejs.org/api/stream.html#stream_event_end
stream.on('end', function () {
if (errorMessage) {
buildLogs.log('Push failed...')
reject(errorMessage)
return
}
buildLogs.log('Push succeeded...')
resolve()
})
stream.on('error', function (chunk) {
errorMessage += chunk
})
})
})
}
/**
* Creates a new service
*
* @param imageName REQUIRED
* @param serviceName REQUIRED
* @param portsToMap an array, containerPort & hostPort
* @param nodeId node ID on which we lock down the service
* @param volumeToMount an array, hostPath & containerPath
* @param arrayOfEnvKeyAndValue:
* [
* {
* key: 'somekey'
* value: 'some value'
* }
* ]
* @param resourcesObject:
* [
* {
* Limits: { NanoCPUs , MemoryBytes}
* Reservation: { NanoCPUs , MemoryBytes}
*
* ]
*/
createServiceOnNodeId(
imageName: string,
serviceName: string,
portsToMap: IAppPort[] | undefined,
nodeId: string | undefined,
volumeToMount: IAppVolume[] | undefined,
arrayOfEnvKeyAndValue: IAppEnvVar[] | undefined,
resourcesObject?: IDockerContainerResource
) {
const self = this
const ports: IDockerApiPort[] = []
if (portsToMap) {
for (let i = 0; i < portsToMap.length; i++) {
const publishMode = portsToMap[i].publishMode
const protocol = portsToMap[i].protocol
const containerPort = portsToMap[i].containerPort
const hostPort = portsToMap[i].hostPort
if (protocol) {
const item: IDockerApiPort = {
Protocol: protocol,
TargetPort: containerPort,
PublishedPort: hostPort,
}
if (publishMode) {
item.PublishMode = publishMode
}
ports.push(item)
} else {
const tcpItem: IDockerApiPort = {
Protocol: 'tcp',
TargetPort: containerPort,
PublishedPort: hostPort,
}
const udpItem: IDockerApiPort = {
Protocol: 'udp',
TargetPort: containerPort,
PublishedPort: hostPort,
}
if (publishMode) {
tcpItem.PublishMode = publishMode
udpItem.PublishMode = publishMode
}
ports.push(tcpItem)
ports.push(udpItem)
}
}
}
const dataToCreate: any = {
name: serviceName,
Labels: {
[CAPROVER_MANAGED_SERVICE_LABEL]: 'true',
},
TaskTemplate: {
ContainerSpec: {
Image: imageName,
},
Resources: resourcesObject,
Placement: {
Constraints: nodeId ? [`node.id == ${nodeId}`] : [],
},
LogDriver: {
Name: 'json-file',
Options: {
'max-size': CaptainConstants.configs.defaultMaxLogSize,
},
},
},
EndpointSpec: {
Ports: ports,
},
}
if (volumeToMount) {
const mts = []
for (let idx = 0; idx < volumeToMount.length; idx++) {
const v = volumeToMount[idx]
if (!v.containerPath) {
throw new Error(
'Service Create currently only supports bind volumes.'
)
}
mts.push({
Source: v.hostPath,
Target: v.containerPath,
Type: VolumesTypes.BIND,
ReadOnly: false,
Consistency: 'default',
})
}
dataToCreate.TaskTemplate.ContainerSpec.Mounts = mts
}
if (arrayOfEnvKeyAndValue) {
dataToCreate.TaskTemplate.ContainerSpec.Env = []
for (let i = 0; i < arrayOfEnvKeyAndValue.length; i++) {
const keyVal = arrayOfEnvKeyAndValue[i]
const newSet = `${keyVal.key}=${keyVal.value}`
dataToCreate.TaskTemplate.ContainerSpec.Env.push(newSet)
}
}
return self.dockerode.createService(dataToCreate)
}
removeServiceByName(serviceName: string) {
const self = this
return self.dockerode.getService(serviceName).remove()
}
deleteVols(vols: string[]) {
const self = this
const promises: (() => Promise<void>)[] = []
const failedVols: string[] = []
vols.forEach((v) => {
promises.push(function () {
return self.dockerode
.getVolume(v) //
.remove() // { force: true }
.catch((err) => {
Logger.d(err)
failedVols.push(v)
})
})
})
return Utils.runPromises(promises) //
.then(function () {
return failedVols
})
}
isServiceRunningByName(serviceName: string) {
return this.dockerode
.getService(serviceName)
.inspect()
.then(function () {
return true
})
.catch(function (error) {
if (error && error.statusCode === 404) {
return false
}
throw error
})
}
getContainerIdByServiceName(
serviceName: string,
retryCountMaybe?: number
): Promise<string> {
const self = this
const retryCount: number = retryCountMaybe || 0
return self.dockerode
.listTasks({
filters: {
service: [serviceName],
'desired-state': ['running'],
},
})
.then(function (data) {
if (data.length >= 2) {
throw new Error(
`There must be only one instance (not ${data.length}) of the service running for sendSingleContainerKillHUP. ${serviceName}`
)
}
if (data.length === 1 && !!data[0].Status.ContainerStatus) {
return Promise.resolve(
data[0].Status.ContainerStatus.ContainerID
)
}
if (retryCount < 10) {
return new Promise<void>(function (resolve) {
setTimeout(function () {
resolve()
}, 3000)
}).then(function () {
Logger.d(
`Retrying to get containerId for ${serviceName} retry count:${retryCount}`
)
return self.getContainerIdByServiceName(
serviceName,
retryCount + 1
)
})
}
throw new Error('No containerId is found')
})
}
executeCommand(serviceName: string, cmd: string[]) {
const self = this
return self
.getContainerIdByServiceName(serviceName)
.then(function (containerIdFound) {
const cmdForLogging = (cmd || []).join(' ')
Logger.d(
`executeCommand Container: ${serviceName} ${cmdForLogging} `
)
if (!Array.isArray(cmd)) {
throw new Error(
'Command should be an array. e.g, ["echo", "--help"] '
)
}
return self.dockerode
.getContainer(containerIdFound)
.exec({
AttachStdin: false,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: cmd,
})
.then(function (execInstance) {
return execInstance.start({
Detach: false,
Tty: true,
})
})
.then(function (execStream) {
if (!execStream) {
throw new Error(
`No output from service: ${serviceName} running ${cmd}`
)
}
return new Promise<string>(function (resolve) {
let finished = false
let outputBody = ''
// output is a readable stream
// https://nodejs.org/api/stream.html#stream_event_end
execStream.setEncoding('utf8')
execStream.on('data', function (chunk: string) {
outputBody += chunk
})
execStream.on('end', function () {
if (finished) {
return
}
finished = true
resolve(outputBody)
})
execStream.on('close', function () {
if (finished) {
return
}
finished = true
resolve(outputBody)
})
})
})
})
}
sendSingleContainerKillHUP(serviceName: string) {
const self = this
return self
.getContainerIdByServiceName(serviceName)
.then(function (containerIdFound) {
Logger.d(`Kill HUP Container: ${containerIdFound}`)
return self.dockerode.getContainer(containerIdFound).kill({
signal: 'HUP',
})
})
}
/**
* Adds secret to service if it does not already have it.
* @param serviceName
* @param secretName
* @returns {Promise.<>} FALSE if the secret is JUST added, TRUE if secret existed before
*/
ensureSecretOnService(serviceName: string, secretName: string) {
const self = this
let secretToExpose: Docker.Secret
return self.dockerode
.listSecrets({
name: secretName,
})
.then(function (secrets) {
// the filter returns all secrets whose name includes the provided secretKey. e.g., if you ask for
// captain-me, it also returns captain-me1 and etc if exist
for (let i = 0; i < secrets.length; i++) {
const specs = secrets[i].Spec
if (specs && specs.Name === secretName) {
secretToExpose = secrets[i]
break
}
}
if (!secretToExpose) {
throw new Error(`Cannot find secret: ${secretName}`)
}
return self.checkIfServiceHasSecret(
serviceName,
secretToExpose.ID
)
})
.then(function (hasSecret) {
if (hasSecret) {
Logger.d(
`${serviceName} (service) has already been connected to secret: ${secretName}`
)
return true
}
Logger.d(
`Adding ${secretToExpose.ID} Name:${secretName} to service: ${serviceName}`
)
// we only want to update the service is it doesn't have the secret. Otherwise, it keeps restarting!
return self
.updateService(
serviceName,
undefined,
undefined,
undefined,
undefined,
[
{
secretName: secretName,
secretId: secretToExpose.ID,
},
],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
)
.then(function () {
return false
})
})
}
checkIfServiceHasSecret(serviceName: string, secretId: string) {
const self = this
return self.dockerode
.getService(serviceName)
.inspect()
.then(function (data) {
const secrets = data.Spec.TaskTemplate.ContainerSpec.Secrets
if (secrets) {
for (let i = 0; i < secrets.length; i++) {
if (secrets[i].SecretID === secretId) {
return true
}
}
}
return false
})
}
ensureSecret(secretKey: string, valueIfNotExist: string) {
const self = this
return this.checkIfSecretExist(secretKey).then(function (secretExists) {
if (secretExists) {
return
} else {
return self.dockerode
.createSecret({
Name: secretKey,
Labels: {},
Data: Base64.encode(valueIfNotExist),
})
.then(function () {
return
})
}
})
}
checkIfSecretExist(secretKey: string) {
const self = this
return self.dockerode
.listSecrets({
name: secretKey,
})
.then(function (secrets) {
// the filter returns all secrets whose name includes the provided secretKey. e.g., if you ask for
// captain-me, it also returns captain-me1 and etc if exist
let secretExists = false
for (let i = 0; i < secrets.length; i++) {
const spec = secrets[i].Spec
if (spec && spec.Name === secretKey) {
secretExists = true
break
}
}
return secretExists
})
}
ensureServiceConnectedToNetwork(serviceName: string, networkName: string) {
const self = this
let networkId: string
return self.dockerode
.getNetwork(networkName)
.inspect()
.then(function (data) {
networkId = data.Id
return self.dockerode.getService(serviceName).inspect()
})
.then(function (serviceData) {
let availableNetworks = serviceData.Spec.TaskTemplate.Networks
const allNetworks = []
availableNetworks = availableNetworks || []
for (let i = 0; i < availableNetworks.length; i++) {
allNetworks.push(availableNetworks[i].Target)
if (availableNetworks[i].Target === networkId) {
Logger.d(
`Network ${networkName} is already attached to service: ${serviceName}`
)
return
}
}
allNetworks.push(networkId)
Logger.d(
`Attaching network ${networkName} to service: ${serviceName}`
)
return self.updateService(
serviceName,
undefined,
undefined,
allNetworks,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
)
})
}
ensureOverlayNetwork(networkName: string, networkOverride: any) {
const self = this
return self.dockerode
.getNetwork(networkName)
.inspect()
.then(function (data) {
// Network exists!
return true
})
.catch(function (error) {
if (error && error.statusCode === 404) {
return self.dockerode.createNetwork(
Utils.mergeObjects(
{
Name: networkName,
CheckDuplicate: true,
Driver: 'overlay',
Attachable: true,
},
networkOverride
)
)
}
return new Promise<any>(function (resolve, reject) {
reject(error)
})
})
}
/**
* @param serviceName
* @param imageName
* @param volumes
* [
* {
* containerPath: 'some value' [REQUIRED]
* hostPath: 'somekey' [REQUIRED for bind type]
* volumeName: 'my-volume-name' [REQUIRED for type:volume]
* type: <defaults to bind>, can be volume or tmpfs (not supported yet through captain)
* }
* ]
* @param networks
* @param arrayOfEnvKeyAndValue:
* [
* {
* key: 'somekey'
* value: 'some value'
* }
* ]
* @param secrets:
* [
* {
* secretName: 'somekey'
* secretId: 'some value'
* }
* ]
* @param authObject:
* [
* {
* username: 'someuser
* password: 'password'
* serveraddress: 'registry.captain.com:996'
* }
* ]
* @param instanceCount: String '12' or null
* @param nodeId: nodeId of the node this service will be locked to or null
* @param namespace: String 'captain' or null
* @returns {Promise.<>}
*/
updateService(
serviceName: string,
imageName: string | undefined,
volumes: IAppVolume[] | undefined,
networks: string[] | undefined,
arrayOfEnvKeyAndValue: IAppEnvVar[] | undefined,
secrets: DockerSecret[] | undefined,
authObject: DockerAuthObj | undefined,
instanceCount: number | undefined,
nodeId: string | undefined,
namespace: string | undefined,
ports: IAppPort[] | undefined,
appObject: IAppDef | undefined,
updateOrder: IDockerUpdateOrder | undefined,
serviceUpdateOverride: any | undefined,
preDeployFunction: PreDeployFunction | undefined
) {
const self = this
return self.dockerode
.getService(serviceName)
.inspect()
.then(function (readData) {
const data = JSON.parse(JSON.stringify(readData))
const updatedData = data.Spec
updatedData.version = parseInt(data.Version.Index)
if (imageName) {
updatedData.TaskTemplate.ContainerSpec.Image = imageName
}
if (nodeId) {
updatedData.TaskTemplate.Placement =
updatedData.TaskTemplate.Placement || {}
updatedData.TaskTemplate.Placement.Constraints =
updatedData.TaskTemplate.Placement.Constraints || []
const newConstraints = []
for (
let i = 0;
i <
updatedData.TaskTemplate.Placement.Constraints.length;
i++
) {
const c =
updatedData.TaskTemplate.Placement.Constraints[i]
if (c.indexOf('node.id') < 0) {
newConstraints.push(c)
}
}
newConstraints.push(`node.id == ${nodeId}`)
updatedData.TaskTemplate.Placement.Constraints =
newConstraints
}
if (arrayOfEnvKeyAndValue) {
updatedData.TaskTemplate.ContainerSpec.Env = []
for (let i = 0; i < arrayOfEnvKeyAndValue.length; i++) {
const keyVal = arrayOfEnvKeyAndValue[i]
const newSet = `${keyVal.key}=${keyVal.value}`
updatedData.TaskTemplate.ContainerSpec.Env.push(newSet)
}
}
if (ports) {
updatedData.EndpointSpec = updatedData.EndpointSpec || {}
updatedData.EndpointSpec.Ports = []
for (let i = 0; i < ports.length; i++) {
const p = ports[i]
if (p.protocol) {
updatedData.EndpointSpec.Ports.push({
Protocol: p.protocol,
TargetPort: p.containerPort,
PublishedPort: p.hostPort,
})
} else {
updatedData.EndpointSpec.Ports.push({
Protocol: 'tcp',
TargetPort: p.containerPort,
PublishedPort: p.hostPort,
})
updatedData.EndpointSpec.Ports.push({
Protocol: 'udp',
TargetPort: p.containerPort,
PublishedPort: p.hostPort,
})
}
}
}
if (volumes) {
const mts = []
for (let idx = 0; idx < volumes.length; idx++) {
const v = volumes[idx]
if (v.hostPath) {
mts.push({
Source: v.hostPath,
Target: v.containerPath,
Type: VolumesTypes.BIND,
ReadOnly: false,
Consistency: 'default',
})
} else if (v.volumeName) {
// named volumes are created here:
// /var/lib/docker/volumes/YOUR_VOLUME_NAME/_data
mts.push({
Source:
(namespace && appObject?.isLegacyAppName
? namespace + '--'
: '') + v.volumeName,
Target: v.containerPath,
Type: VolumesTypes.VOLUME,
ReadOnly: false,
})
} else {
throw new Error('Unknown volume type!!')
}
}
updatedData.TaskTemplate.ContainerSpec.Mounts = mts
}
if (networks) {
if (appObject) {
const legacyDnsAlias = getLegacyServiceDnsAlias(
serviceName,
namespace,
!!appObject.isLegacyAppName
)
updatedData.TaskTemplate.Networks =
getServiceNetworkAttachments(
networks,
legacyDnsAlias
)
} else {
updatedData.TaskTemplate.Networks = []
for (let i = 0; i < networks.length; i++) {
updatedData.TaskTemplate.Networks.push({
Target: networks[i],
})
}
}
}
if (secrets) {
updatedData.TaskTemplate.ContainerSpec.Secrets =
updatedData.TaskTemplate.ContainerSpec.Secrets || []
for (let i = 0; i < secrets.length; i++) {
const obj = secrets[i]
let foundIndexSecret = -1
for (
let idx = 0;
idx <
updatedData.TaskTemplate.ContainerSpec.Secrets
.length;
idx++
) {
if (
updatedData.TaskTemplate.ContainerSpec.Secrets[
idx
].secretId === obj.secretId
) {
foundIndexSecret = idx
}
}
const objToAdd = {
File: {
Name: obj.secretName,
UID: '0',
GID: '0',
Mode: 292, // TODO << what is this! I just added a secret and this is how it came out with... But I don't know what it means
},
SecretID: obj.secretId,
SecretName: obj.secretName,
}
if (foundIndexSecret >= 0) {
updatedData.TaskTemplate.ContainerSpec.Secrets[
foundIndexSecret
] = objToAdd
} else {
updatedData.TaskTemplate.ContainerSpec.Secrets.push(
objToAdd
)
}
}
}
if (updateOrder) {
updatedData.UpdateConfig = updatedData.UpdateConfig || {}
switch (updateOrder) {
case IDockerUpdateOrders.AUTO:
updatedData.UpdateConfig.Order =
(
updatedData.TaskTemplate.ContainerSpec
.Mounts || []
).length > 0
? 'stop-first'
: 'start-first'
break
case IDockerUpdateOrders.START_FIRST:
updatedData.UpdateConfig.Order = 'start-first'
break
case IDockerUpdateOrders.STOP_FIRST:
updatedData.UpdateConfig.Order = 'stop-first'
break
default:
// eslint-disable-next-line
const neverHappens: never = updateOrder
throw new Error(
`Unknown update order! ${updateOrder}${neverHappens}`
)
}
}
// docker seems to be trying to smart and update if necessary!
// but sometimes, it fails to update! no so smart, eh?
// Using this random flag, we'll make it to update!
// The main reason for this is NGINX. For some reason, when it sets the volume, it caches the initial
// data from the volume and the container does not pick up changes in the host mounted volume.
// All it takes is a restart of the container to start picking up changes. Note that it only requires
// to restart once. Once rebooted, all changes start showing up.
updatedData.TaskTemplate.ContainerSpec.Labels =
updatedData.TaskTemplate.ContainerSpec.Labels || {}
updatedData.TaskTemplate.ContainerSpec.Labels.randomLabelForceUpdate =
uuid()
updatedData.authconfig = authObject
// TODO
// This stupid hack is necessary. Otherwise, the following scenario will fail
// Service is deployed (or updated) with an image from a private registry
// Then the service is updated with a public image like nginx or something.
// Without this hack, the update fails!!!
// To replicate the bug:
// - Remove this
// - Create a manager and a worker
// - Create a app and have it locked to the worker node
// - Deploy a few sample apps, it works fine.
// - Then try to deploy a simple imageName such as "nginx:1". This will fail!
// - Even with "docker service update srv-captain--name --image nginx:1 --force" it will still fail
// - The only way that you can make it work is by passing --wit-registry-auth flag to CLI.
// I did some reverse engineering to see what happens under the hood, and it appears that docker uses empty user/pass
// So I did that below, and things started working!
// See https://github.com/docker/cli/blob/b9f150b17eea7ea8f92e3a961f666bc599bb4fdf/cli/command/service/update.go#L209
// and
// https://github.com/docker/cli/blob/0c444c521ff43f4341fcb2e673f93e364e8f2fcf/cli/command/registry.go#L176
// and
// https://github.com/docker/cli/blob/0c444c521ff43f4341fcb2e673f93e364e8f2fcf/cli/command/registry.go#L61
if (!updatedData.authconfig) {
updatedData.authconfig = {
username: '',
password: '',
serveraddress: 'docker.io/v1',
}
}
// updatedData.registryAuthFrom = 'previous-spec'
instanceCount = Number(instanceCount)
if (
(instanceCount && instanceCount > 0) ||
instanceCount === 0
) {
// assign instance count as replicas for replicated mode services, not for global mode services
if (updatedData.Mode.Replicated) {
updatedData.Mode.Replicated.Replicas = instanceCount
}
}
return Utils.mergeObjects(updatedData, serviceUpdateOverride)
})
.then(function (updatedData) {
if (preDeployFunction) {
Logger.d('Running preDeployFunction')
return preDeployFunction(appObject, updatedData)
}
return updatedData
})
.then(function (updatedData) {
updatedData.Labels = updatedData.Labels || {}
updatedData.Labels[CAPROVER_MANAGED_SERVICE_LABEL] = 'true'
return self.dockerode
.getService(serviceName)
.update(updatedData)
})
.then(function (serviceData) {
// give some time such that the new container is updated.
// also we don't want to fail the update just because prune failed.
setTimeout(function () {
self.pruneContainers()
}, 5000)
return serviceData
})
}
pruneContainers() {
Logger.d('Pruning containers...')
const self = this
return self.dockerode
.pruneContainers() //
.catch(function (error) {
// Error: (HTTP code 409) unexpected - a prune operation is already running
if (error && error.statusCode === 409) {
Logger.d('Skipping prune due to a minor error: ' + error)
return
}
Logger.d('Prune Containers Failed!')
Logger.e(error)
})
}
isNodeManager(nodeId: string) {
const self = this
return self.dockerode
.getNode(nodeId)
.inspect()
.then(function (data) {
return data.Spec.Role === 'manager'
})
}
getLogForService(serviceName: string, tailCount: number, encoding: string) {
const self = this
return Promise.resolve() //
.then(function () {
return self.dockerode
.getService(serviceName) //
.logs({
tail: tailCount,
follow: false,
timestamps:
!!CaptainConstants.configs
.enableDockerLogsTimestamp,
stdout: true,
stderr: true,
})
})
.then(function (data) {
if (Buffer.isBuffer(data)) {
return data.toString(encoding as any)
}
throw new Error(
'Logs are not instance of Buffer! Cannot be parsed!!'
)
})
}
getDockerVersion() {
const self = this
return Promise.resolve().then(function () {
return self.dockerode.version()
})
}
checkRegistryAuth(authObj: DockerAuthObj) {
const self = this
return Promise.resolve().then(function () {
return self.dockerode.checkAuth(authObj)
})
}
getDockerInfo() {
const self = this
return Promise.resolve().then(function () {
return self.dockerode.info()
})
}
deleteImages(imageIds: string[]) {
const self = this
return Promise.resolve().then(function () {
let promises = Promise.resolve()
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i]
const p = function () {
return self.dockerode
.getImage(imageId)
.remove()
.then(function () {
Logger.d(`Image Deleted: ${imageId}`)
})
.catch(function (err) {
Logger.e(err)
})
}
promises = promises.then(p)
}
return promises
})
}
getImages() {
const self = this
return Promise.resolve().then(function () {
return self.dockerode.listImages()
})
}
getNodeLables(nodeId: string) {
const self = this
return self.dockerode
.getNode(nodeId)
.inspect()
.then(function (data) {
return data.Spec.Labels
})
}
updateNodeLabels(
nodeId: string,
labels: IHashMapGeneric<string>,
nodeName: string
) {
const self = this
return self.dockerode
.getNode(nodeId)
.inspect()
.then(function (data) {
const currentLabels = data.Spec.Labels || {}
Object.keys(labels).forEach(function (key) {
currentLabels[key] = labels[key]
})
return self.dockerode.getNode(nodeId).update({
version: parseInt(data.Version.Index),
Name: nodeName,
Labels: currentLabels,
Role: data.Spec.Role,
Availability: data.Spec.Availability,
})
})
.then(function () {
return true
})
}
}
const dockerApiAddressSplited = (EnvVars.CAPTAIN_DOCKER_API || '').split(':')
const connectionParams: Docker.DockerOptions =
dockerApiAddressSplited.length < 2
? {
socketPath: CaptainConstants.dockerSocketPath,
}
: dockerApiAddressSplited.length === 2
? {
host: dockerApiAddressSplited[0],
port: Number(dockerApiAddressSplited[1]),
}
: {
host: `${dockerApiAddressSplited[0]}:${dockerApiAddressSplited[1]}`,
port: Number(dockerApiAddressSplited[2]),
}
connectionParams.version = CaptainConstants.configs.dockerApiVersion
const dockerApiInstance = new DockerApi(connectionParams)
const lowVersionDocker = JSON.parse(JSON.stringify(connectionParams))
lowVersionDocker.version = 'v1.44'
new Docker(lowVersionDocker)
.version()
.then((data) => {
Logger.d('Docker API Version on host: ' + data.ApiVersion)
const majorSupported = Number(data.ApiVersion.split('.')[0])
const minorSupported = Number(data.ApiVersion.split('.')[1])
const majorNeeded = Number(
CaptainConstants.configs.dockerApiVersion
.replace('v', '')
.split('.')[0]
)
const minorNeeded = Number(
CaptainConstants.configs.dockerApiVersion
.replace('v', '')
.split('.')[1]
)
if (
majorSupported < majorNeeded ||
(majorSupported === majorNeeded && minorSupported < minorNeeded)
) {
dockerApiInstance.dockerNeedsUpdate = true
}
})
.catch((error) => {
Logger.e('Docker API Version Error: ' + error)
})
export default DockerApi