@forge/cli-shared
8.15.18.15.1-experimental-8b78d46
out/tunnel/docker-compose-lifecycle.js~
out/tunnel/docker-compose-lifecycle.jsModified+211−23
Index: package/out/tunnel/docker-compose-lifecycle.js
===================================================================
--- package/out/tunnel/docker-compose-lifecycle.js
+++ package/out/tunnel/docker-compose-lifecycle.js
@@ -1,11 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
-exports.processDockerAuthentication = exports.stopDockerComposeStack = exports.startDockerComposeStack = exports.deleteDockerComposeFile = exports.generateContainersDockerComposeFile = exports.DockerAuthenticationError = exports.CannotUseBothImageAndBuildContextError = exports.MissingImageOrBuildContextError = exports.DockerUnableToStartError = exports.DockerUnableToPullProxySidecarImage = exports.InvalidContainerServicePort = exports.K8S_AUTH_TOKEN_FILENAME = exports.CONTAINER_SERVICE_ASSETS = exports.PROXY_SIDECAR_VOLUME_DIR = void 0;
+exports.processDockerAuthentication = exports.stopDockerComposeStack = exports.determineComposeFlags = exports.startDockerComposeStack = exports.deleteDockerComposeFile = exports.generateContainersDockerComposeFile = exports.DockerAuthenticationError = exports.CannotUseBothImageAndBuildContextError = exports.MissingImageOrBuildContextError = exports.UnableToParseDockerComposeFileError = exports.DockerUnableToStartError = exports.DockerUnableToPullProxySidecarImage = exports.InvalidContainerServicePort = exports.STARTUP_TIMER_BUFFER_MS = exports.STARTUP_TIMER_MS = exports.K8S_AUTH_TOKEN_FILENAME = exports.CONTAINER_SERVICE_ASSETS = exports.PROXY_SIDECAR_VOLUME_DIR = void 0;
const tslib_1 = require("tslib");
const fs = tslib_1.__importStar(require("fs"));
const path = tslib_1.__importStar(require("path"));
const yaml = tslib_1.__importStar(require("yaml"));
+const http = tslib_1.__importStar(require("http"));
const docker_compose_1 = require("docker-compose");
const shared_1 = require("../shared");
const text_1 = require("../ui/text");
const tunnel_options_1 = require("./tunnel-options");
@@ -13,8 +14,16 @@
exports.PROXY_SIDECAR_VOLUME_DIR = '/forge/container';
exports.CONTAINER_SERVICE_ASSETS = '.container-service-assets';
exports.K8S_AUTH_TOKEN_FILENAME = 'local-account';
const PROXY_SIDECAR_SERVICE_NAME = 'proxy-sidecar';
+const LIFECYCLE_PATTERNS = [
+ /Container .* (Starting|Started|Stopping|Stopped|Creating|Created|Recreated)/,
+ /Network .* (Creating|Created|Removing|Removed)/,
+ /Volume .* (Creating|Created|Removing|Removed)/,
+ /\[[\+\-]\] Running/
+];
+exports.STARTUP_TIMER_MS = 30 * 1000;
+exports.STARTUP_TIMER_BUFFER_MS = 5 * 1000;
class InvalidContainerServicePort extends shared_1.UserError {
constructor(serviceKey) {
super(text_1.Text.error.invalidServicePort(serviceKey));
}
@@ -31,8 +40,14 @@
super(text_1.Text.tunnel.unableToStartDockerComposeStack(err?.message ?? 'Unknown Error Occurred.'));
}
}
exports.DockerUnableToStartError = DockerUnableToStartError;
+class UnableToParseDockerComposeFileError extends shared_1.UserError {
+ constructor(serviceKey, err) {
+ super(text_1.Text.tunnel.UnableToParseDockerComposeFileError(serviceKey, err?.message ?? 'Unknown Error Occurred.'));
+ }
+}
+exports.UnableToParseDockerComposeFileError = UnableToParseDockerComposeFileError;
class MissingImageOrBuildContextError extends shared_1.UserError {
constructor(containerKey) {
super(text_1.Text.tunnel.missingImageOrBuildContext(containerKey));
}
@@ -51,9 +66,9 @@
}
exports.DockerAuthenticationError = DockerAuthenticationError;
const generateContainersDockerComposeFile = async (services, appId, envId) => {
const filesGenerated = {};
- for (const service of services) {
+ for (const [serviceIndex, service] of services.entries()) {
const { key: serviceKey, containers } = service;
const containersWithTunnelConfig = containers.filter((container) => !!container.tunnel);
if (containersWithTunnelConfig.length > 0) {
const port = await (0, tunnel_options_1.getServicePort)(services, serviceKey);
@@ -71,25 +86,31 @@
container_name: container.key,
...container.tunnel?.docker,
depends_on: [PROXY_SIDECAR_SERVICE_NAME]
};
- if (config.build?.context && !path.isAbsolute(config.build.context)) {
- let context = config.build.context;
- if (context.startsWith('./')) {
- context = context.slice(2);
- }
- config.build.context = '../' + context;
+ if (config.build?.context) {
+ config.build.context = adjustFilePath(config.build.context);
}
+ if (config.volumes) {
+ config.volumes = config.volumes.map((volume) => adjustFilePath(volume));
+ }
+ if (config.develop?.watch) {
+ config.develop.watch = config.develop.watch.map((watchConfig) => ({
+ ...watchConfig,
+ path: adjustFilePath(watchConfig.path)
+ }));
+ }
const envArray = container?.tunnel?.docker.environment ?? [];
const filteredEnvArray = envArray.filter((envVar) => !envVar.startsWith('FORGE_EGRESS_PROXY_URL='));
filteredEnvArray.push('FORGE_EGRESS_PROXY_URL=http://proxy-sidecar:7072');
config.environment = filteredEnvArray;
return [container.key, config];
}));
+ const healthEndpointPath = containersWithTunnelConfig[0].health.route.path;
const dockerComposeConfig = {
services: {
...containerConfig,
- ...(await getProxySidecarConfig(serviceKey, Object.keys(containerConfig), port, appId, envId))
+ ...(await getProxySidecarConfig(serviceKey, Object.keys(containerConfig), port, appId, envId, serviceIndex, healthEndpointPath))
}
};
const yamlString = yaml.stringify(dockerComposeConfig);
const filePath = getContainerDockerComposePath(serviceKey);
@@ -99,9 +120,19 @@
}
return filesGenerated;
};
exports.generateContainersDockerComposeFile = generateContainersDockerComposeFile;
-const getProxySidecarConfig = async (serviceKey, containerKeys, port, appId, envId) => {
+const adjustFilePath = (filePath) => {
+ if (path.isAbsolute(filePath)) {
+ return filePath;
+ }
+ let adjusted = filePath;
+ if (adjusted.startsWith('./')) {
+ adjusted = adjusted.slice(2);
+ }
+ return path.join('..', adjusted);
+};
+const getProxySidecarConfig = async (serviceKey, containerKeys, port, appId, envId, serviceIndex, healthEndpointPath) => {
let fopBaseUrl = 'https://forge-outbound-proxy.services.atlassian.com';
let jwksUrl = 'https://forge.cdn.prod.atlassian-dev.net/.well-known/jwks.json';
let proxySidecarImage = 'forge-ecr.services.atlassian.com/forge-platform/proxy-sidecar:latest';
if (process.env.FORGE_GRAPHQL_GATEWAY?.startsWith('https://api-private.stg.atlassian.com/graphql')) {
@@ -123,12 +154,16 @@
`APP_ID=ari:cloud:ecosystem::app/${appIdShort}`,
`ENV_ID=ari:cloud:ecosystem::environment/${appIdShort}/${envId}`,
`JWKS_URL=${jwksUrl}`,
`IS_LOCAL_DEV=true`,
- `K8S_AUTH_TOKEN_PATH=${exports.PROXY_SIDECAR_VOLUME_DIR}/${exports.K8S_AUTH_TOKEN_FILENAME}`
+ `K8S_AUTH_TOKEN_PATH=${exports.PROXY_SIDECAR_VOLUME_DIR}/${exports.K8S_AUTH_TOKEN_FILENAME}`,
+ `${containerKeys[0]}_CONTAINER_HEALTHCHECK=http://${containerKeys[0]}:8080${healthEndpointPath}`
],
volumes: [`../${exports.CONTAINER_SERVICE_ASSETS}:${exports.PROXY_SIDECAR_VOLUME_DIR}:ro`],
- ports: [`${port}:${tunnel_options_1.DEFAULT_PROXY_INGRESS_PORT}`]
+ ports: [
+ `${port}:${tunnel_options_1.DEFAULT_PROXY_INGRESS_PORT}`,
+ `${tunnel_options_1.DEFAULT_PROXY_HEALTHCHECK_PORT_HOST_MACHINE + serviceIndex}:${tunnel_options_1.DEFAULT_PROXY_HEALTHCHECK_PORT}`
+ ]
}
};
};
const getProxySidecarContainerName = (serviceKey) => {
@@ -153,9 +188,9 @@
}
}
};
exports.deleteDockerComposeFile = deleteDockerComposeFile;
-const startDockerComposeStack = async (dockerComposeFilePath, serviceKey) => {
+const startDockerComposeStack = async (dockerComposeFilePath, serviceKey, logger) => {
try {
await (0, docker_compose_1.pullOne)('proxy-sidecar', {
cwd: path.dirname(dockerComposeFilePath),
log: true,
@@ -164,23 +199,155 @@
}
catch (err) {
throw new DockerUnableToPullProxySidecarImage(err);
}
+ await waitForContainersToStart(dockerComposeFilePath, serviceKey, logger);
+};
+exports.startDockerComposeStack = startDockerComposeStack;
+const determineComposeFlags = async (dockerComposeFilePath, serviceKey, logger) => {
+ const flags = ['--build', '--quiet-pull'];
try {
- await (0, docker_compose_1.upAll)({
- cwd: path.dirname(dockerComposeFilePath),
- log: true,
- config: dockerComposeFilePath,
- composeOptions: [`-p${serviceKey}`],
- commandOptions: ['--build']
+ const composeConfig = getComposeConfig(dockerComposeFilePath);
+ const hasWatchConfig = Object.values(composeConfig?.services ?? {}).some((service) => service?.develop?.watch);
+ if (hasWatchConfig) {
+ logger.info(`Hot reload config detected. Starting up ${serviceKey} containers using the --watch flag.`);
+ flags.push('--watch');
+ }
+ }
+ catch (_) { }
+ return flags;
+};
+exports.determineComposeFlags = determineComposeFlags;
+const getComposeConfig = (dockerComposeFilePath) => {
+ try {
+ const composeContent = fs.readFileSync(dockerComposeFilePath, 'utf8');
+ const composeConfig = yaml.parse(composeContent);
+ return composeConfig;
+ }
+ catch (_) { }
+ return undefined;
+};
+const extractProxySidecarHealthcheckUrl = (composeConfig) => {
+ if (!composeConfig?.services) {
+ return undefined;
+ }
+ let healthCheckHostPort = undefined;
+ try {
+ const proxySidecarService = composeConfig.services[PROXY_SIDECAR_SERVICE_NAME];
+ if (proxySidecarService && proxySidecarService.ports && proxySidecarService.ports.length > 0) {
+ const portMapping = proxySidecarService.ports.find((portMapping) => {
+ const parts = portMapping.split(':');
+ return parts[1] === tunnel_options_1.DEFAULT_PROXY_HEALTHCHECK_PORT.toString();
+ });
+ if (portMapping) {
+ healthCheckHostPort = portMapping.split(':')[0];
+ }
+ }
+ }
+ catch (_) { }
+ if (healthCheckHostPort) {
+ return `http://localhost:${healthCheckHostPort}/health`;
+ }
+ return undefined;
+};
+const checkContainerHealth = async (url) => {
+ return new Promise((resolve) => {
+ const request = http.get(url, { timeout: 3000 }, (res) => {
+ resolve((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300);
});
+ request.on('error', () => {
+ resolve(false);
+ });
+ request.on('timeout', () => {
+ request.destroy();
+ resolve(false);
+ });
+ });
+};
+const pollContainerHealth = async (proxySidecarHealthEndpointUrl, logger) => {
+ const maxAttempts = exports.STARTUP_TIMER_MS / 1000 - exports.STARTUP_TIMER_BUFFER_MS / 1000;
+ const pollInterval = 1000;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const isHealthy = await checkContainerHealth(proxySidecarHealthEndpointUrl);
+ if (isHealthy) {
+ logger.info('All health endpoints responded successfully!');
+ return true;
+ }
+ if (attempt === maxAttempts) {
+ logger.warn(`Containers did not become healthy within ${exports.STARTUP_TIMER_MS / 1000}s`);
+ return false;
+ }
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
+ return false;
+};
+const waitForContainersToStart = async (dockerComposeFilePath, serviceKey, logger) => {
+ const composeFlags = await (0, exports.determineComposeFlags)(dockerComposeFilePath, serviceKey, logger);
+ let proxySidecarHealthEndpointUrl;
+ try {
+ const composeConfig = getComposeConfig(dockerComposeFilePath);
+ proxySidecarHealthEndpointUrl = extractProxySidecarHealthcheckUrl(composeConfig);
+ }
catch (err) {
- throw new DockerUnableToStartError(err);
+ throw new UnableToParseDockerComposeFileError(serviceKey, err);
}
+ return new Promise((resolve, reject) => {
+ let containersStarted = false;
+ const startupTimer = setTimeout(() => {
+ if (!containersStarted) {
+ containersStarted = true;
+ logger.info(`Startup timeout reached (${exports.STARTUP_TIMER_MS / 1000}s). Starting up the forge tunnel...`);
+ resolve();
+ }
+ }, exports.STARTUP_TIMER_MS);
+ const logFilter = (chunk) => {
+ const output = chunk.toString();
+ const lines = output.split('\n');
+ lines.forEach((line) => {
+ if (!line.trim())
+ return;
+ if (LIFECYCLE_PATTERNS.some((pattern) => pattern.test(line))) {
+ logger.info(line);
+ }
+ });
+ };
+ (0, docker_compose_1.execCompose)('up', composeFlags, {
+ cwd: path.dirname(dockerComposeFilePath),
+ log: false,
+ config: dockerComposeFilePath,
+ composeOptions: [`-p${serviceKey}`],
+ callback: logFilter
+ }).catch((error) => {
+ clearTimeout(startupTimer);
+ reject(new DockerUnableToStartError(error));
+ });
+ setTimeout(async () => {
+ if (containersStarted)
+ return;
+ try {
+ if (proxySidecarHealthEndpointUrl) {
+ logger.info(`Polling health endpoints for service: ${serviceKey}`);
+ const allHealthy = await pollContainerHealth(proxySidecarHealthEndpointUrl, logger);
+ if (allHealthy && !containersStarted) {
+ containersStarted = true;
+ clearTimeout(startupTimer);
+ resolve();
+ }
+ }
+ else {
+ logger.info('No health check endpoints found. Continuing with tunnel startup...');
+ containersStarted = true;
+ clearTimeout(startupTimer);
+ resolve();
+ }
+ }
+ catch (err) {
+ logger.warn(`Health check polling failed: ${err.message}. Relying on startup timeout...`);
+ }
+ }, exports.STARTUP_TIMER_BUFFER_MS);
+ });
};
-exports.startDockerComposeStack = startDockerComposeStack;
-const stopDockerComposeStack = async (configFile, composeFiles) => {
+const stopDockerComposeStack = async (configFile, logger, composeFiles) => {
if (!composeFiles || Object.keys(composeFiles).length === 0)
return;
const { services } = await configFile.readConfig();
const serviceWithTunnelConfigExists = services?.some((service) => service.containers?.some((container) => {
@@ -189,9 +356,15 @@
if (!services || services.length === 0 || !serviceWithTunnelConfigExists)
return;
await Promise.all(Object.entries(composeFiles).map(async ([serviceKey, file]) => {
try {
- await (0, docker_compose_1.downAll)({ cwd: '.', log: true, config: file, composeOptions: [`-p${serviceKey}`] });
+ await (0, docker_compose_1.downAll)({
+ cwd: '.',
+ log: false,
+ config: file,
+ composeOptions: [`-p${serviceKey}`],
+ callback: createCustomLogFilter(logger)
+ });
await (0, exports.deleteDockerComposeFile)(file);
}
catch (err) {
throw new Error(text_1.Text.tunnel.unableToStopDockerComposeStack(serviceKey, err.message ?? 'Unknown Error Occurred.'));
@@ -199,8 +372,23 @@
}));
deleteContainerServiceAssetsDir();
};
exports.stopDockerComposeStack = stopDockerComposeStack;
+const createCustomLogFilter = (logger) => {
+ let buffer = '';
+ return (chunk) => {
+ buffer += chunk.toString();
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ lines.forEach((line) => {
+ if (!line.trim())
+ return;
+ if (LIFECYCLE_PATTERNS.some((pattern) => pattern.test(line))) {
+ logger.info(line);
+ }
+ });
+ };
+};
const processDockerAuthentication = async (childProcess) => {
await new Promise((resolve, reject) => {
childProcess.on('close', (code) => {
if (code === 0) {