Skip to content

Commit

Permalink
fix(no-duplicate-exported-ports): add protocol handling in port parsing
Browse files Browse the repository at this point in the history
ref: #91
  • Loading branch information
zavoloklom committed Jan 1, 2025
1 parent 42f0da7 commit 7063c56
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 95 deletions.
22 changes: 14 additions & 8 deletions src/rules/no-duplicate-exported-ports-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
RuleMeta,
} from '../linter/linter.types';
import { findLineNumberForService } from '../util/line-finder';
import { extractPublishedPortValue, parsePortsRange } from '../util/service-ports-parser';
import { extractPublishedPortValueWithProtocol, parsePortsRange } from '../util/service-ports-parser';

export default class NoDuplicateExportedPortsRule implements LintRule {
public name = 'no-duplicate-exported-ports';
Expand Down Expand Up @@ -61,11 +61,12 @@ export default class NoDuplicateExportedPortsRule implements LintRule {
if (!isSeq(ports)) return;

ports.items.forEach((portItem) => {
const publishedPort = extractPublishedPortValue(portItem);
const currentPortRange = parsePortsRange(publishedPort);
const { port, protocol } = extractPublishedPortValueWithProtocol(portItem);
const currentPortRange = parsePortsRange(port);

currentPortRange.some((port) => {
if (exportedPortsMap.has(port)) {
currentPortRange.some((rangePort) => {
const rangeKey = `${rangePort}/${protocol}`;
if (exportedPortsMap.has(rangeKey)) {
const line = findLineNumberForService(parsedDocument, context.sourceCode, serviceName, 'ports');
errors.push({
rule: this.name,
Expand All @@ -74,8 +75,8 @@ export default class NoDuplicateExportedPortsRule implements LintRule {
severity: this.severity,
message: this.getMessage({
serviceName,
publishedPort,
anotherService: String(exportedPortsMap.get(port)),
publishedPort: port,
anotherService: String(exportedPortsMap.get(rangeKey)),
}),
line,
column: 1,
Expand All @@ -88,7 +89,12 @@ export default class NoDuplicateExportedPortsRule implements LintRule {
});

// Map ports to the service
currentPortRange.forEach((port) => !exportedPortsMap.has(port) && exportedPortsMap.set(port, serviceName));
currentPortRange.forEach((rangePort) => {
const rangeKey = `${rangePort}/${protocol}`;
if (!exportedPortsMap.has(rangeKey)) {
exportedPortsMap.set(rangeKey, serviceName);
}
});
});
});

Expand Down
8 changes: 4 additions & 4 deletions src/rules/service-ports-alphabetical-order-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
RuleMeta,
} from '../linter/linter.types';
import { findLineNumberForService } from '../util/line-finder';
import { extractPublishedPortValue } from '../util/service-ports-parser';
import { extractPublishedPortValueWithProtocol } from '../util/service-ports-parser';

export default class ServicePortsAlphabeticalOrderRule implements LintRule {
public name = 'service-ports-alphabetical-order';
Expand Down Expand Up @@ -50,7 +50,7 @@ export default class ServicePortsAlphabeticalOrderRule implements LintRule {
const ports = service.get('ports');
if (!isSeq(ports)) return;

const extractedPorts = ports.items.map((port) => extractPublishedPortValue(port));
const extractedPorts = ports.items.map((port) => extractPublishedPortValueWithProtocol(port).port);
const sortedPorts = [...extractedPorts].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));

if (JSON.stringify(extractedPorts) !== JSON.stringify(sortedPorts)) {
Expand Down Expand Up @@ -88,8 +88,8 @@ export default class ServicePortsAlphabeticalOrderRule implements LintRule {
if (!isSeq(ports)) return;

ports.items.sort((a, b) => {
const valueA = extractPublishedPortValue(a);
const valueB = extractPublishedPortValue(b);
const valueA = extractPublishedPortValueWithProtocol(a).port;
const valueB = extractPublishedPortValueWithProtocol(b).port;

return valueA.localeCompare(valueB, undefined, { numeric: true });
});
Expand Down
22 changes: 14 additions & 8 deletions src/util/service-ports-parser.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
import net from 'node:net';
import { isMap, isScalar } from 'yaml';

function extractPublishedPortValue(yamlNode: unknown): string {
function extractPublishedPortValueWithProtocol(yamlNode: unknown): { port: string; protocol: string } {
if (isScalar(yamlNode)) {
const value = String(yamlNode.value);

// Check for host before ports
const parts = value.split(/:(?![^[]*])/);
// Extract protocol (e.g., '8080/tcp')
const [portPart, protocol] = value.split('/');

// Extract the actual port, ignoring host/ip
const parts = portPart.split(/:(?![^[]*])/);

if (parts[0].startsWith('[') && parts[0].endsWith(']')) {
parts[0] = parts[0].slice(1, -1);
}

if (net.isIP(parts[0])) {
return String(parts[1]);
return { port: String(parts[1]), protocol: protocol || 'tcp' };
}

return parts[0];
return { port: parts[0], protocol: protocol || 'tcp' };
}

if (isMap(yamlNode)) {
return yamlNode.get('published')?.toString() || '';
return {
port: yamlNode.get('published')?.toString() || '',
protocol: yamlNode.get('protocol')?.toString() || 'tcp',
};
}

return '';
return { port: '', protocol: 'tcp' };
}

function extractPublishedPortInterfaceValue(yamlNode: unknown): string {
Expand Down Expand Up @@ -75,4 +81,4 @@ function parsePortsRange(port: string): string[] {
return ports;
}

export { extractPublishedPortValue, extractPublishedPortInterfaceValue, parsePortsRange };
export { extractPublishedPortValueWithProtocol, extractPublishedPortInterfaceValue, parsePortsRange };
29 changes: 29 additions & 0 deletions tests/rules/no-duplicate-exported-ports-rule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ services:
- 8000-8085
`;

// YAML with same ports but different protocols
const yamlWithDifferentProtocols = `
services:
myservice:
ports:
- '127.0.0.1:8388:8388/tcp'
- '127.0.0.1:8388:8388/udp'
- '127.0.0.1:8888:8888/tcp'
another-service:
ports:
- '127.0.0.1:8080:8080/tcp'
one-more-service:
ports:
- '127.0.0.1:8080:8080/udp'
`;

const filePath = '/docker-compose.yml';

// @ts-ignore TS2349
Expand Down Expand Up @@ -161,3 +177,16 @@ test('NoDuplicateExportedPortsRule: should return an error when range overlap is
t.true(error.message.includes(expectedMessages[index]));
});
});

// @ts-ignore TS2349
test('NoDuplicateExportedPortsRule: should not return errors when same ports have different protocols', (t: ExecutionContext) => {
const rule = new NoDuplicateExportedPortsRule();
const context: LintContext = {
path: filePath,
content: parseDocument(yamlWithDifferentProtocols).toJS() as Record<string, unknown>,
sourceCode: yamlWithDifferentProtocols,
};

const errors = rule.check(context);
t.is(errors.length, 0, 'There should be no errors when ports have different protocols.');
});
116 changes: 41 additions & 75 deletions tests/util/service-ports-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,53 @@ import test from 'ava';
import type { ExecutionContext } from 'ava';
import { Scalar, YAMLMap } from 'yaml';
import {
extractPublishedPortValue,
extractPublishedPortValueWithProtocol,
extractPublishedPortInterfaceValue,
parsePortsRange,
} from '../../src/util/service-ports-parser';

// @ts-ignore TS2349
test('extractPublishedPortValue should return port from scalar value with no IP', (t: ExecutionContext) => {
test('extractPublishedPortValueWithProtocol should return port and default protocol from scalar value with no IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('8080:9000');
const result = extractPublishedPortValue(scalarNode);
t.is(result, '8080');
const result = extractPublishedPortValueWithProtocol(scalarNode);
t.deepEqual(result, { port: '8080', protocol: 'tcp' });
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return correct port from scalar value with IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('127.0.0.1:3000');
const result = extractPublishedPortValue(scalarNode);
t.is(result, '3000');
test('extractPublishedPortValueWithProtocol should return correct port and protocol from scalar value with IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('127.0.0.1:3000/udp');
const result = extractPublishedPortValueWithProtocol(scalarNode);
t.deepEqual(result, { port: '3000', protocol: 'udp' });
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return correct port from scalar value with IPv6', (t: ExecutionContext) => {
const scalarNode = new Scalar('[::1]:3000');
const result = extractPublishedPortValue(scalarNode);
t.is(result, '3000');
test('extractPublishedPortValueWithProtocol should return correct port and protocol from scalar value with IPv6', (t: ExecutionContext) => {
const scalarNode = new Scalar('[::1]:3000/tcp');
const result = extractPublishedPortValueWithProtocol(scalarNode);
t.deepEqual(result, { port: '3000', protocol: 'tcp' });
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return published port from map node', (t: ExecutionContext) => {
test('extractPublishedPortValueWithProtocol should return published port and default protocol from map node', (t: ExecutionContext) => {
const mapNode = new YAMLMap();
mapNode.set('published', '8080');
const result = extractPublishedPortValue(mapNode);
t.is(result, '8080');
const result = extractPublishedPortValueWithProtocol(mapNode);
t.deepEqual(result, { port: '8080', protocol: 'tcp' });
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return empty string for unknown node type', (t: ExecutionContext) => {
const result = extractPublishedPortValue({});
t.is(result, '');
test('extractPublishedPortValueWithProtocol should return published port and specified protocol from map node', (t: ExecutionContext) => {
const mapNode = new YAMLMap();
mapNode.set('published', '8080');
mapNode.set('protocol', 'udp');
const result = extractPublishedPortValueWithProtocol(mapNode);
t.deepEqual(result, { port: '8080', protocol: 'udp' });
});

// @ts-ignore TS2349
test('extractPublishedPortValueWithProtocol should return empty values for unknown node type', (t: ExecutionContext) => {
const result = extractPublishedPortValueWithProtocol({});
t.deepEqual(result, { port: '', protocol: 'tcp' });
});

// @ts-ignore TS2349
Expand All @@ -49,85 +58,42 @@ test('parsePortsRange should return array of ports for a range', (t: ExecutionCo
});

// @ts-ignore TS2349
test('parsePortsRange should return empty string from map node', (t: ExecutionContext) => {
const mapNode = new YAMLMap();
const result = extractPublishedPortValue(mapNode);
t.is(result, '');
test('parsePortsRange should return empty array for invalid range', (t: ExecutionContext) => {
t.deepEqual(parsePortsRange('$TEST'), []);
t.deepEqual(parsePortsRange('$TEST-3002'), []);
t.deepEqual(parsePortsRange('3000-$TEST'), []);
t.deepEqual(parsePortsRange('$TEST-$TEST'), []);
t.deepEqual(parsePortsRange('3000-$TEST-$TEST-5000'), []);
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar without IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('8080:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '');
test('parsePortsRange should return empty array when start port is greater than end port', (t: ExecutionContext) => {
t.deepEqual(parsePortsRange('3005-3002'), []);
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar without IP and automapped port', (t: ExecutionContext) => {
const scalarNode = new Scalar('8080');
test('extractPublishedPortInterfaceValue should return listen IP for scalar without IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('8080:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '');
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar on 127.0.0.1 with automapped port', (t: ExecutionContext) => {
test('extractPublishedPortInterfaceValue should return correct IP for scalar with IP', (t: ExecutionContext) => {
const scalarNode = new Scalar('127.0.0.1:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '127.0.0.1');
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar on 0.0.0.0 with automapped port', (t: ExecutionContext) => {
const scalarNode = new Scalar('0.0.0.0:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '0.0.0.0');
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar on ::1 with automapped port', (t: ExecutionContext) => {
test('extractPublishedPortInterfaceValue should return correct IP for scalar with IPv6', (t: ExecutionContext) => {
const scalarNode = new Scalar('[::1]:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '::1');
});

// @ts-ignore TS2349
test('extractPublishedPortInterfaceValue should return listen ip string for scalar on ::1 without automated port', (t: ExecutionContext) => {
const scalarNode = new Scalar('[::1]:8080:8080');
const result = extractPublishedPortInterfaceValue(scalarNode);
t.is(result, '::1');
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return host_ip from map node', (t: ExecutionContext) => {
const mapNode = new YAMLMap();
mapNode.set('host_ip', '0.0.0.0');
const result = extractPublishedPortInterfaceValue(mapNode);
t.is(result, '0.0.0.0');
});

// @ts-ignore TS2349
test('extractPublishedPortValue should return empty string from map node', (t: ExecutionContext) => {
const mapNode = new YAMLMap();
const result = extractPublishedPortInterfaceValue(mapNode);
test('extractPublishedPortInterfaceValue should return empty string for unknown node type', (t: ExecutionContext) => {
const result = extractPublishedPortInterfaceValue({});
t.is(result, '');
});

// @ts-ignore TS2349
test('parsePortsRange should return single port when no range is specified', (t: ExecutionContext) => {
const result = parsePortsRange('8080');
t.deepEqual(result, ['8080']);
});

// @ts-ignore TS2349
test('parsePortsRange should return empty array for invalid range', (t: ExecutionContext) => {
t.deepEqual(parsePortsRange('$TEST'), []);
t.deepEqual(parsePortsRange('$TEST-3002'), []);
t.deepEqual(parsePortsRange('3000-$TEST'), []);
t.deepEqual(parsePortsRange('$TEST-$TEST'), []);
t.deepEqual(parsePortsRange('3000-$TEST-$TEST-5000'), []);
});

// @ts-ignore TS2349
test('parsePortsRange should return empty array when start port is greater than end port', (t: ExecutionContext) => {
t.deepEqual(parsePortsRange('3005-3002'), []);
});

0 comments on commit 7063c56

Please sign in to comment.