Skip to content

Commit

Permalink
Add Docker network label if custom ipam config
Browse files Browse the repository at this point in the history
In a target release where the only change is the addition or removal
of a custom ipam config, the Supervisor does not recreate the network
due to ignoring ipam config differences when comparing current and target
network (in network.isEqualConfig). This commit implements the addition of
a network label if the target compose object includes a network with custom
ipam. With the label, the Supervisor will detect a difference between a
network with a custom ipam and a network without, without needing to compare
the ipam configs themselves.

This is a major change, as devices running networks with custom ipam configs
will have their networks recreated to add the network label.

Closes: #2251
Change-type: major
Signed-off-by: Christina Ying Wang <[email protected]>
  • Loading branch information
cywang117 committed Jan 29, 2025
1 parent 3fbd98e commit 102ab21
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 78 deletions.
9 changes: 9 additions & 0 deletions src/compose/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ class NetworkImpl implements Network {
configOnly: network.config_only || false,
};

// Add label if there's non-default ipam config
// e.g. explicitly defined subnet or gateway.
// When updating between a release where the ipam config
// changes, this label informs the Supervisor that
// there's an ipam diff that requires recreating the network.
if (net.config.ipam.config.length > 0) {
net.config.labels['io.balena.network.ipam'] = 'true';
}

return net;
}

Expand Down
2 changes: 2 additions & 0 deletions test/integration/compose/network.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ describe('compose/network: integration tests', () => {
Labels: {
'io.balena.supervised': 'true',
'io.balena.app-id': '12345',
// This label should be present as we've defined a custom ipam config
'io.balena.network.ipam': 'true',
},
Options: {},
ConfigOnly: false,
Expand Down
232 changes: 180 additions & 52 deletions test/integration/state-engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,38 +260,44 @@ describe('state engine', () => {
});
});

it('updates an app with two services with a network change', async () => {
it('updates an app with two services with a network change where the only change is a custom ipam config addition', async () => {
const services = {
'1': {
image: 'alpine:latest',
imageId: 11,
serviceName: 'one',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
},
'2': {
image: 'alpine:latest',
imageId: 12,
serviceName: 'two',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
},
};
await setTargetState({
config: {},
apps: {
'123': {
name: 'test-app',
commit: 'deadbeef',
releaseId: 1,
services: {
'1': {
image: 'alpine:latest',
imageId: 11,
serviceName: 'one',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
labels: {},
environment: {},
},
'2': {
image: 'alpine:latest',
imageId: 12,
serviceName: 'two',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
labels: {},
environment: {},
},
services,
networks: {
default: {},
},
networks: {},
volumes: {},
},
},
Expand All @@ -311,39 +317,109 @@ describe('state engine', () => {
]);
const containerIds = containers.map(({ Id }) => Id);

// Network should not have custom ipam config
const defaultNet = await docker.getNetwork('123_default').inspect();
expect(defaultNet)
.to.have.property('IPAM')
.to.not.deep.equal({
Config: [{ Gateway: '192.168.91.1', Subnet: '192.168.91.0/24' }],
Driver: 'default',
Options: {},
});

// Network should not have custom ipam label
expect(defaultNet)
.to.have.property('Labels')
.to.not.have.property('io.balena.network.ipam');

await setTargetState({
config: {},
apps: {
'123': {
name: 'test-app',
commit: 'deadca1f',
releaseId: 2,
services: {
'1': {
image: 'alpine:latest',
imageId: 21,
serviceName: 'one',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
},
'2': {
image: 'alpine:latest',
imageId: 22,
serviceName: 'two',
restart: 'unless-stopped',
running: true,
command: 'sh -c "echo two && sleep infinity"',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
services,
networks: {
default: {
driver: 'bridge',
ipam: {
config: [
{ gateway: '192.168.91.1', subnet: '192.168.91.0/24' },
],
driver: 'default',
},
},
},
volumes: {},
},
},
});

const updatedContainers = await docker.listContainers();
expect(
updatedContainers.map(({ Names, State }) => ({ Name: Names[0], State })),
).to.have.deep.members([
{ Name: '/one_11_2_deadca1f', State: 'running' },
{ Name: '/two_12_2_deadca1f', State: 'running' },
]);

// Container ids must have changed
expect(updatedContainers.map(({ Id }) => Id)).to.not.have.members(
containerIds,
);

// Network should have custom ipam config
const customNet = await docker.getNetwork('123_default').inspect();
expect(customNet)
.to.have.property('IPAM')
.to.deep.equal({
Config: [{ Gateway: '192.168.91.1', Subnet: '192.168.91.0/24' }],
Driver: 'default',
Options: {},
});

// Network should have custom ipam label
expect(customNet)
.to.have.property('Labels')
.to.have.property('io.balena.network.ipam');
});

it('updates an app with two services with a network change where the only change is a custom ipam config removal', async () => {
const services = {
'1': {
image: 'alpine:latest',
imageId: 11,
serviceName: 'one',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
},
'2': {
image: 'alpine:latest',
imageId: 12,
serviceName: 'two',
restart: 'unless-stopped',
running: true,
command: 'sleep infinity',
stop_signal: 'SIGKILL',
networks: ['default'],
labels: {},
environment: {},
},
};
await setTargetState({
config: {},
apps: {
'123': {
name: 'test-app',
commit: 'deadbeef',
releaseId: 1,
services,
networks: {
default: {
driver: 'bridge',
Expand All @@ -360,26 +436,78 @@ describe('state engine', () => {
},
});

const state = await getCurrentState();
expect(
state.apps['123'].services.map((s: any) => s.serviceName),
).to.deep.equal(['one', 'two']);

// Network should have custom ipam config
const customNet = await docker.getNetwork('123_default').inspect();
expect(customNet)
.to.have.property('IPAM')
.to.deep.equal({
Config: [{ Gateway: '192.168.91.1', Subnet: '192.168.91.0/24' }],
Driver: 'default',
Options: {},
});

// Network should have custom ipam label
expect(customNet)
.to.have.property('Labels')
.to.have.property('io.balena.network.ipam');

const containers = await docker.listContainers();
expect(
containers.map(({ Names, State }) => ({ Name: Names[0], State })),
).to.have.deep.members([
{ Name: '/one_11_1_deadbeef', State: 'running' },
{ Name: '/two_12_1_deadbeef', State: 'running' },
]);
const containerIds = containers.map(({ Id }) => Id);

await setTargetState({
config: {},
apps: {
'123': {
name: 'test-app',
commit: 'deadca1f',
releaseId: 2,
services,
networks: {
default: {},
},
volumes: {},
},
},
});

const updatedContainers = await docker.listContainers();
expect(
updatedContainers.map(({ Names, State }) => ({ Name: Names[0], State })),
).to.have.deep.members([
{ Name: '/one_21_2_deadca1f', State: 'running' },
{ Name: '/two_22_2_deadca1f', State: 'running' },
{ Name: '/one_11_2_deadca1f', State: 'running' },
{ Name: '/two_12_2_deadca1f', State: 'running' },
]);

// Container ids must have changed
expect(updatedContainers.map(({ Id }) => Id)).to.not.have.members(
containerIds,
);

expect(await docker.getNetwork('123_default').inspect())
// Network should not have custom ipam config
const defaultNet = await docker.getNetwork('123_default').inspect();
expect(defaultNet)
.to.have.property('IPAM')
.to.deep.equal({
.to.not.deep.equal({
Config: [{ Gateway: '192.168.91.1', Subnet: '192.168.91.0/24' }],
Driver: 'default',
Options: {},
});

// Network should not have custom ipam label
expect(defaultNet)
.to.have.property('Labels')
.to.not.have.property('io.balena.network.ipam');
});

it('updates an app with two services with a network removal', async () => {
Expand Down
54 changes: 28 additions & 26 deletions test/unit/compose/network.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ describe('compose/network', () => {
'io.balena.supervised': 'true',
'io.balena.app-id': '12345',
'com.docker.some-label': 'yes',
// This label should be present as we've defined a custom ipam config
'io.balena.network.ipam': 'true',
});

expect(dockerConfig.Options).to.deep.equal({
Expand Down Expand Up @@ -344,12 +346,14 @@ describe('compose/network', () => {
'io.resin.features.something': '123',
'io.balena.features.dummy': 'abc',
'io.balena.supervised': 'true',
'io.balena.network.ipam': 'true',
} as NetworkInspectInfo['Labels'],
} as NetworkInspectInfo);

expect(network.config.labels).to.deep.equal({
'io.balena.features.something': '123',
'io.balena.features.dummy': 'abc',
'io.balena.network.ipam': 'true',
});
});
});
Expand Down Expand Up @@ -425,34 +429,32 @@ describe('compose/network', () => {
});

describe('comparing network configurations', () => {
it('ignores IPAM configuration', () => {
const network = Network.fromComposeObject('default', 12345, 'deadbeef', {
ipam: {
driver: 'default',
config: [
{
subnet: '172.20.0.0/16',
ip_range: '172.20.10.0/24',
gateway: '172.20.0.1',
},
],
options: {},
it('distinguishes a network with custom ipam config from a network without', () => {
const customIpam = Network.fromComposeObject(
'default',
12345,
'deadbeef',
{
ipam: {
driver: 'default',
config: [
{
subnet: '172.20.0.0/16',
gateway: '172.20.0.1',
},
],
options: {},
},
},
});
expect(
network.isEqualConfig(
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
),
).to.be.true;
);
const noCustomIpam = Network.fromComposeObject(
'default',
12345,
'deadbeef',
{},
);

// Only ignores ipam.config, not other ipam elements
expect(
network.isEqualConfig(
Network.fromComposeObject('default', 12345, 'deadbeef', {
ipam: { driver: 'aaa' },
}),
),
).to.be.false;
expect(customIpam.isEqualConfig(noCustomIpam)).to.be.false;
});

it('compares configurations recursively', () => {
Expand Down

0 comments on commit 102ab21

Please sign in to comment.