balena-supervisor/test/36-compose-app.spec.ts

1096 lines
27 KiB
TypeScript
Raw Normal View History

import * as _ from 'lodash';
import { expect } from 'chai';
import * as appMock from './lib/application-state-mock';
import * as applicationManager from '../src/compose/application-manager';
import * as deviceState from '../src/device-state';
import App from '../src/compose/app';
import * as config from '../src/config';
import * as dbFormat from '../src/device-state/db-format';
import Service from '../src/compose/service';
import Network from '../src/compose/network';
import Volume from '../src/compose/volume';
import {
CompositionStep,
CompositionStepAction,
} from '../src/compose/composition-steps';
import { ServiceComposeConfig } from '../src/compose/types/service';
import { Image } from '../src/compose/images';
import { inspect } from 'util';
const defaultContext = {
localMode: false,
availableImages: [],
containerIds: {},
downloading: [],
};
function createApp(
services: Service[],
networks: Network[],
volumes: Volume[],
target: boolean,
appId = 1,
) {
return new App(
{
appId,
services,
networks: _.keyBy(networks, 'name'),
volumes: _.keyBy(volumes, 'name'),
},
target,
);
}
async function createService(
conf: Partial<ServiceComposeConfig>,
appId = 1,
serviceName = 'test',
releaseId = 2,
serviceId = 3,
imageId = 4,
extraState?: Partial<Service>,
) {
const svc = await Service.fromComposeObject(
{
appId,
serviceName,
releaseId,
serviceId,
imageId,
...conf,
},
{} as any,
);
if (extraState != null) {
for (const k of Object.keys(extraState)) {
(svc as any)[k] = (extraState as any)[k];
}
}
return svc;
}
type ServicePredicate = string | ((service: Partial<Service>) => boolean);
type StepTest = Chai.Assertion & {
asStep?: CompositionStep;
forCurrent: (predicate: ServicePredicate) => Chai.Assertion;
forTarget: (predicate: ServicePredicate) => Chai.Assertion;
};
// tslint:disable: no-unused-expression-chai
function withSteps(steps: CompositionStep[]) {
return {
expectStep: (action: CompositionStepAction): StepTest => {
const matchingSteps = _.filter(steps, (step) => step.action === action);
const assertion: Partial<StepTest> = expect(
matchingSteps,
`Step for '${action}', not found`,
);
const forService = (
predicate: ServicePredicate,
property: 'current' | 'target',
) => {
const [firstMatch] = _.filter(matchingSteps, (s) => {
const t = (s as any)[property];
if (!t) {
throw new Error(`${property} is not defined for action ${action}`);
}
if (_.isFunction(predicate)) {
return predicate(t);
} else {
return t.serviceName! === predicate;
}
});
return expect(
firstMatch,
`Step for '${action}' matching predicate, not found`,
);
};
assertion.asStep = matchingSteps[0];
assertion.forCurrent = (service) => forService(service, 'current');
assertion.forTarget = (service) => forService(service, 'target');
return assertion as StepTest;
},
rejectStep: (action: CompositionStepAction) => expectNoStep(action, steps),
};
}
function expectStep(
action: CompositionStepAction,
steps: CompositionStep[],
): number {
const idx = _.findIndex(steps, { action });
if (idx === -1) {
console.log(inspect({ action, steps }, true, 3, true));
throw new Error(`Expected to find step with action: ${action}`);
}
return idx;
}
function expectNoStep(action: CompositionStepAction, steps: CompositionStep[]) {
if (_.some(steps, { action })) {
console.log(inspect({ action, steps }, true, 3, true));
throw new Error(`Did not expect to find step with action: ${action}`);
}
}
const defaultNetwork = Network.fromComposeObject('default', 1, {});
describe('compose/app', () => {
before(async () => {
await config.initialized;
await applicationManager.initialized;
});
beforeEach(() => {
// Sane defaults
appMock.mockSupervisorNetwork(true);
appMock.mockManagers([], [], []);
appMock.mockImages([], false, []);
});
afterEach(() => {
appMock.unmockAll();
});
it('should correctly infer a volume create step', () => {
const current = createApp([], [], [], false);
const target = createApp(
[],
[],
[Volume.fromComposeObject('test-volume', 1, {})],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('createVolume', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('name')
.that.equals('test-volume');
});
it('should correctly infer more than one volume create step', () => {
const current = createApp([], [], [], false);
const target = createApp(
[],
[],
[
Volume.fromComposeObject('test-volume', 1, {}),
Volume.fromComposeObject('test-volume-2', 1, {}),
],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
let idx = expectStep('createVolume', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('name')
.that.equals('test-volume');
delete steps[idx];
idx = expectStep('createVolume', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('name')
.that.equals('test-volume-2');
});
// We don't remove volumes until the end
it('should correctly not infer a volume remove step when the app is still referenced', () => {
const current = createApp(
[],
[],
[
Volume.fromComposeObject('test-volume', 1, {}),
Volume.fromComposeObject('test-volume-2', 1, {}),
],
false,
);
const target = createApp(
[],
[],
[Volume.fromComposeObject('test-volume-2', 1, {})],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expect(() => {
expectStep('removeVolume', steps);
}).to.throw();
});
it('should correctly infer volume recreation steps', () => {
const current = createApp(
[],
[],
[Volume.fromComposeObject('test-volume', 1, {})],
false,
);
const target = createApp(
[],
[],
[
Volume.fromComposeObject('test-volume', 1, {
labels: { test: 'test' },
}),
],
true,
);
let steps = current.nextStepsForAppUpdate(defaultContext, target);
let idx = expectStep('removeVolume', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('config')
.that.has.property('labels')
.that.deep.equals({ 'io.balena.supervised': 'true' });
current.volumes = {};
steps = current.nextStepsForAppUpdate(defaultContext, target);
idx = expectStep('createVolume', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('config')
.that.has.property('labels')
.that.deep.equals({ 'io.balena.supervised': 'true', test: 'test' });
});
it('should kill dependencies of a volume before changing config', async () => {
const current = createApp(
[await createService({ volumes: ['test-volume'] })],
[],
[Volume.fromComposeObject('test-volume', 1, {})],
false,
);
const target = createApp(
[await createService({ volumes: ['test-volume'] })],
[],
[
Volume.fromComposeObject('test-volume', 1, {
labels: { test: 'test' },
}),
],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('serviceName')
.that.equals('test');
});
it('should correctly infer to remove an apps volumes when it is no longer referenced', async () => {
appMock.mockManagers(
[],
[Volume.fromComposeObject('test-volume', 1, {})],
[],
);
appMock.mockImages([], false, []);
const origFn = dbFormat.getApps;
// @ts-expect-error Assigning to a RO property
dbFormat.getApps = () => Promise.resolve({});
const target = await deviceState.getTarget();
try {
const steps = await applicationManager.getRequiredSteps(
target.local.apps,
);
expect(steps).to.have.length(1);
expect(steps[0]).to.have.property('action').that.equals('removeVolume');
} finally {
// @ts-expect-error Assigning to a RO property
dbFormat.getApps = origFn;
}
});
it('should correctly infer a network create step', () => {
const current = createApp([], [], [], false);
const target = createApp(
[],
[Network.fromComposeObject('default', 1, {})],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('createNetwork', steps);
});
it('should correctly infer a network remove step', () => {
const current = createApp(
[],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp([], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('removeNetwork', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('name')
.that.equals('test-network');
});
it('should correctly infer a network recreation step', () => {
const current = createApp(
[],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp(
[],
[
Network.fromComposeObject('test-network', 1, {
labels: { TEST: 'TEST' },
}),
],
[],
true,
);
let steps = current.nextStepsForAppUpdate(defaultContext, target);
let idx = expectStep('removeNetwork', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('name')
.that.equals('test-network');
delete current.networks['test-network'];
steps = current.nextStepsForAppUpdate(defaultContext, target);
idx = expectStep('createNetwork', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('name')
.that.equals('test-network');
});
it('should kill dependencies of networks before removing', async () => {
const current = createApp(
[await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp([await createService({})], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('serviceName')
.that.equals('test');
});
it('should kill dependencies of networks before changing config', async () => {
const current = createApp(
[await createService({ networks: { 'test-network': {} } })],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp(
[await createService({ networks: { 'test-network': {} } })],
[
Network.fromComposeObject('test-network', 1, {
labels: { test: 'test' },
}),
],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('serviceName')
.that.equals('test');
// We shouldn't try to remove the network until we have gotten rid of the dependencies
expect(() => expectStep('removeNetwork', steps)).to.throw();
});
it('should not output a kill step for a service which is already stopping when changing a volume', async () => {
const service = await createService({ volumes: ['test-volume'] });
service.status = 'Stopping';
const current = createApp(
[service],
[],
[Volume.fromComposeObject('test-volume', 1, {})],
false,
);
const target = createApp(
[service],
[],
[
Volume.fromComposeObject('test-volume', 1, {
labels: { test: 'test' },
}),
],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expect(() => expectStep('kill', steps)).to.throw();
});
it('should create the default network if it does not exist', () => {
const current = createApp([], [], [], false);
const target = createApp([], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('createNetwork', steps);
expect(steps[idx])
.to.have.property('target')
.that.has.property('name')
.that.equals('default');
});
it('should create a kill step for service which is no longer referenced', async () => {
const current = createApp(
[
await createService({}, 1, 'main', 1, 1),
await createService({}, 1, 'aux', 1, 2),
],
[Network.fromComposeObject('test-network', 1, {})],
[],
false,
);
const target = createApp(
[await createService({}, 1, 'main', 2, 1)],
[Network.fromComposeObject('test-network', 1, {})],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const idx = expectStep('kill', steps);
expect(steps[idx])
.to.have.property('current')
.that.has.property('serviceName')
.that.equals('aux');
});
it('should emit a noop when a service which is no longer referenced is already stopping', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Stopping' })],
[],
[],
false,
);
const target = createApp([], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('noop', steps);
});
it('should remove a dead container that is still referenced in the target state', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[],
[],
false,
);
const target = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('remove', steps);
});
it('should remove a dead container that is not referenced in the target state', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1, { status: 'Dead' })],
[],
[],
false,
);
const target = createApp([], [], [], true);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('remove', steps);
});
it('should emit a noop when a service has an image downloading', async () => {
const current = createApp([], [], [], false);
const target = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, ...{ downloading: [1] } },
target,
);
expectStep('noop', steps);
});
it('should emit an updateMetadata step when a service has not changed but the release has', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[await createService({}, 1, 'main', 2, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('updateMetadata', steps);
});
it('should stop a container which has stoppped as its target', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectStep('stop', steps);
});
it('should not try to start a container which has exitted and has restart policy of no', async () => {
// Container is a "run once" type of service so it has exitted.
const current = createApp(
[
await createService(
{ restart: 'no', running: false },
1,
'main',
1,
1,
1,
{ containerId: 'run_once' },
),
],
[],
[],
false,
);
// Mark this container as previously being started
applicationManager.containerStarted['run_once'] = true;
// Now test that another start step is not added on this service
const target = createApp(
[
await createService(
{ restart: 'no', running: true },
1,
'main',
1,
1,
1,
{ containerId: 'run_once' },
),
],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).rejectStep('start');
});
it('should recreate a container if the target configuration changes', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
{
appId: 1,
dependent: 0,
imageId: 1,
releaseId: 1,
serviceId: 1,
name: 'main-image',
serviceName: 'main',
},
],
},
};
let current = createApp(
[await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
const target = createApp(
[await createService({ privileged: true }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
true,
);
// should see a 'stop'
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('stop').to.exist;
// remove the service since it's stopped...
current = createApp([], [defaultNetwork], [], false);
// now should see a 'start'
steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps)
.expectStep('start')
.forTarget((t) => t.serviceName === 'main').to.exist;
});
it('should not start a container when it depends on a service which is being installed', async () => {
const mainImage: Image = {
appId: 1,
dependent: 0,
imageId: 1,
releaseId: 1,
serviceId: 1,
name: 'main-image',
serviceName: 'main',
};
const depImage: Image = {
appId: 1,
dependent: 0,
imageId: 2,
releaseId: 1,
serviceId: 2,
name: 'dep-image',
serviceName: 'dep',
};
const availableImages = [mainImage, depImage];
const contextWithImages = { ...defaultContext, ...{ availableImages } };
try {
let current = createApp(
[
await createService({ running: false }, 1, 'dep', 1, 2, 2, {
status: 'Installing',
containerId: 'id',
}),
],
[defaultNetwork],
[],
false,
);
const target = createApp(
[
await createService({}, 1, 'main', 1, 1, 1, { dependsOn: ['dep'] }),
await createService({}, 1, 'dep', 1, 2, 2),
],
[defaultNetwork],
[],
true,
);
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps)
.expectStep('start')
.forTarget((t) => t.serviceName === 'dep').to.exist;
withSteps(steps)
.expectStep('start')
.forTarget((t) => t.serviceName === 'main').to.not.exist;
// we now make our current state have the 'dep' service as started...
current = createApp(
[await createService({}, 1, 'dep', 1, 2, 2, { containerId: 'id' })],
[defaultNetwork],
[],
false,
);
// We keep track of the containers that we've tried to start so that we
// dont spam start requests if the container hasn't started running
applicationManager.containerStarted['id'] = true;
// we should now see a start for the 'main' service...
steps = current.nextStepsForAppUpdate(
{ ...contextWithImages, ...{ containerIds: { dep: 'id' } } },
target,
);
withSteps(steps)
.expectStep('start')
.forTarget((t) => t.serviceName === 'main').to.exist;
} finally {
delete applicationManager.containerStarted['id'];
}
});
it('should emit a fetch step when an image has not been downloaded for a service', async () => {
const current = createApp([], [], [], false);
const target = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('fetch').to.exist;
});
it('should stop a container which has stoppped as its target', async () => {
const current = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
false,
);
const target = createApp(
[await createService({ running: false }, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('stop');
});
it('should create a start step when all that changes is a running state', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
{
appId: 1,
dependent: 0,
imageId: 1,
releaseId: 1,
serviceId: 1,
name: 'main-image',
serviceName: 'main',
},
],
},
};
const current = createApp(
[await createService({ running: false }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
const target = createApp(
[await createService({}, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
true,
);
// now should see a 'start'
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps)
.expectStep('start')
.forTarget((t) => t.serviceName === 'main').to.exist;
});
it('should not infer a fetch step when the download is already in progress', async () => {
const contextWithDownloading = {
...defaultContext,
...{
downloading: [1],
},
};
const current = createApp([], [], [], false);
const target = createApp(
[await createService({}, 1, 'main', 1, 1, 1)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(contextWithDownloading, target);
withSteps(steps).expectStep('fetch').forTarget('main').to.not.exist;
});
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
{
appId: 1,
dependent: 0,
imageId: 1,
releaseId: 1,
serviceId: 1,
name: 'main-image',
serviceName: 'main',
},
],
},
};
const labels = {
'io.balena.update.strategy': 'kill-then-download',
};
const current = createApp(
[
await createService(
{ labels, image: 'main-image' },
1,
'main',
1,
1,
1,
{},
),
],
[defaultNetwork],
[],
false,
);
const target = createApp(
[
await createService(
{ labels, image: 'main-image-2' },
1,
'main',
2,
1,
2,
{},
),
],
[defaultNetwork],
[],
true,
);
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
// next volatile state...
const afterKill = createApp([], [defaultNetwork], [], false);
steps = afterKill.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('fetch').to.exist;
const fetchStep = withSteps(steps).expectStep('fetch').asStep;
expect(fetchStep)
.to.have.property('image')
.that.has.property('name')
.that.equals('main-image-2');
});
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const contextWithImages = {
...defaultContext,
...{
downloading: [4],
availableImages: [
{
appId: 1,
releaseId: 1,
dependent: 0,
name: 'main-image',
imageId: 1,
serviceName: 'main',
serviceId: 1,
},
{
appId: 1,
releaseId: 1,
dependent: 0,
name: 'dep-image',
imageId: 2,
serviceName: 'dep',
serviceId: 2,
},
{
appId: 1,
releaseId: 2,
dependent: 0,
name: 'main-image-2',
imageId: 3,
serviceName: 'main',
serviceId: 1,
},
],
},
};
const current = createApp(
[
await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {
dependsOn: ['dep'],
}),
await createService({ image: 'dep-image' }, 1, 'dep', 1, 2, 2, {}),
],
[defaultNetwork],
[],
false,
);
const target = createApp(
[
await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 3, {
dependsOn: ['dep'],
}),
await createService({ image: 'dep-image-2' }, 1, 'dep', 2, 2, 4, {}),
],
[defaultNetwork],
[],
true,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('kill').forCurrent('main').to.not.exist;
});
it('should create several kill steps as long as there is no unmet dependencies', async () => {
const contextWithImages = {
...defaultContext,
...{
availableImages: [
{
appId: 1,
releaseId: 1,
dependent: 0,
name: 'main-image',
imageId: 1,
serviceName: 'main',
serviceId: 1,
},
{
appId: 1,
releaseId: 2,
dependent: 0,
name: 'main-image-2',
imageId: 2,
serviceName: 'main',
serviceId: 1,
},
],
},
};
const current = createApp(
[await createService({ image: 'main-image' }, 1, 'main', 1, 1, 1, {})],
[defaultNetwork],
[],
false,
);
const target = createApp(
[await createService({ image: 'main-image-2' }, 1, 'main', 2, 1, 2)],
[defaultNetwork],
[],
true,
);
let steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
// same states again...
steps = current.nextStepsForAppUpdate(contextWithImages, target);
withSteps(steps).expectStep('kill').forCurrent('main').to.exist;
});
it('should create a kill step when a service has to be updated but the strategy is kill-then-download', async () => {
const labels = {
'io.balena.update.strategy': 'kill-then-download',
};
const current = createApp([await createService({ labels })], [], [], false);
const target = createApp(
[await createService({ privileged: true })],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('kill').forCurrent('main');
});
it('should not infer a kill step with the default strategy if a dependency is not downloaded', async () => {
const current = createApp(
[await createService({ image: 'image1' })],
[],
[],
false,
);
const target = createApp(
[await createService({ image: 'image2' })],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('fetch');
withSteps(steps).rejectStep('kill');
});
it('should create several kill steps as long as there is no unmet dependencies', async () => {
const current = createApp(
[
await createService({}, 1, 'one', 1, 2),
await createService({}, 1, 'two', 1, 3),
await createService({}, 1, 'three', 1, 4),
],
[],
[],
false,
);
const target = createApp(
[await createService({}, 1, 'three', 1, 4)],
[],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('kill').to.have.length(2);
});
it('should not create a service when a network it depends on is not ready', async () => {
const current = createApp([], [defaultNetwork], [], false);
const target = createApp(
[await createService({ networks: ['test'] }, 1)],
[defaultNetwork, Network.fromComposeObject('test', 1, {})],
[],
true,
);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
withSteps(steps).expectStep('createNetwork');
withSteps(steps).rejectStep('start');
}); // no create service, is create network
});