Compare commits

..

No commits in common. "master" and "v16.12.8" have entirely different histories.

17 changed files with 103 additions and 393 deletions

View File

@ -1,97 +1,3 @@
- commits:
- subject: Fix search for app leftover locks
hash: d475b1d8301c83b932ce272d3496bf4aac0ef1ad
body: |
The leftover locks search was creating an array rather than an object
keyed by the appId. This could affect the lock cleanup and make leftover
locks from one app affect the install of the app in local mode.
footer:
Change-type: patch
change-type: patch
author: Felipe Lalanne
nested: []
version: 17.0.2
title: ""
date: 2025-04-02T20:16:09.754Z
- commits:
- subject: Clarify firewall docs on behavior with host network containers
hash: caed4dcca0043f848f6dd5a3d1a2f82a2466e8d6
body: ""
footer:
Change-type: patch
change-type: patch
Signed-off-by: Christina Ying Wang <christina@balena.io>
signed-off-by: Christina Ying Wang <christina@balena.io>
author: Christina Ying Wang
nested: []
version: 17.0.1
title: ""
date: 2025-03-25T20:41:20.141Z
- commits:
- subject: Add Docker network label if custom ipam config
hash: b596c77ce2d229e79082cbb1f0022f93806f09ae
body: >
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.
footer:
Closes: "#2251"
closes: "#2251"
Change-type: major
change-type: major
See: https://balena.fibery.io/Work/Project/Fix-Supervisor-not-recreating-network-when-passed-custom-ipam-config-1127
see: https://balena.fibery.io/Work/Project/Fix-Supervisor-not-recreating-network-when-passed-custom-ipam-config-1127
Signed-off-by: Christina Ying Wang <christina@balena.io>
signed-off-by: Christina Ying Wang <christina@balena.io>
author: Christina Ying Wang
nested: []
version: 17.0.0
title: ""
date: 2025-03-24T22:18:08.753Z
- commits:
- subject: Start a dependent if all dependencies are started
hash: 7764f98c9d357a1942628e57951266767555f67b
body: |
The previous behavior required that dependencies were running beefore
starting the dependent service. This made it that services dependent on
a one-shot service would not get started and goes against the default
docker behavior.
Depending on a service to be running will require the implementation of
[long syntax depends_on](https://docs.docker.com/reference/compose-file/services/#long-syntax-1) and the condition
`service_healthy`.
footer:
Change-type: patch
change-type: patch
Closes: "#2409"
closes: "#2409"
author: Felipe Lalanne
nested: []
version: 16.12.9
title: ""
date: 2025-03-20T18:43:06.085Z
- commits:
- subject: Remove GOT retries on state poll
hash: ae337a1dd7743b0ee0a05c32a5ce01965c5bafef

View File

@ -4,26 +4,6 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
# v17.0.2
## (2025-04-02)
* Fix search for app leftover locks [Felipe Lalanne]
# v17.0.1
## (2025-03-25)
* Clarify firewall docs on behavior with host network containers [Christina Ying Wang]
# v17.0.0
## (2025-03-24)
* Add Docker network label if custom ipam config [Christina Ying Wang]
# v16.12.9
## (2025-03-20)
* Start a dependent if all dependencies are started [Felipe Lalanne]
# v16.12.8
## (2025-03-12)

View File

@ -1 +1 @@
17.0.2
16.12.8

View File

@ -2,6 +2,6 @@ name: balena-supervisor
description: 'Balena Supervisor: balena''s agent on devices.'
joinable: false
type: sw.application
version: 17.0.2
version: 16.12.8
provides:
- slug: sw.compose.long-volume-syntax

View File

@ -8,10 +8,10 @@ To switch between firewall modes, the `HOST_FIREWALL_MODE` (with `BALENA_` or le
> [!NOTE] Configuration variables defined in the dashboard will not apply to devices in local mode.
| Mode | Description |
| ---- | ----------- |
| on | Only traffic for core services provided by balena are allowed. Any other ports, including those used by containers with host networking, are blocked unless explicitly configured. |
| off | All network traffic is allowed. |
| Mode | Description |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| on | Only traffic for core services provided by balena and containers on the host network are allowed. |
| off | All network traffic is allowed. |
| auto | If there _are_ host network services, behaves as if `FIREWALL_MODE` = `on`. If there _aren't_ host network services, behaves as if `FIREWALL_MODE` = `off`. |
## Issues

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "balena-supervisor",
"version": "17.0.2",
"version": "16.12.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "balena-supervisor",
"version": "17.0.2",
"version": "16.12.8",
"license": "Apache-2.0",
"dependencies": {
"@balena/systemd": "^0.5.0",

View File

@ -1,7 +1,7 @@
{
"name": "balena-supervisor",
"description": "This is balena's Supervisor, a program that runs on IoT devices and has the task of running user Apps (which are Docker containers), and updating them as the balena API informs it to.",
"version": "17.0.2",
"version": "16.12.8",
"license": "Apache-2.0",
"repository": {
"type": "git",
@ -137,6 +137,6 @@
"yargs": "^17.7.2"
},
"versionist": {
"publishedAt": "2025-04-02T20:16:10.284Z"
"publishedAt": "2025-03-12T14:50:33.763Z"
}
}

View File

@ -921,24 +921,19 @@ class AppImpl implements App {
volumePairs: Array<ChangingPair<Volume>>,
servicePairs: Array<ChangingPair<Service>>,
): boolean {
// Firstly we check if a dependency has already been started (this is
// Firstly we check if a dependency is not already running (this is
// different to a dependency which is in the servicePairs below, as these
// are services which are changing). We could have a dependency which is
// starting up, but is not yet running.
const depCreatedButNotStarted = _.some(this.services, (svc) => {
const depInstallingButNotRunning = _.some(this.services, (svc) => {
if (target.dependsOn?.includes(svc.serviceName)) {
if (
svc.status === 'Installing' ||
svc.startedAt == null ||
svc.createdAt == null ||
svc.startedAt < svc.createdAt
) {
if (!svc.config.running) {
return true;
}
}
});
if (depCreatedButNotStarted) {
if (depInstallingButNotRunning) {
return false;
}

View File

@ -187,12 +187,8 @@ export async function inferNextSteps(
const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10));
const targetAppIds = Object.keys(targetApps).map((i) => parseInt(i, 10));
const withLeftoverLocks = Object.fromEntries(
await Promise.all(
currentAppIds.map(
async (id) => [id, await hasLeftoverLocks(id)] as [number, boolean],
),
),
const withLeftoverLocks = await Promise.all(
currentAppIds.map((id) => hasLeftoverLocks(id)),
);
const bootTime = getBootTime();

View File

@ -160,15 +160,6 @@ 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.private.ipam.config'] = 'true';
}
return net;
}

View File

@ -61,7 +61,6 @@ class ServiceImpl implements Service {
public dockerImageId: string | null;
public status: ServiceStatus;
public createdAt: Date | null;
public startedAt: Date | null;
private static configArrayFields: ServiceConfigArrayField[] = [
'volumes',
@ -477,7 +476,6 @@ class ServiceImpl implements Service {
}
svc.createdAt = new Date(container.Created);
svc.startedAt = new Date(container.State.StartedAt);
svc.containerId = container.Id;
svc.exitErrorMessage = container.State.Error;

View File

@ -373,7 +373,6 @@ export interface Service {
// from docker
status: ServiceStatus;
createdAt: Date | null;
startedAt: Date | null;
hasNetwork(networkName: string): boolean;
hasVolume(volumeName: string): boolean;

View File

@ -1128,20 +1128,11 @@ describe('compose/application-manager', () => {
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
services: [
await createService(
{
image: 'dep-image',
serviceName: 'dep',
commit: 'new-release',
},
{
state: {
createdAt: new Date(Date.now() - 5 * 1000),
// Container was started 5 after creation
startedAt: new Date(),
},
},
),
await createService({
image: 'dep-image',
serviceName: 'dep',
commit: 'new-release',
}),
],
networks: [DEFAULT_NETWORK],
images: [

View File

@ -67,8 +67,6 @@ 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.private.ipam.config': 'true',
},
Options: {},
ConfigOnly: false,

View File

@ -260,33 +260,7 @@ describe('state engine', () => {
});
});
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: {},
},
};
it('updates an app with two services with a network change', async () => {
await setTargetState({
config: {},
apps: {
@ -294,10 +268,30 @@ describe('state engine', () => {
name: 'test-app',
commit: 'deadbeef',
releaseId: 1,
services,
networks: {
default: {},
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: {},
},
},
networks: {},
volumes: {},
},
},
@ -317,21 +311,6 @@ 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.private.ipam.config');
await setTargetState({
config: {},
apps: {
@ -339,7 +318,32 @@ describe('state engine', () => {
name: 'test-app',
commit: 'deadca1f',
releaseId: 2,
services,
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: {},
},
},
networks: {
default: {
driver: 'bridge',
@ -360,8 +364,8 @@ describe('state engine', () => {
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' },
{ Name: '/one_21_2_deadca1f', State: 'running' },
{ Name: '/two_22_2_deadca1f', State: 'running' },
]);
// Container ids must have changed
@ -369,145 +373,13 @@ describe('state engine', () => {
containerIds,
);
// Network should have custom ipam config
const customNet = await docker.getNetwork('123_default').inspect();
expect(customNet)
expect(await docker.getNetwork('123_default').inspect())
.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.private.ipam.config');
});
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',
ipam: {
config: [
{ gateway: '192.168.91.1', subnet: '192.168.91.0/24' },
],
driver: 'default',
},
},
},
volumes: {},
},
},
});
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.private.ipam.config');
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_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 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.private.ipam.config');
});
it('updates an app with two services with a network removal', async () => {

View File

@ -1458,14 +1458,7 @@ describe('compose/app', () => {
services: [
await createService(
{ appId: 1, serviceName: 'dep' },
{
state: {
containerId: 'dep-id',
createdAt: new Date(Date.now() - 5 * 1000),
// Container was started 5 after creation
startedAt: new Date(),
},
},
{ state: { containerId: 'dep-id' } },
),
],
networks: [DEFAULT_NETWORK],
@ -1482,7 +1475,7 @@ describe('compose/app', () => {
.that.deep.includes({ serviceName: 'main' });
});
it('should not start a container when it depends on a service that has not been started yet', async () => {
it('should not start a container when it depends on a service that is not running', async () => {
const current = createApp({
services: [
await createService(
@ -1542,14 +1535,7 @@ describe('compose/app', () => {
services: [
await createService(
{ appId: 1, serviceName: 'dep' },
{
state: {
containerId: 'dep-id',
createdAt: new Date(Date.now() - 5 * 1000),
// Container was started 5 after creation
startedAt: new Date(),
},
},
{ state: { containerId: 'dep-id' } },
),
],
networks: [DEFAULT_NETWORK],

View File

@ -183,8 +183,6 @@ 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.private.ipam.config': 'true',
});
expect(dockerConfig.Options).to.deep.equal({
@ -346,14 +344,12 @@ describe('compose/network', () => {
'io.resin.features.something': '123',
'io.balena.features.dummy': 'abc',
'io.balena.supervised': 'true',
'io.balena.private.ipam.config': '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.private.ipam.config': 'true',
});
});
});
@ -429,32 +425,34 @@ describe('compose/network', () => {
});
describe('comparing network configurations', () => {
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: {},
},
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: {},
},
);
const noCustomIpam = Network.fromComposeObject(
'default',
12345,
'deadbeef',
{},
);
});
expect(
network.isEqualConfig(
Network.fromComposeObject('default', 12345, 'deadbeef', {}),
),
).to.be.true;
expect(customIpam.isEqualConfig(noCustomIpam)).to.be.false;
// Only ignores ipam.config, not other ipam elements
expect(
network.isEqualConfig(
Network.fromComposeObject('default', 12345, 'deadbeef', {
ipam: { driver: 'aaa' },
}),
),
).to.be.false;
});
it('compares configurations recursively', () => {