Skip to content

Commit 28a2091

Browse files
committed
Dramatically improve docker-compose interception UX
This moves from remapping the project name label to instead remapping the labels that hold the config hash. That means that all docker-compose commands (intercepted or not) are now working on the same state, with the only difference being that each one considers containers created by the other to need recreating. This is great! No more conflicts, no leftover container usage, and much easier container management, with a simpler implementation. What a win!
1 parent 4e39274 commit 28a2091

File tree

5 files changed

+103
-121
lines changed

5 files changed

+103
-121
lines changed

src/interceptors/docker/docker-commands.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getTerminalEnvVars,
99
OVERRIDES_DIR
1010
} from '../terminal/terminal-env-overrides';
11+
import { transformComposeCreationLabels } from './docker-compose';
1112

1213
// Used to label intercepted docker containers with the port of the proxy
1314
// that's currently intercepting them.
@@ -215,15 +216,7 @@ export function transformContainerCreationConfig(
215216
)
216217
],
217218
Labels: {
218-
...currentConfig.Labels,
219-
// If there's a docker-compose project label, append our suffix. This ensures that normal
220-
// DC commands don't use intercepted containers, and vice versa.
221-
...(currentConfig.Labels?.['com.docker.compose.project']
222-
? { 'com.docker.compose.project':
223-
`${currentConfig.Labels['com.docker.compose.project']}_HTK:${proxyPort}`
224-
}
225-
: {}
226-
),
219+
...transformComposeCreationLabels(proxyPort, currentConfig.Labels),
227220
// Label the resulting container as intercepted by this specific proxy:
228221
[DOCKER_CONTAINER_LABEL]: String(proxyPort)
229222
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const interceptionSuffix = (proxyPort: number) => `+httptoolkit:${proxyPort}`;
2+
3+
// Take a set of labels provided by a client for a new container that we're intercepting, and remap the config
4+
// hashes so this can be used in future docker-compose commands automatically:
5+
export function transformComposeCreationLabels(proxyPort: number, labels: { [label: string]: string } | undefined) {
6+
// No/null labels - we don't need to do anything
7+
if (!labels) return undefined;
8+
9+
// Not a docker-compose container - nothing to do here
10+
if (!labels['com.docker.compose.config-hash']) return labels;
11+
12+
return {
13+
...labels,
14+
'com.docker.compose.config-hash': labels['com.docker.compose.config-hash'] + interceptionSuffix(proxyPort)
15+
};
16+
}
17+
18+
// Take a set of labels that we might be returning to a client, and remap the config hashes so that only
19+
// the intercepted containers seem to be usable (so that any non-intercepted containers are recreated, not used).
20+
export function transformComposeResponseLabels(proxyPort: number, labels: { [label: string]: string } | undefined) {
21+
// No/null labels - we don't need to do anything
22+
if (!labels) return undefined;
23+
24+
// Not a docker-compose container - nothing to do here
25+
if (!labels['com.docker.compose.config-hash']) return labels;
26+
27+
const currentHash = labels['com.docker.compose.config-hash'];
28+
const modifiedHash = currentHash.endsWith(interceptionSuffix(proxyPort))
29+
? currentHash.slice(0, -1 * interceptionSuffix(proxyPort).length)
30+
: currentHash + '+unintercepted';
31+
32+
return {
33+
...labels,
34+
'com.docker.compose.config-hash': modifiedHash
35+
};
36+
}

src/interceptors/docker/docker-networking.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ class DockerNetworkMonitor {
367367
const linkStrings: string[] = container.HostConfig.Links || [];
368368
const linkAliases = await Promise.all(linkStrings.map(async (link) => {
369369
// Aliases are of the form:
370-
// /compose_default-service-a_1_HTK8000:/compose_linked-service-b_1_HTK8000/a
370+
// /compose_default-service-a_1:/compose_linked-service-b_1/a
371371
// I.e. service-a is linked by service-b with alias 'a'.
372372
const endOfContainerName = link.indexOf(':/');
373373
const aliasIndex = link.lastIndexOf('/');

src/interceptors/docker/docker-proxy.ts

Lines changed: 58 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from './docker-commands';
2323
import { injectIntoBuildStream, getBuildOutputPipeline } from './docker-build-injection';
2424
import { ensureDockerServicesRunning, isDockerAvailable } from './docker-interception-services';
25+
import { transformComposeResponseLabels } from './docker-compose';
2526

2627
export const getDockerPipePath = (proxyPort: number, targetPlatform: NodeJS.Platform = process.platform) => {
2728
if (targetPlatform === 'win32') {
@@ -47,6 +48,7 @@ const BUILD_IMAGE_MATCHER = /^\/[^\/]+\/build$/;
4748
const EVENTS_MATCHER = /^\/[^\/]+\/events$/;
4849
const ATTACH_CONTAINER_MATCHER = /^\/[^\/]+\/containers\/([^\/]+)\/attach/;
4950
const CONTAINER_LIST_MATCHER = /^\/[^\/]+\/containers\/json/;
51+
const CONTAINER_INSPECT_MATCHER = /^\/[^\/]+\/containers\/[^\/]+\/json/;
5052

5153
const DOCKER_PROXY_MAP: { [mockServerPort: number]: Promise<DestroyableServer> | undefined } = {};
5254

@@ -144,41 +146,6 @@ async function createDockerProxy(proxyPort: number, httpsConfig: { certPath: str
144146
}
145147
);
146148
requestBodyStream = stream.Readable.from(JSON.stringify(transformedConfig));
147-
148-
// If you specify an explicit name in cases that will cause conflicts (when a container already
149-
// exists, or if you're using docker-compose) we try to create a separate container instead,
150-
// that uses a _HTK$PORT suffix to avoid conflicts. This happens commonly, because of how
151-
// docker-compose automatically generates container names.
152-
const containerName = reqUrl.searchParams.get('name');
153-
if (containerName) {
154-
const existingContainer = await docker.getContainer(containerName).inspect()
155-
.catch<false>(() => false);
156-
157-
if (existingContainer && existingContainer.State.Running) {
158-
// If there's a duplicate running container, we could rename the new one, but instead we return
159-
// an error. It's likely that this will create conflicts otherwise - e.g. two running containers
160-
// using the same volumes or the same network aliases. Better to play it safe.
161-
res.statusCode = 409;
162-
res.end(JSON.stringify({
163-
"message": `Conflict. The container name ${
164-
containerName
165-
} is already in use by a running container.\n${''
166-
}HTTP Toolkit won't intercept this by default to avoid conflicts over shared resources. ${''
167-
}To create & intercept this container, either stop the existing unintercepted container, or use a different container name.`
168-
}));
169-
return;
170-
} else if (existingContainer || hasDockerComposeLabels) {
171-
// If there's a naming conflict, and we can safely work around it (because the container isn't
172-
// running) then we do so.
173-
174-
// For Docker-Compose, we *always* rewrite names. This ensures that subsequent Docker-Compose usage
175-
// outside intercepted usage doesn't run into naming conflicts (however, this still only applies
176-
// after checking for running containers - we never create a duplicate parallel container ourselves)
177-
178-
reqUrl.searchParams.set('name', `${containerName}_HTK${proxyPort}`);
179-
req.url = reqUrl.toString();
180-
}
181-
}
182149
}
183150

184151
// Intercept container creation (e.g. docker start):
@@ -193,70 +160,6 @@ async function createDockerProxy(proxyPort: number, httpsConfig: { certPath: str
193160
"The container must be recreated here first - try `docker run <image>` instead."
194161
);
195162
}
196-
197-
if (containerData.Name.endsWith(`_HTK${proxyPort}`)) {
198-
// Trim initial slash and our HTK suffix:
199-
const clonedContainerName = containerData.Name.slice(1, -1 * `_HTK${proxyPort}`.length);
200-
const clonedContainerData = await docker.getContainer(clonedContainerName)
201-
.inspect()
202-
.catch(() => undefined);
203-
204-
if (clonedContainerData && clonedContainerData.State.Running) {
205-
// If you successfully intercept a docker-compose container, stop it & start the original container(s),
206-
// and then restart the already-created intercepted container, you could risk conflicts, so we warn you:
207-
res.writeHead(409).end(
208-
`Conflict: an unintercepted container with the same base name is already running.\n${''
209-
}HTTP Toolkit won't launch intercepted containers in parallel by default to avoid conflicts ${''
210-
}over shared resources. To create & intercept this container, either stop the existing ${''
211-
}unintercepted container, or use a different container name.`
212-
213-
);
214-
}
215-
}
216-
}
217-
218-
if (
219-
reqPath.match(CONTAINER_LIST_MATCHER) ||
220-
reqPath.match(EVENTS_MATCHER)
221-
) {
222-
const filterString = reqUrl.searchParams.get('filters') ?? '{}';
223-
224-
try {
225-
const filters = JSON.parse(filterString) as {
226-
// Docs say only string[] is allowed, but Docker CLI uses bool maps in "docker compose"
227-
[key: string]: string[] | { [key: string]: boolean }
228-
};
229-
const labelFilters = (
230-
_.isArray(filters.label)
231-
? filters.label
232-
: Object.keys(filters.label ?? {})
233-
.filter(key => !!(filters.label as _.Dictionary<boolean>)[key])
234-
);
235-
const projectFilter = labelFilters.filter(l => l.startsWith("com.docker.compose.project="))[0];
236-
237-
if (projectFilter) {
238-
const project = projectFilter.slice(projectFilter.indexOf('=') + 1);
239-
240-
// This is a request from docker-compose, looking for containers related to a
241-
// specific project. Add an extra filter so that it only finds *intercepted*
242-
// containers. This ensures it'll recreate any unintercepted containers.
243-
reqUrl.searchParams.set('filters', JSON.stringify({
244-
...filters,
245-
label: [
246-
// Replace the project filter, to ensure that the intercepted & non-intercepted containers
247-
// are handled separately. We need to ensure that a) this request only finds intercepted
248-
// containers, and b) future non-proxied requests only find non-intercepted containers.
249-
// By excluding non-intercepted containers, we force DC to recreate, so we can then
250-
// intercept the container creation itself and inject what we need.
251-
...labelFilters.filter((label) => label !== projectFilter),
252-
`com.docker.compose.project=${project}_HTK:${proxyPort}`
253-
]
254-
}));
255-
req.url = reqUrl.toString();
256-
}
257-
} catch (e) {
258-
console.log("Could not parse /containers/json filters param", e);
259-
}
260163
}
261164

262165
let extraDockerCommandCount: Promise<number> | undefined;
@@ -304,23 +207,73 @@ async function createDockerProxy(proxyPort: number, httpsConfig: { certPath: str
304207
});
305208

306209
dockerReq.on('response', async (dockerRes) => {
210+
res.on('error', (e) => {
211+
console.error('Docker proxy conn error', e);
212+
dockerRes.destroy();
213+
});
214+
215+
// In any container data responses that might be used by docker-compose, we need to remap some of the
216+
// content to ensure that intercepted containers are always used:
217+
const isContainerInspect = reqPath.match(CONTAINER_INSPECT_MATCHER);
218+
const isComposeContainerQuery = reqPath.match(CONTAINER_LIST_MATCHER) &&
219+
reqUrl.searchParams.get('filters')?.includes("com.docker.compose");
220+
const shouldRemapContainerData = isContainerInspect || isComposeContainerQuery;
221+
222+
if (shouldRemapContainerData) {
223+
// We're going to mess with the body, so we need to ensure that the content
224+
// length isn't going to conflict along the way:
225+
delete dockerRes.headers['content-length'];
226+
}
227+
307228
res.writeHead(
308229
dockerRes.statusCode!,
309230
dockerRes.statusMessage,
310231
dockerRes.headers
311232
);
312-
res.on('error', (e) => {
313-
console.error('Docker proxy conn error', e);
314-
dockerRes.destroy();
315-
});
233+
res.flushHeaders(); // Required, or blocking responses (/wait) don't work!
316234

317235
if (reqPath.match(BUILD_IMAGE_MATCHER) && dockerRes.statusCode === 200) {
236+
// We transform the build output to replace the docker build interception steps with a cleaner
237+
// & simpler HTTP Toolkit interception message:
318238
dockerRes.pipe(getBuildOutputPipeline(await extraDockerCommandCount!)).pipe(res);
239+
} else if (shouldRemapContainerData) {
240+
// We need to remap container data, to hook all docker-compose behaviour:
241+
const data = await new Promise<Buffer>((resolve, reject) => {
242+
const dataChunks: Buffer[] = [];
243+
dockerRes.on('data', (d) => dataChunks.push(d));
244+
dockerRes.on('end', () => resolve(Buffer.concat(dataChunks)));
245+
dockerRes.on('error', reject);
246+
});
247+
248+
try {
249+
if (isComposeContainerQuery) {
250+
const containerQueryResponse: Dockerode.ContainerInfo[] = JSON.parse(data.toString('utf8'));
251+
const modifiedResponse = containerQueryResponse.map((container) => ({
252+
...container,
253+
Labels: transformComposeResponseLabels(proxyPort, container.Labels)
254+
}));
255+
256+
res.end(JSON.stringify(modifiedResponse));
257+
} else {
258+
const containerInspectResponse: Dockerode.ContainerInspectInfo = JSON.parse(data.toString('utf8'));
259+
const modifiedResponse = {
260+
...containerInspectResponse,
261+
Config: {
262+
...containerInspectResponse.Config,
263+
Labels: transformComposeResponseLabels(proxyPort, containerInspectResponse.Config?.Labels)
264+
}
265+
};
266+
267+
res.end(JSON.stringify(modifiedResponse));
268+
}
269+
} catch (e) {
270+
console.error("Failed to parse container data response", e);
271+
// Write the raw body back to the response - effectively just do nothing.
272+
res.end(data);
273+
}
319274
} else {
320275
dockerRes.pipe(res);
321276
}
322-
323-
res.flushHeaders(); // Required, or blocking responses (/wait) don't work!
324277
});
325278
});
326279

test/interceptors/docker-terminal-interception.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,15 +282,15 @@ Successfully built <hash>
282282
.replace(/\s+\|/g, ' |'); // Strip container name variable whitespace
283283

284284
[
285-
`compose_host_1_HTK${server.port}`,
286-
`compose_default-service-a_1_HTK${server.port}`,
287-
`compose_default-linked-service-b_1_HTK${server.port}`,
288-
`compose_multi-network-a_1_HTK${server.port}`,
289-
`compose_multi-network-b_1_HTK${server.port}`
285+
`host_1`,
286+
`default-service-a_1`,
287+
`default-linked-service-b_1`,
288+
`multi-network-a_1`,
289+
`multi-network-b_1`
290290
].forEach((container) => {
291291
expect(dcOutput).to.include(`${container} | All requests ok`);
292292
});
293-
expect(dcOutput).to.include(`compose_none_1_HTK${server.port} | Skipping`);
293+
expect(dcOutput).to.include(`none_1 | Skipping`);
294294
});
295295

296296
it("should clean up containers after shutdown", async () => {

0 commit comments

Comments
 (0)