Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support port ranges when starting a container (#3204) #3654

Merged
merged 3 commits into from Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/main/src/plugin/index.ts
Expand Up @@ -47,7 +47,7 @@ import type { PullEvent } from './api/pull-event.js';
import type { ExtensionInfo } from './api/extension-info.js';
import type { ImageInspectInfo } from './api/image-inspect-info.js';
import type { TrayMenu } from '../tray-menu.js';
import { getFreePort } from './util/port.js';
import { getFreePort, getFreePortRange } from './util/port.js';
import { isLinux, isMac } from '../util.js';
import type { MessageBoxOptions, MessageBoxReturnValue } from './message-box.js';
import { MessageBox } from './message-box.js';
Expand Down Expand Up @@ -1154,6 +1154,10 @@ export class PluginSystem {
return getFreePort(port);
});

this.ipcHandle('system:get-free-port-range', async (_, rangeSize: number): Promise<string> => {
return getFreePortRange(rangeSize);
});

this.ipcHandle(
'provider-registry:startReceiveLogs',
async (
Expand Down
74 changes: 74 additions & 0 deletions packages/main/src/plugin/util/port.spec.ts
@@ -0,0 +1,74 @@
/**********************************************************************
* Copyright (C) 2023 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { expect, test } from 'vitest';
import * as port from './port.js';
import * as net from 'net';

test('return valid port range', async () => {
const range = await port.getFreePortRange(3);

const rangeValues = range.split('-');
expect(rangeValues.length).toBe(2);

const startRange = parseInt(rangeValues[0]);
const endRange = parseInt(rangeValues[1]);

expect(isNaN(startRange)).toBe(false);
expect(isNaN(endRange)).toBe(false);

expect(endRange + 1 - startRange).toBe(3);
expect(await port.isFreePort(startRange)).toBe(true);
expect(await port.isFreePort(endRange)).toBe(true);
});

test('check that the range returns new free ports if the one in previous call are busy', async () => {
const range = await port.getFreePortRange(3);

const rangeValues = range.split('-');
expect(rangeValues.length).toBe(2);

const startRange = parseInt(rangeValues[0]);
const endRange = parseInt(rangeValues[1]);

expect(isNaN(startRange)).toBe(false);
expect(isNaN(endRange)).toBe(false);

expect(endRange + 1 - startRange).toBe(3);

const server = net.createServer();
server.listen(endRange);

const newRange = await port.getFreePortRange(3);

server.close();

const newRangeValues = newRange.split('-');
expect(newRangeValues.length).toBe(2);

const startNewRange = parseInt(newRangeValues[0]);
const endNewRange = parseInt(newRangeValues[1]);

expect(isNaN(startNewRange)).toBe(false);
expect(isNaN(endNewRange)).toBe(false);

expect(startNewRange > endRange).toBe(true);
expect(endNewRange + 1 - startNewRange).toBe(3);
expect(await port.isFreePort(startNewRange)).toBe(true);
expect(await port.isFreePort(endNewRange)).toBe(true);
});
29 changes: 29 additions & 0 deletions packages/main/src/plugin/util/port.ts
Expand Up @@ -35,3 +35,32 @@ export function getFreePort(port = 0): Promise<number> {
.listen(port),
);
}

/**
* Find a free port range
*/
export async function getFreePortRange(rangeSize: number): Promise<string> {
let port = 9000;
let startPort = port;

do {
if (await isFreePort(port)) {
++port;
} else {
++port;
startPort = port;
}
} while (port + 1 - startPort <= rangeSize);

return `${startPort}-${port - 1}`;
}

export function isFreePort(port: number): Promise<boolean> {
const server = net.createServer();
return new Promise((resolve, reject) =>
server
.on('error', (error: NodeJS.ErrnoException) => (error.code === 'EADDRINUSE' ? resolve(false) : reject(error)))
.on('listening', () => server.close(() => resolve(true)))
.listen(port),
);
}
4 changes: 4 additions & 0 deletions packages/preload/src/index.ts
Expand Up @@ -1064,6 +1064,10 @@ function initExposure(): void {
return ipcInvoke('system:get-free-port', port);
});

contextBridge.exposeInMainWorld('getFreePortRange', async (rangeSize: number): Promise<string> => {
return ipcInvoke('system:get-free-port-range', rangeSize);
});

type LogFunction = (...data: unknown[]) => void;

let onDataCallbacksStartReceiveLogsId = 0;
Expand Down
27 changes: 27 additions & 0 deletions packages/renderer/src/lib/image/RunImage.spec.ts
Expand Up @@ -25,6 +25,7 @@ import { runImageInfo } from '../../stores/run-image-store';
import RunImage from '/@/lib/image/RunImage.svelte';
import type { ImageInspectInfo } from '../../../../main/src/plugin/api/image-inspect-info';
import { mockBreadcrumb } from '../../stores/breadcrumb';
import userEvent from '@testing-library/user-event';

// fake the window.events object
beforeAll(() => {
Expand Down Expand Up @@ -308,4 +309,30 @@ describe('RunImage', () => {
expect.objectContaining({ Cmd: ['command1', 'command2'] }),
);
});

test('Expect to see an error if the container/host ranges have different size', async () => {
await createRunImage(undefined, ['command1', 'command2']);

const customMappingButton = screen.getByRole('button', { name: 'Add custom port mapping' });
await fireEvent.click(customMappingButton);

const hostInput = screen.getByLabelText('host port');
await userEvent.click(hostInput);
await userEvent.clear(hostInput);
await userEvent.keyboard('9000-9001');

const containerInput = screen.getByLabelText('container port');
await userEvent.click(containerInput);
await userEvent.clear(containerInput);
await userEvent.keyboard('9000-9003');

const button = screen.getByRole('button', { name: 'Start Container' });

await fireEvent.click(button);

const errorComponent = screen.getByLabelText('createError');
expect(errorComponent.textContent.trim()).toBe(
'Error: host and container port ranges (9000-9001:9000-9003) have different lengths: 2 vs 4',
);
});
});
139 changes: 122 additions & 17 deletions packages/renderer/src/lib/image/RunImage.svelte
Expand Up @@ -99,7 +99,6 @@ onMount(async () => {
return;
}

exposedPorts = [];
containerPortMapping = [];

imageInspectInfo = await window.getImageInspect(image.engineId, image.id);
Expand All @@ -121,8 +120,8 @@ onMount(async () => {
containerPortMapping = new Array<string>(exposedPorts.length);
await Promise.all(
exposedPorts.map(async (port, index) => {
const localPort = await getPort(port);
containerPortMapping[index] = `${localPort}`;
const localPorts = await getPortsInfo(port);
containerPortMapping[index] = localPorts;
}),
);
dataReady = true;
Expand Down Expand Up @@ -172,9 +171,37 @@ onMount(async () => {
}
});

async function getPortsInfo(portDescriptor: string): Promise<string | undefined> {
// check if portDescriptor is a range of ports
if (portDescriptor.includes('-')) {
return await getPortRange(portDescriptor);
} else {
const localPort = await getPort(portDescriptor);
if (!localPort) {
return undefined;
}
return `${localPort}`;
}
}

/**
* return a range of the same length as portDescriptor containing free ports
* undefined if the portDescriptor range is not valid
* e.g 5000:5001 -> 9000:9001
*/
function getPortRange(portDescriptor: string): Promise<string | undefined> {
const rangeValues = getStartEndRange(portDescriptor);
if (!rangeValues) {
return Promise.resolve(undefined);
}

const rangeSize = rangeValues.endRange + 1 - rangeValues.startRange;
return window.getFreePortRange(rangeSize);
}

function getPort(portDescriptor: string): Promise<number | undefined> {
let port: number;
if (portDescriptor.endsWith('/tcp')) {
if (portDescriptor.endsWith('/tcp') || portDescriptor.endsWith('/udp')) {
port = parseInt(portDescriptor.substring(0, portDescriptor.length - 4));
} else {
port = parseInt(portDescriptor);
Expand All @@ -192,20 +219,34 @@ async function startContainer() {
const ExposedPorts = {};

const PortBindings = {};
exposedPorts.forEach((port, index) => {
if (containerPortMapping[index]) {
PortBindings[port] = [{ HostPort: containerPortMapping[index] }];
}
ExposedPorts[port] = {};
});

hostContainerPortMappings
.filter(pair => pair.hostPort && pair.containerPort)
.forEach(pair => {
PortBindings[pair.containerPort] = [{ HostPort: pair.hostPort }];
ExposedPorts[pair.containerPort] = {};
try {
exposedPorts.forEach((port, index) => {
if (port.includes('-') || containerPortMapping[index]?.includes('-')) {
addPortsFromRange(ExposedPorts, PortBindings, port, containerPortMapping[index]);
} else {
if (containerPortMapping[index]) {
PortBindings[port] = [{ HostPort: containerPortMapping[index] }];
}
ExposedPorts[port] = {};
}
});

hostContainerPortMappings
.filter(pair => pair.hostPort && pair.containerPort)
.forEach(pair => {
if (pair.containerPort.includes('-') || pair.hostPort.includes('-')) {
addPortsFromRange(ExposedPorts, PortBindings, pair.containerPort, pair.hostPort);
} else {
PortBindings[pair.containerPort] = [{ HostPort: pair.hostPort }];
ExposedPorts[pair.containerPort] = {};
}
});
} catch (e) {
createError = e;
console.error('Error while creating container', e);
return;
}

const Env = environmentVariables
// filter variables withouts keys
.filter(env => env.key)
Expand Down Expand Up @@ -320,6 +361,66 @@ async function startContainer() {
window.location.href = '#/containers';
}

function addPortsFromRange(
exposedPorts: { [key: string]: unknown },
portBindings: { [key: string]: unknown },
containerRange: string,
hostRange: string,
) {
const containerRangeValues = getStartEndRange(containerRange);
if (!containerRangeValues) {
throw new Error(`range ${containerRange} is not valid. Must be in format <port>-<port> (e.g 8080-8085)`);
}
const startContainerRange = containerRangeValues.startRange;
const endContainerRange = containerRangeValues.endRange;

const hostRangeValues = getStartEndRange(hostRange);
if (!hostRangeValues) {
throw new Error(`range ${hostRange} is not valid. Must be in format <port>-<port> (e.g 8080-8085)`);
}
const startHostRange = hostRangeValues.startRange;
const endHostRange = hostRangeValues.endRange;

// if the two ranges have different size, do not proceed
const containerRangeSize = endContainerRange + 1 - startContainerRange;
const hostRangeSize = endHostRange + 1 - startHostRange;
if (containerRangeSize !== hostRangeSize) {
throw new Error(
`host and container port ranges (${hostRange}:${containerRange}) have different lengths: ${hostRangeSize} vs ${containerRangeSize}`,
);
}

// we add all ports separately - if we have two ranges like 8080-8082 and 9000-9002 we'll end up with a mapping like
// 8080 => HostPort: 9000
// 8081 => HostPort: 9001
// 8082 => HostPort: 9002
for (let i = 0; i < containerRangeSize; i++) {
portBindings[`${startContainerRange + i}`] = [{ HostPort: `${startHostRange + i}` }];
exposedPorts[`${startContainerRange + i}`] = {};
}
}

function getStartEndRange(range: string) {
if (range.endsWith('/tcp') || range.endsWith('/udp')) {
range = range.substring(0, range.length - 4);
}

const rangeValues = range.split('-');
if (rangeValues.length !== 2) {
return undefined;
}
const startRange = parseInt(rangeValues[0]);
const endRange = parseInt(rangeValues[1]);

if (isNaN(startRange) || isNaN(endRange)) {
return undefined;
}
return {
startRange,
endRange,
};
}

function addEnvVariable() {
environmentVariables = [...environmentVariables, { key: '', value: '' }];
}
Expand Down Expand Up @@ -522,11 +623,13 @@ function checkContainerName(event: any) {
<input
type="text"
bind:value="{hostContainerPortMapping.hostPort}"
aria-label="host port"
placeholder="Host Port"
class="w-full p-2 outline-none text-sm bg-charcoal-800 rounded-sm text-gray-700 placeholder-gray-700" />
<input
type="text"
bind:value="{hostContainerPortMapping.containerPort}"
aria-label="container port"
placeholder="Container Port"
class="ml-2 w-full p-2 outline-none text-sm bg-charcoal-800 rounded-sm text-gray-700 placeholder-gray-700" />
<button
Expand Down Expand Up @@ -892,7 +995,9 @@ function checkContainerName(event: any) {
<Button on:click="{() => startContainer()}" class="w-full" icon="{faPlay}" bind:disabled="{invalidFields}">
Start Container
</Button>
<ErrorMessage class="py-2 text-sm" error="{createError}" />
<div aria-label="createError">
<ErrorMessage class="py-2 text-sm" error="{createError}" />
</div>
</div>
</div>
</FormPage>
Expand Down