Skip to content

Commit 90e1813

Browse files
committed
feat(new tool): Docker Labels Pangolin Converter
Fix #311
1 parent 98b98a7 commit 90e1813

4 files changed

Lines changed: 531 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { describe, expect, it } from 'vitest';
2+
import yaml from 'yaml';
3+
import { blueprintToLabels, extractPangolinLabelsFromCompose, pangolinLabelsToBlueprint } from './docker-pangolin-labels.service';
4+
5+
const composeYml = `
6+
services:
7+
newt:
8+
image: fosrl/newt
9+
container_name: newt
10+
restart: unless-stopped
11+
volumes:
12+
- /var/run/docker.sock:/var/run/docker.sock
13+
environment:
14+
- PANGOLIN_ENDPOINT=https://app.pangolin.net
15+
- NEWT_ID=h1rbsgku89wf9z3
16+
- NEWT_SECRET=z7g54mbcwkglpx1aau9gb8mzcccoof2fdbs97keoakg2pp5z
17+
- DOCKER_SOCKET=/var/run/docker.sock
18+
19+
nginx1:
20+
image: nginxdemos/hello
21+
container_name: nginx1
22+
labels:
23+
# Public Resource Configuration
24+
- pangolin.public-resources.nginx.name=nginx
25+
- pangolin.public-resources.nginx.full-domain=nginx.fosrl.io
26+
- pangolin.public-resources.nginx.protocol=http
27+
- pangolin.public-resources.nginx.headers[0].name=X-Example-Header
28+
- pangolin.public-resources.nginx.headers[0].value=example-value
29+
# Target Configuration - the port and hostname will be auto-detected
30+
- pangolin.public-resources.nginx.targets[0].method=http
31+
- pangolin.public-resources.nginx.targets[0].path=/path
32+
- pangolin.public-resources.nginx.targets[0].path-match=prefix
33+
34+
nginx2:
35+
image: nginxdemos/hello
36+
container_name: nginx2
37+
labels:
38+
# Additional target for the same resource where the port and hostname are explicit
39+
pangolin.public-resources.nginx.targets[1].method: http
40+
pangolin.public-resources.nginx.targets[1].hostname: nginx2
41+
pangolin.public-resources.nginx.targets[1].port: 80
42+
43+
networks:
44+
default:
45+
name: pangolin_default`;
46+
47+
describe('Pangolin Compose Extraction', () => {
48+
it('extracts Pangolin labels from full compose', () => {
49+
const compose = yaml.parse(composeYml);
50+
const labels = extractPangolinLabelsFromCompose(compose);
51+
52+
expect(labels).to.deep.eq({
53+
'pangolin.public-resources.nginx.full-domain': 'nginx.fosrl.io',
54+
'pangolin.public-resources.nginx.headers[0].name': 'X-Example-Header',
55+
'pangolin.public-resources.nginx.headers[0].value': 'example-value',
56+
'pangolin.public-resources.nginx.name': 'nginx',
57+
'pangolin.public-resources.nginx.protocol': 'http',
58+
'pangolin.public-resources.nginx.targets[0].method': 'http',
59+
'pangolin.public-resources.nginx.targets[0].path': '/path',
60+
'pangolin.public-resources.nginx.targets[0].path-match': 'prefix',
61+
'pangolin.public-resources.nginx.targets[1].hostname': 'nginx2',
62+
'pangolin.public-resources.nginx.targets[1].method': 'http',
63+
'pangolin.public-resources.nginx.targets[1].port': '80',
64+
});
65+
});
66+
67+
it('parses extracted labels into blueprint', () => {
68+
const compose = yaml.parse(composeYml);
69+
const labels = extractPangolinLabelsFromCompose(compose);
70+
const blueprint = pangolinLabelsToBlueprint(labels);
71+
72+
expect(blueprint).to.deep.eq({
73+
'public-resources': {
74+
nginx: {
75+
'full-domain': 'nginx.fosrl.io',
76+
'headers': [
77+
{
78+
name: 'X-Example-Header',
79+
value: 'example-value',
80+
},
81+
],
82+
'name': 'nginx',
83+
'protocol': 'http',
84+
'targets': [
85+
{
86+
'method': 'http',
87+
'path': '/path',
88+
'path-match': 'prefix',
89+
},
90+
{
91+
hostname: 'nginx2',
92+
method: 'http',
93+
port: 80,
94+
},
95+
],
96+
},
97+
},
98+
});
99+
});
100+
});
101+
102+
// ---------------------------------------------------------
103+
// Forward Mapping Tests
104+
// ---------------------------------------------------------
105+
106+
const blueprintYml = `
107+
public-resources:
108+
resource-nice-id-uno:
109+
name: this is a http resource
110+
protocol: http
111+
full-domain: uno.example.com
112+
host-header: example.com
113+
tls-server-name: example.com
114+
headers:
115+
- name: X-Example-Header
116+
value: example-value
117+
- name: X-Another-Header
118+
value: another-value
119+
rules:
120+
- action: allow
121+
match: ip
122+
value: 1.1.1.1
123+
priority: 1
124+
- action: deny
125+
match: cidr
126+
value: 2.2.2.2/32
127+
priority: 2
128+
- action: allow
129+
match: asn
130+
value: AS13335
131+
priority: 3
132+
- action: pass
133+
match: path
134+
value: /admin
135+
targets:
136+
- site: lively-yosemite-toad
137+
hostname: localhost
138+
method: http
139+
port: 8000
140+
- site: slim-alpine-chipmunk
141+
hostname: localhost
142+
path: /admin
143+
path-match: exact
144+
method: https
145+
port: 8001
146+
resource-nice-id-dos:
147+
name: this is a raw resource
148+
protocol: tcp
149+
proxy-port: 3000
150+
targets:
151+
- site: lively-yosemite-toad
152+
hostname: localhost
153+
port: 3000`;
154+
155+
describe('Forward Mapping (blueprint → labels)', () => {
156+
it('blueprintToLabels should produce array', () => {
157+
expect(blueprintToLabels(yaml.parse(blueprintYml), 'array')).to.deep.eq([
158+
'pangolin.public-resources.resource-nice-id-uno.name=this is a http resource',
159+
'pangolin.public-resources.resource-nice-id-uno.protocol=http',
160+
'pangolin.public-resources.resource-nice-id-uno.full-domain=uno.example.com',
161+
'pangolin.public-resources.resource-nice-id-uno.host-header=example.com',
162+
'pangolin.public-resources.resource-nice-id-uno.tls-server-name=example.com',
163+
'pangolin.public-resources.resource-nice-id-uno.headers[0].name=X-Example-Header',
164+
'pangolin.public-resources.resource-nice-id-uno.headers[0].value=example-value',
165+
'pangolin.public-resources.resource-nice-id-uno.headers[1].name=X-Another-Header',
166+
'pangolin.public-resources.resource-nice-id-uno.headers[1].value=another-value',
167+
'pangolin.public-resources.resource-nice-id-uno.rules[0].action=allow',
168+
'pangolin.public-resources.resource-nice-id-uno.rules[0].match=ip',
169+
'pangolin.public-resources.resource-nice-id-uno.rules[0].value=1.1.1.1',
170+
'pangolin.public-resources.resource-nice-id-uno.rules[0].priority=1',
171+
'pangolin.public-resources.resource-nice-id-uno.rules[1].action=deny',
172+
'pangolin.public-resources.resource-nice-id-uno.rules[1].match=cidr',
173+
'pangolin.public-resources.resource-nice-id-uno.rules[1].value=2.2.2.2/32',
174+
'pangolin.public-resources.resource-nice-id-uno.rules[1].priority=2',
175+
'pangolin.public-resources.resource-nice-id-uno.rules[2].action=allow',
176+
'pangolin.public-resources.resource-nice-id-uno.rules[2].match=asn',
177+
'pangolin.public-resources.resource-nice-id-uno.rules[2].value=AS13335',
178+
'pangolin.public-resources.resource-nice-id-uno.rules[2].priority=3',
179+
'pangolin.public-resources.resource-nice-id-uno.rules[3].action=pass',
180+
'pangolin.public-resources.resource-nice-id-uno.rules[3].match=path',
181+
'pangolin.public-resources.resource-nice-id-uno.rules[3].value=/admin',
182+
'pangolin.public-resources.resource-nice-id-uno.targets[0].site=lively-yosemite-toad',
183+
'pangolin.public-resources.resource-nice-id-uno.targets[0].hostname=localhost',
184+
'pangolin.public-resources.resource-nice-id-uno.targets[0].method=http',
185+
'pangolin.public-resources.resource-nice-id-uno.targets[0].port=8000',
186+
'pangolin.public-resources.resource-nice-id-uno.targets[1].site=slim-alpine-chipmunk',
187+
'pangolin.public-resources.resource-nice-id-uno.targets[1].hostname=localhost',
188+
'pangolin.public-resources.resource-nice-id-uno.targets[1].path=/admin',
189+
'pangolin.public-resources.resource-nice-id-uno.targets[1].path-match=exact',
190+
'pangolin.public-resources.resource-nice-id-uno.targets[1].method=https',
191+
'pangolin.public-resources.resource-nice-id-uno.targets[1].port=8001',
192+
'pangolin.public-resources.resource-nice-id-dos.name=this is a raw resource',
193+
'pangolin.public-resources.resource-nice-id-dos.protocol=tcp',
194+
'pangolin.public-resources.resource-nice-id-dos.proxy-port=3000',
195+
'pangolin.public-resources.resource-nice-id-dos.targets[0].site=lively-yosemite-toad',
196+
'pangolin.public-resources.resource-nice-id-dos.targets[0].hostname=localhost',
197+
'pangolin.public-resources.resource-nice-id-dos.targets[0].port=3000',
198+
]);
199+
});
200+
it('blueprintToLabels should produce object', () => {
201+
expect(blueprintToLabels(yaml.parse(blueprintYml), 'object')).to.deep.eq({
202+
'pangolin.public-resources.resource-nice-id-dos.name': 'this is a raw resource',
203+
'pangolin.public-resources.resource-nice-id-dos.protocol': 'tcp',
204+
'pangolin.public-resources.resource-nice-id-dos.proxy-port': '3000',
205+
'pangolin.public-resources.resource-nice-id-dos.targets[0].hostname': 'localhost',
206+
'pangolin.public-resources.resource-nice-id-dos.targets[0].port': '3000',
207+
'pangolin.public-resources.resource-nice-id-dos.targets[0].site': 'lively-yosemite-toad',
208+
'pangolin.public-resources.resource-nice-id-uno.full-domain': 'uno.example.com',
209+
'pangolin.public-resources.resource-nice-id-uno.headers[0].name': 'X-Example-Header',
210+
'pangolin.public-resources.resource-nice-id-uno.headers[0].value': 'example-value',
211+
'pangolin.public-resources.resource-nice-id-uno.headers[1].name': 'X-Another-Header',
212+
'pangolin.public-resources.resource-nice-id-uno.headers[1].value': 'another-value',
213+
'pangolin.public-resources.resource-nice-id-uno.host-header': 'example.com',
214+
'pangolin.public-resources.resource-nice-id-uno.name': 'this is a http resource',
215+
'pangolin.public-resources.resource-nice-id-uno.protocol': 'http',
216+
'pangolin.public-resources.resource-nice-id-uno.rules[0].action': 'allow',
217+
'pangolin.public-resources.resource-nice-id-uno.rules[0].match': 'ip',
218+
'pangolin.public-resources.resource-nice-id-uno.rules[0].priority': '1',
219+
'pangolin.public-resources.resource-nice-id-uno.rules[0].value': '1.1.1.1',
220+
'pangolin.public-resources.resource-nice-id-uno.rules[1].action': 'deny',
221+
'pangolin.public-resources.resource-nice-id-uno.rules[1].match': 'cidr',
222+
'pangolin.public-resources.resource-nice-id-uno.rules[1].priority': '2',
223+
'pangolin.public-resources.resource-nice-id-uno.rules[1].value': '2.2.2.2/32',
224+
'pangolin.public-resources.resource-nice-id-uno.rules[2].action': 'allow',
225+
'pangolin.public-resources.resource-nice-id-uno.rules[2].match': 'asn',
226+
'pangolin.public-resources.resource-nice-id-uno.rules[2].priority': '3',
227+
'pangolin.public-resources.resource-nice-id-uno.rules[2].value': 'AS13335',
228+
'pangolin.public-resources.resource-nice-id-uno.rules[3].action': 'pass',
229+
'pangolin.public-resources.resource-nice-id-uno.rules[3].match': 'path',
230+
'pangolin.public-resources.resource-nice-id-uno.rules[3].value': '/admin',
231+
'pangolin.public-resources.resource-nice-id-uno.targets[0].hostname': 'localhost',
232+
'pangolin.public-resources.resource-nice-id-uno.targets[0].method': 'http',
233+
'pangolin.public-resources.resource-nice-id-uno.targets[0].port': '8000',
234+
'pangolin.public-resources.resource-nice-id-uno.targets[0].site': 'lively-yosemite-toad',
235+
'pangolin.public-resources.resource-nice-id-uno.targets[1].hostname': 'localhost',
236+
'pangolin.public-resources.resource-nice-id-uno.targets[1].method': 'https',
237+
'pangolin.public-resources.resource-nice-id-uno.targets[1].path': '/admin',
238+
'pangolin.public-resources.resource-nice-id-uno.targets[1].path-match': 'exact',
239+
'pangolin.public-resources.resource-nice-id-uno.targets[1].port': '8001',
240+
'pangolin.public-resources.resource-nice-id-uno.targets[1].site': 'slim-alpine-chipmunk',
241+
'pangolin.public-resources.resource-nice-id-uno.tls-server-name': 'example.com',
242+
});
243+
});
244+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
export function extractPangolinLabelsFromCompose(compose: any) {
2+
const collected: Record<string, string> = {};
3+
4+
for (const [_serviceName, svc] of Object.entries<any>(compose.services ?? {})) {
5+
const labels = svc.labels ?? [];
6+
7+
if (Array.isArray(labels)) {
8+
for (const entry of labels) {
9+
if (typeof entry === 'string') {
10+
const i = entry.indexOf('=');
11+
if (i === -1) {
12+
continue;
13+
}
14+
const key = entry.slice(0, i);
15+
const value = entry.slice(i + 1);
16+
17+
if (key.startsWith('pangolin.')) {
18+
collected[key] = value;
19+
}
20+
}
21+
}
22+
}
23+
else {
24+
for (const [key, value] of Object.entries(labels)) {
25+
if (key.startsWith('pangolin.')) {
26+
collected[key] = String(value);
27+
}
28+
}
29+
}
30+
}
31+
32+
return collected;
33+
}
34+
35+
export function pangolinLabelsToBlueprint(labels: Record<string, string>) {
36+
const resources: Record<string, any> = {};
37+
38+
for (const [key, rawValue] of Object.entries(labels)) {
39+
if (!key.startsWith('pangolin.')) {
40+
continue;
41+
}
42+
43+
const rest = key.slice('pangolin.'.length);
44+
const [resourceName, ...pathParts] = rest.split('.');
45+
const path = pathParts.join('.');
46+
47+
if (!resources[resourceName]) {
48+
resources[resourceName] = {};
49+
}
50+
51+
assignNested(resources[resourceName], path, coerce(rawValue));
52+
}
53+
54+
return resources;
55+
}
56+
57+
function assignNested(target: any, path: string, value: any) {
58+
if (!path) {
59+
return;
60+
}
61+
62+
// Convert "a.b[0].c" → ["a", "b", "0", "c"]
63+
const segments = path
64+
.replace(/\]/g, '')
65+
.split(/\.|\[/)
66+
.filter(Boolean);
67+
68+
let current = target;
69+
70+
for (let i = 0; i < segments.length; i++) {
71+
const key = segments[i];
72+
const isLast = i === segments.length - 1;
73+
const next = segments[i + 1];
74+
75+
if (isLast) {
76+
// Final assignment
77+
current[key] = value;
78+
return;
79+
}
80+
81+
// Determine whether next segment is array index
82+
const shouldBeArray = /^\d+$/.test(next);
83+
84+
// Create container if missing
85+
if (current[key] === undefined) {
86+
current[key] = shouldBeArray ? [] : {};
87+
}
88+
89+
// If wrong type, fix it
90+
if (shouldBeArray && !Array.isArray(current[key])) {
91+
current[key] = [];
92+
}
93+
else if (!shouldBeArray && typeof current[key] !== 'object') {
94+
current[key] = {};
95+
}
96+
97+
current = current[key];
98+
}
99+
}
100+
101+
function coerce(value: string) {
102+
if (value === 'true') {
103+
return true;
104+
}
105+
if (value === 'false') {
106+
return false;
107+
}
108+
if (!Number.isNaN(Number(value))) {
109+
return Number(value);
110+
}
111+
return value;
112+
}
113+
114+
export function blueprintToLabels(resources: Record<string, any>, labelsType: 'array' | 'object') {
115+
const labels: Record<string, string> = {};
116+
117+
for (const [resourceName, config] of Object.entries(resources)) {
118+
flatten(config, `pangolin.${resourceName}`, labels);
119+
}
120+
121+
if (labelsType === 'object') {
122+
return labels;
123+
}
124+
125+
return Object.entries(labels).map(([k, v]) => `${k}=${v}`);
126+
}
127+
128+
function flatten(obj: any, prefix: string, out: Record<string, string>) {
129+
for (const [key, value] of Object.entries(obj)) {
130+
const path = `${prefix}.${key}`;
131+
132+
if (Array.isArray(value)) {
133+
value.forEach((item, i) => {
134+
flatten(item, `${path}[${i}]`, out);
135+
});
136+
}
137+
else if (value && typeof value === 'object') {
138+
flatten(value, path, out);
139+
}
140+
else {
141+
out[path] = String(value);
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)